From 56f7d52c9d30255ddf201a2fedfb8c73b31f35c5 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 23 Oct 2026 22:11:11 +0900 Subject: [PATCH 1/4] feat(cli): work without package.json and copy rule guides into .agents/code-style The CLI no longer requires package.json, so init, update, and doctor work in Gradle, pubspec, and Cargo-only projects. - copy the rule guides into .agents/code-style/ together with NOTICE.md and LICENSE-Apache-2.0.txt - link those copies from the AGENTS.md block for all projects - keep reading legacy node_modules/@pleaseai/code-style/rules/ links and migrate them in update - make doctor check that the linked guides exist - update both READMEs, rules.md, and the en/ko CLI docs Refs #65 --- apps/docs/src/content/docs/en/cli.mdx | 4 +- apps/docs/src/content/docs/ko/cli.mdx | 4 +- packages/cli/README.ko.md | 15 ++++-- packages/cli/README.md | 18 ++++--- packages/cli/rules.md | 5 +- packages/cli/src/appliers.ts | 9 +++- packages/cli/src/commands.ts | 67 ++++++++++++++----------- packages/cli/src/guides.ts | 49 ++++++++++++++++++ packages/cli/src/i18n.ts | 3 -- packages/cli/src/languages.ts | 12 +++-- packages/cli/test/appliers.test.ts | 49 +++++++++++++++--- packages/cli/test/index.test.ts | 71 +++++++++++++++++++++++++-- packages/cli/test/languages.test.ts | 7 ++- 13 files changed, 247 insertions(+), 66 deletions(-) create mode 100644 packages/cli/src/guides.ts diff --git a/apps/docs/src/content/docs/en/cli.mdx b/apps/docs/src/content/docs/en/cli.mdx index e73e3b0..533a329 100644 --- a/apps/docs/src/content/docs/en/cli.mdx +++ b/apps/docs/src/content/docs/en/cli.mdx @@ -10,7 +10,7 @@ Inspired by [ultracite](https://github.com/haydenbleasel/ultracite) and NaverPay ## Quick Start -Run from the root of any project that has a `package.json`: +Run from the root of your project. A `package.json` is optional: without one (Gradle, pubspec or Cargo-only projects) `init` offers only the AGENTS.md block and its guides, and `update` and `doctor` work as usual: @@ -124,7 +124,7 @@ My own content stays here untouched. The CLI **only** owns the content between the two marker comments — everything else in `AGENTS.md` is preserved verbatim. Running `update` twice in a row is a no-op; the block never duplicates. -The full canonical rules are shipped per language for AI assistants that prefer to read the long version: start with `node_modules/@pleaseai/code-style/rules/general.md` (every language), then the language guide listed in `node_modules/@pleaseai/code-style/rules.md` (for example, `node_modules/@pleaseai/code-style/rules/typescript.md` for TypeScript and JavaScript). +The full canonical rules are copied into your project per language for AI assistants that prefer to read the long version, so they resolve without `node_modules`: start with `.agents/code-style/general.md` (every language), then the guide of your language (for example, `.agents/code-style/typescript.md` for TypeScript and JavaScript). `NOTICE.md` and `LICENSE-Apache-2.0.txt` in the same directory carry the attribution for the adapted guides. Commit `.agents/code-style/`: `init` and `update` rewrite it, overwrite its files and remove the guide of a language that is no longer selected (other files there are never touched). `doctor` checks that every guide the block links exists. ## Options diff --git a/apps/docs/src/content/docs/ko/cli.mdx b/apps/docs/src/content/docs/ko/cli.mdx index 9ac0dc1..7d33554 100644 --- a/apps/docs/src/content/docs/ko/cli.mdx +++ b/apps/docs/src/content/docs/ko/cli.mdx @@ -10,7 +10,7 @@ sidebar: ## 빠른 시작 -`package.json`이 있는 프로젝트 루트에서 실행하세요: +프로젝트 루트에서 실행하세요. `package.json`은 필수가 아닙니다. 없는 프로젝트(Gradle, pubspec, Cargo 전용)에서는 `init`이 AGENTS.md 블록과 가이드만 제공하며, `update`와 `doctor`는 그대로 동작합니다: @@ -124,7 +124,7 @@ CLI는 `AGENTS.md`에 마커로 구분된 블록을 작성하므로, 주변에 CLI는 두 마커 주석 사이의 내용 **만** 관리하며 — `AGENTS.md`의 나머지 내용은 그대로 보존됩니다. `update`를 연속으로 두 번 실행해도 no-op이며, 블록이 중복되지 않습니다. -전체 원본 규칙은 AI 어시스턴트가 긴 버전을 읽기 원할 때 참고할 수 있도록 언어별로 나뉘어 함께 배포됩니다. `node_modules/@pleaseai/code-style/rules/general.md`(모든 언어)를 먼저 읽고, `node_modules/@pleaseai/code-style/rules.md`에 나열된 언어별 가이드를 읽으세요(예: TypeScript·JavaScript는 `node_modules/@pleaseai/code-style/rules/typescript.md`). +전체 원본 규칙은 AI 어시스턴트가 긴 버전을 읽기 원할 때 참고할 수 있도록 언어별로 나뉘어 프로젝트에 복사되므로 `node_modules` 없이도 읽을 수 있습니다. `.agents/code-style/general.md`(모든 언어)를 먼저 읽고, 사용하는 언어의 가이드를 읽으세요(예: TypeScript·JavaScript는 `.agents/code-style/typescript.md`). 같은 디렉터리의 `NOTICE.md`와 `LICENSE-Apache-2.0.txt`에 각색한 가이드의 출처 표기가 있습니다. `.agents/code-style/`은 커밋하세요. `init`과 `update`가 이 디렉터리를 다시 쓰며 파일을 덮어쓰고, 더 이상 선택하지 않은 언어의 가이드는 삭제합니다(디렉터리의 다른 파일은 건드리지 않습니다). `doctor`는 블록이 가리키는 가이드가 모두 있는지 확인합니다. ## 옵션 diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index 1d7e729..d7eb3a1 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -12,7 +12,8 @@ eslint/prettier/editorconfig/ast-grep 패키지를 설치하고, AI 코딩 어 ## 사용법 -`package.json`이 있는 프로젝트 루트에서 실행하세요: +프로젝트 루트에서 실행하세요. `package.json`은 필수가 아닙니다. 없는 프로젝트(Gradle, +pubspec, Cargo 전용)에서는 `init`이 AGENTS.md 블록과 가이드만 제공합니다: ```bash bunx @pleaseai/code-style # `init`과 동일 @@ -210,10 +211,14 @@ CLI는 아래 마커 사이의 내용만 관리합니다 — `AGENTS.md`의 나 ``` `@pleaseai/code-style`을 업그레이드한 뒤에는 `pleaseai-code-style update`를 -다시 실행하면 이 블록만 새로고침할 수 있습니다. 전체 규칙은 참고용으로 -언어별로 나뉘어 함께 배포됩니다. `node_modules/@pleaseai/code-style/rules/general.md`(모든 언어)를 -먼저 읽고, `node_modules/@pleaseai/code-style/rules.md`에 나열된 언어별 가이드를 읽으세요 -(예: TypeScript·JavaScript는 `node_modules/@pleaseai/code-style/rules/typescript.md`). +다시 실행하면 이 블록만 새로고침할 수 있습니다. 전체 규칙은 언어별로 나뉘어 프로젝트에 +복사되므로 `node_modules` 없이도 어떤 프로젝트에서나 읽을 수 있습니다. +`.agents/code-style/general.md`(모든 언어)를 먼저 읽고, 사용하는 언어의 가이드를 읽으세요 +(예: TypeScript·JavaScript는 `.agents/code-style/typescript.md`). 각색한 가이드의 출처 표기는 +`NOTICE.md`와 `LICENSE-Apache-2.0.txt`에 있습니다. `.agents/code-style/`은 커밋하세요. +`init`과 `update`가 이 디렉터리를 다시 쓰며 파일을 덮어쓰고, 더 이상 선택하지 않은 언어의 +가이드는 삭제합니다(디렉터리의 다른 파일은 건드리지 않습니다). `doctor`는 블록이 가리키는 +가이드가 모두 있는지 확인합니다. ## 옵션 diff --git a/packages/cli/README.md b/packages/cli/README.md index f11b5f9..cdce147 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,7 +12,8 @@ NaverPay's [`@naverpay/code-style-cli`](https://github.com/NaverPayDev/code-styl ## Usage -From the root of any project that has a `package.json`: +From the root of your project. A `package.json` is optional: without one (Gradle, +pubspec or Cargo-only projects) `init` offers only the AGENTS.md block and its guides: ```bash bunx @pleaseai/code-style # same as `init` @@ -392,12 +393,15 @@ The CLI owns only the content between these markers — everything else in ``` Re-run `pleaseai-code-style update` any time you upgrade `@pleaseai/code-style` -to refresh just this block. The full rules are shipped per language for -reference: start with `node_modules/@pleaseai/code-style/rules/general.md` -(every language), then the language guide listed in -`node_modules/@pleaseai/code-style/rules.md` (for example -`node_modules/@pleaseai/code-style/rules/typescript.md` for TypeScript and -JavaScript). +to refresh just this block. The full rules are copied into your project, per +language, so they resolve in every project (no `node_modules` needed): start +with `.agents/code-style/general.md` (every language), then the guide of your +language (for example `.agents/code-style/typescript.md` for TypeScript and +JavaScript), with `NOTICE.md` and `LICENSE-Apache-2.0.txt` for the attribution +of the adapted guides. Commit `.agents/code-style/`; `init` and `update` +rewrite it, overwrite the files and remove the guide of a language that is no +longer selected (other files in the directory are never touched). `doctor` +checks that every guide the block links exists. ## Options diff --git a/packages/cli/rules.md b/packages/cli/rules.md index 535b141..649c967 100644 --- a/packages/cli/rules.md +++ b/packages/cli/rules.md @@ -15,6 +15,9 @@ apply to every language, then the guide for the language you are writing: - [`rules/html-css.md`](./rules/html-css.md) — HTML and CSS `pleaseai-code-style init` and `update` point your `AGENTS.md` block at the -general guide plus the guide of each selected language. Languages are detected +general guide plus the guide of each selected language, and copy those guides +(with `NOTICE.md` and `LICENSE-Apache-2.0.txt`) into `.agents/code-style/` in +your project, so the links work without `node_modules`. Commit that directory; +`update` rewrites it. Languages are detected from marker files in the project root, or set with `--lang `; `update` keeps the languages the existing block links unless `--lang` is given. diff --git a/packages/cli/src/appliers.ts b/packages/cli/src/appliers.ts index 8aea6db..aac2922 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -10,8 +10,9 @@ import { upsertMarkerBlock, writePackageJson, } from './fs-utils.js' +import { syncGuides } from './guides.js' import { t } from './i18n.js' -import { detectLanguages, languagesFromAgentsBlock, RULES_PREFIX } from './languages.js' +import { detectLanguages, GUIDES_DIR, languagesFromAgentsBlock } from './languages.js' async function confirmOverwrite(file: string, auto: boolean): Promise { if (auto) { @@ -182,7 +183,7 @@ function wantsCodeOrganization(ctx: ToolContext): boolean { } function agentsBody(ctx: ToolContext, languages: Language[]): string { - const guides = ['general', ...languages].map(id => `\`${RULES_PREFIX}/${id}.md\``) + const guides = ['general', ...languages].map(id => `\`${GUIDES_DIR}/${id}.md\``) const pointer = guides.length > 1 ? `${guides.slice(0, -1).join(', ')} and ${guides.at(-1)}` : guides[0] @@ -200,6 +201,10 @@ export async function applyAgentsMd(ctx: ToolContext): Promise const languages = ctx.languages ?? languagesFromAgentsBlock(existing) ?? detectLanguages(ctx.cwd) const next = upsertMarkerBlock(existing, agentsBody(ctx, languages)) + const guides = syncGuides(ctx.cwd, languages) + result.created.push(...guides.created) + result.updated.push(...guides.updated) + if (existing == null) { writeFileSync(target, next) result.created.push(label) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2115fad..b99531f 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -10,10 +10,12 @@ import { detectPackageManager, installCommand, isPackageInstalled, + readFileOrNull, readPackageJson, } from './fs-utils.js' +import { guidePath } from './guides.js' import { t } from './i18n.js' -import { detectLanguages, LANGUAGES } from './languages.js' +import { detectLanguages, LANGUAGES, languagesFromAgentsBlock } from './languages.js' export interface CommandOptions { cwd: string @@ -21,11 +23,8 @@ export interface CommandOptions { languages?: Language[] } -function assertProjectRoot(cwd: string): void { - if (!existsSync(resolve(cwd, 'package.json'))) { - log.error(t('noPackageJson')) - process.exit(1) - } +function hasPackageJson(cwd: string): boolean { + return existsSync(resolve(cwd, 'package.json')) } function mergeResults(results: ToolApplyResult[]): ToolApplyResult { @@ -53,16 +52,19 @@ function reportResults(result: ToolApplyResult): void { // --- init ------------------------------------------------------------------- export async function runInit(opts: CommandOptions): Promise { - assertProjectRoot(opts.cwd) intro(t('appTitle')) - const pm = detectPackageManager(opts.cwd) - log.info(t('detectedPm')(pm)) - - const pkg = readPackageJson(opts.cwd) + const withPackages = hasPackageJson(opts.cwd) + const pm = withPackages ? detectPackageManager(opts.cwd) : undefined + const pkg = withPackages ? readPackageJson(opts.cwd) : undefined + if (pm != null) { + log.info(t('detectedPm')(pm)) + } - const options = TOOLS.map((tool) => { - const installed = tool.packages.length > 0 + const available = withPackages ? TOOLS : TOOLS.filter(tool => tool.packages.length === 0) + const options = available.map((tool) => { + const installed = pkg != null + && tool.packages.length > 0 && tool.packages.some(p => isPackageInstalled(pkg, p)) return { value: tool.id, @@ -70,10 +72,10 @@ export async function runInit(opts: CommandOptions): Promise { } }) - const initialValues = opts.autoAccept ? TOOLS.map(tool => tool.id) : undefined + const initialValues = opts.autoAccept ? available.map(tool => tool.id) : undefined const selectedIdsRaw = opts.autoAccept - ? TOOLS.map(tool => tool.id) + ? available.map(tool => tool.id) : await multiselect({ message: t('selectTools'), options, @@ -94,7 +96,7 @@ export async function runInit(opts: CommandOptions): Promise { } const selectedTools: Tool[] = selectedIds - .map(id => TOOLS.find(tool => tool.id === id)) + .map(id => available.find(tool => tool.id === id)) .filter((tool): tool is Tool => tool != null) let languages = opts.languages @@ -123,7 +125,7 @@ export async function runInit(opts: CommandOptions): Promise { new Set(selectedTools.flatMap(tool => tool.packages)), ) - if (packagesToInstall.length > 0) { + if (pm != null && packagesToInstall.length > 0) { const [cmd, args] = installCommand(pm, packagesToInstall) const spin = spinner() spin.start(t('installing')) @@ -152,7 +154,6 @@ export async function runInit(opts: CommandOptions): Promise { // --- update ----------------------------------------------------------------- export async function runUpdate(opts: CommandOptions): Promise { - assertProjectRoot(opts.cwd) intro(t('appTitle')) const result = await applyAgentsMd({ cwd: opts.cwd, autoAccept: true, selectedToolIds: [], languages: opts.languages }) reportResults(result) @@ -163,21 +164,20 @@ export async function runUpdate(opts: CommandOptions): Promise { // --- doctor ----------------------------------------------------------------- export function runDoctor(opts: CommandOptions): void { - assertProjectRoot(opts.cwd) - const pkg = readPackageJson(opts.cwd) - const pm = detectPackageManager(opts.cwd) - log.info(`${t('doctorHeading')}:`) - log.info(` ${t('detectedPm')(pm)}`) - // Package-based tools only — file-only tools (agents-md) are reported below. - for (const tool of TOOLS) { - if (tool.packages.length === 0) { - continue + if (hasPackageJson(opts.cwd)) { + const pkg = readPackageJson(opts.cwd) + log.info(` ${t('detectedPm')(detectPackageManager(opts.cwd))}`) + + for (const tool of TOOLS) { + if (tool.packages.length === 0) { + continue + } + const installed = tool.packages.every(p => isPackageInstalled(pkg, p)) + const suffix = installed ? t('doctorOk') : t('doctorMissing') + log.info(` ${tool.label} — ${suffix}`) } - const installed = tool.packages.every(p => isPackageInstalled(pkg, p)) - const suffix = installed ? t('doctorOk') : t('doctorMissing') - log.info(` ${tool.label} — ${suffix}`) } if (opts.languages != null) { @@ -199,4 +199,11 @@ export function runDoctor(opts: CommandOptions): void { const exists = existsSync(resolve(opts.cwd, f)) log.info(` ${f} — ${exists ? t('doctorOk') : t('doctorMissing')}`) } + + const linked = languagesFromAgentsBlock(readFileOrNull(resolve(opts.cwd, 'AGENTS.md'))) + for (const id of linked == null ? [] : ['general', ...linked]) { + const guide = guidePath(id) + const exists = existsSync(resolve(opts.cwd, guide)) + log.info(` ${guide} — ${exists ? t('doctorOk') : t('doctorMissing')}`) + } } diff --git a/packages/cli/src/guides.ts b/packages/cli/src/guides.ts new file mode 100644 index 0000000..067309e --- /dev/null +++ b/packages/cli/src/guides.ts @@ -0,0 +1,49 @@ +import type { Language } from './languages.js' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { GUIDES_DIR, LANGUAGES } from './languages.js' + +const PACKAGE_RULES = fileURLToPath(new URL('../rules/', import.meta.url)) + +// Attribution for guides adapted from CC BY / Apache-2.0 sources travels with them. +const ATTRIBUTION_FILES = ['NOTICE.md', 'LICENSE-Apache-2.0.txt'] + +export interface GuidesResult { + created: string[] + updated: string[] +} + +export function guidePath(id: string): string { + return `${GUIDES_DIR}/${id}.md` +} + +export function syncGuides(cwd: string, languages: Language[]): GuidesResult { + const result: GuidesResult = { created: [], updated: [] } + const dir = resolve(cwd, GUIDES_DIR) + mkdirSync(dir, { recursive: true }) + + const names = ['general.md', ...languages.map(language => `${language}.md`), ...ATTRIBUTION_FILES] + for (const name of names) { + const source = readFileSync(resolve(PACKAGE_RULES, name)) + const target = resolve(dir, name) + const label = `${GUIDES_DIR}/${name}` + if (!existsSync(target)) { + writeFileSync(target, source) + result.created.push(label) + } + else if (!readFileSync(target).equals(source)) { + writeFileSync(target, source) + result.updated.push(label) + } + } + + for (const language of LANGUAGES) { + const target = resolve(dir, `${language}.md`) + if (!languages.includes(language) && existsSync(target)) { + rmSync(target) + result.updated.push(`${GUIDES_DIR}/${language}.md (removed)`) + } + } + return result +} diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 732172c..65567a1 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -5,7 +5,6 @@ export type Locale = 'en' | 'ko' interface Messages { appTitle: string - noPackageJson: string detectedPm: (pm: string) => string selectTools: string alreadyInstalled: string @@ -43,7 +42,6 @@ interface Messages { const messages: Record = { en: { appTitle: 'PleaseAI code-style setup', - noPackageJson: 'package.json not found. Run this command from your project root.', detectedPm: pm => `Detected package manager: ${pm}`, selectTools: 'Select items to install or update', alreadyInstalled: '(installed)', @@ -108,7 +106,6 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e }, ko: { appTitle: 'PleaseAI code-style 설치', - noPackageJson: 'package.json이 없습니다. 프로젝트 루트에서 실행해주세요.', detectedPm: pm => `감지된 패키지 매니저: ${pm}`, selectTools: '설치하거나 업데이트할 항목을 선택하세요', alreadyInstalled: '(설치됨)', diff --git a/packages/cli/src/languages.ts b/packages/cli/src/languages.ts index 9c01c62..67b8006 100644 --- a/packages/cli/src/languages.ts +++ b/packages/cli/src/languages.ts @@ -6,7 +6,9 @@ export const LANGUAGES = ['typescript', 'dart', 'rust', 'java', 'go', 'python', export type Language = typeof LANGUAGES[number] -export const RULES_PREFIX = 'node_modules/@pleaseai/code-style/rules' +export const GUIDES_DIR = '.agents/code-style' + +const LEGACY_GUIDES_DIR = 'node_modules/@pleaseai/code-style/rules' const MARKERS: Array<{ language: Language, files: string[] }> = [ { language: 'typescript', files: ['package.json', 'tsconfig.json'] }, @@ -53,8 +55,12 @@ export function parseLangFlag(value: string | boolean | undefined): LangFlag { return { kind: 'languages', languages: inOrder(items) } } +function linksGuide(block: string, id: string): boolean { + return block.includes(`${GUIDES_DIR}/${id}.md`) || block.includes(`${LEGACY_GUIDES_DIR}/${id}.md`) +} + function isLanguageAwareBlock(block: string | undefined): block is string { - return block !== undefined && block.includes(`${RULES_PREFIX}/general.md`) + return block !== undefined && linksGuide(block, 'general') } export function languagesFromAgentsBlock(content: string | null): Language[] | undefined { @@ -62,5 +68,5 @@ export function languagesFromAgentsBlock(content: string | null): Language[] | u if (!isLanguageAwareBlock(block)) { return undefined } - return LANGUAGES.filter(language => block.includes(`${RULES_PREFIX}/${language}.md`)) + return LANGUAGES.filter(language => linksGuide(block, language)) } diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index bb9c6bb..18b1f03 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -169,12 +169,12 @@ describe('AGENTS.md rules block', () => { test('points only at rule files the package ships', async () => { await applyAgentsMd({ cwd: dir, autoAccept: true }) const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') - const linked = [...body.matchAll(/node_modules\/@pleaseai\/code-style\/([\w./-]+\.md)/g)].map(m => m[1]) - expect(linked).toEqual(['rules/general.md', 'rules/typescript.md']) + const linked = [...body.matchAll(/\.agents\/code-style\/([\w./-]+\.md)/g)].map(m => m[1]) + expect(linked).toEqual(['general.md', 'typescript.md']) const pkgRoot = join(import.meta.dir, '..') const { files } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')) as { files: string[] } - for (const path of linked) { + for (const path of linked.map(name => `rules/${name}`)) { expect(existsSync(join(pkgRoot, path))).toBe(true) expect(files.some(entry => path === entry || path.startsWith(`${entry}/`))).toBe(true) } @@ -203,8 +203,8 @@ describe('AGENTS.md rules block', () => { test('links the selected languages and drops the TypeScript bullets when typescript is not selected', async () => { await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart', 'rust'] }) const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') - const linked = [...body.matchAll(/node_modules\/@pleaseai\/code-style\/([\w./-]+\.md)/g)].map(m => m[1]) - expect(linked).toEqual(['rules/general.md', 'rules/dart.md', 'rules/rust.md']) + const linked = [...body.matchAll(/\.agents\/code-style\/([\w./-]+\.md)/g)].map(m => m[1]) + expect(linked).toEqual(['general.md', 'dart.md', 'rust.md']) expect(body).not.toContain('@pleaseai/eslint-config') expect(body).not.toContain('ESM only') expect(body).not.toContain('import.meta.vitest') @@ -216,8 +216,43 @@ describe('AGENTS.md rules block', () => { await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart'] }) await applyAgentsMd({ cwd: dir, autoAccept: true }) const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') - expect(body).toContain('rules/dart.md') - expect(body).not.toContain('rules/typescript.md') + expect(body).toContain('.agents/code-style/dart.md') + expect(body).not.toContain('.agents/code-style/typescript.md') + }) + + test('copies every guide the block links, plus the attribution files, into .agents/code-style', async () => { + for (const languages of [undefined, ['dart', 'rust'], [...LANGUAGES]] as const) { + rmSync(join(dir, '.agents'), { recursive: true, force: true }) + await applyAgentsMd({ cwd: dir, autoAccept: true, languages: languages == null ? undefined : [...languages] }) + const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') + const linked = [...body.matchAll(/\.agents\/code-style\/[\w./-]+\.md/g)].map(m => m[0]) + expect(linked.length).toBeGreaterThan(1) + for (const path of linked) { + expect(existsSync(join(dir, path))).toBe(true) + } + for (const name of ['NOTICE.md', 'LICENSE-Apache-2.0.txt']) { + expect(existsSync(join(dir, '.agents/code-style', name))).toBe(true) + } + } + }) + + test('removes the guide of a deselected language and leaves other files alone', async () => { + await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart', 'rust'] }) + writeFileSync(join(dir, '.agents/code-style/team-notes.md'), 'mine') + writeFileSync(join(dir, '.agents/code-style/dart.md'), 'stale') + const result = await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['rust'] }) + expect(existsSync(join(dir, '.agents/code-style/dart.md'))).toBe(false) + expect(existsSync(join(dir, '.agents/code-style/rust.md'))).toBe(true) + expect(readFileSync(join(dir, '.agents/code-style/team-notes.md'), 'utf-8')).toBe('mine') + expect(result.updated).toContain('.agents/code-style/dart.md (removed)') + }) + + test('refreshes a modified guide from the package copy', async () => { + await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart'] }) + writeFileSync(join(dir, '.agents/code-style/general.md'), 'edited') + await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart'] }) + const shipped = readFileSync(join(import.meta.dir, '../rules/general.md'), 'utf-8') + expect(readFileSync(join(dir, '.agents/code-style/general.md'), 'utf-8')).toBe(shipped) }) test('ships a guide for every language, covered by the package files', () => { diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 5ea330e..4363c9c 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -55,8 +55,73 @@ describe('--lang languages', () => { const res = run(['update', '--lang', 'dart'], dir) expect(res.status).toBe(0) const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') - expect(body).toContain('rules/dart.md') - expect(body).not.toContain('rules/typescript.md') + expect(body).toContain('.agents/code-style/dart.md') + expect(body).not.toContain('.agents/code-style/typescript.md') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('projects without a package.json', () => { + const MARKERS = [ + { marker: 'build.gradle.kts', language: 'java' }, + { marker: 'pubspec.yaml', language: 'dart' }, + { marker: 'Cargo.toml', language: 'rust' }, + ] + + for (const { marker, language } of MARKERS) { + test(`${marker}: init --yes, update and doctor succeed and link the ${language} guide`, () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-nopkg-')) + try { + writeFileSync(join(dir, marker), '') + const init = run(['init', '--yes'], dir) + expect(init.status).toBe(0) + expect(existsSync(join(dir, 'package.json'))).toBe(false) + const guide = `.agents/code-style/${language}.md` + expect(readFileSync(join(dir, 'AGENTS.md'), 'utf-8')).toContain(guide) + expect(existsSync(join(dir, guide))).toBe(true) + expect(existsSync(join(dir, 'eslint.config.mjs'))).toBe(false) + + expect(run(['update'], dir).status).toBe(0) + expect(readFileSync(join(dir, 'AGENTS.md'), 'utf-8')).toContain(guide) + + const doctor = run(['doctor'], dir) + expect(doctor.status).toBe(0) + expect(doctor.stdout).toContain(`${guide} — OK`) + expect(doctor.stdout).not.toContain('package manager') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + } + + test('doctor reports a guide the AGENTS.md block links but the project lacks', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-nopkg-')) + try { + writeFileSync(join(dir, 'pubspec.yaml'), '') + run(['update'], dir) + rmSync(join(dir, '.agents/code-style/dart.md')) + expect(run(['doctor'], dir).stdout).toContain('.agents/code-style/dart.md — missing') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('update rewrites a legacy node_modules block to the project copies and keeps its languages', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-legacy-')) + try { + writeFileSync(join(dir, 'AGENTS.md'), '\nread `node_modules/@pleaseai/code-style/rules/general.md` and `node_modules/@pleaseai/code-style/rules/dart.md`\n\n') + expect(run(['update'], dir).status).toBe(0) + const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') + expect(body).toContain('.agents/code-style/dart.md') + expect(body).not.toContain('node_modules/@pleaseai/code-style') + expect(body).not.toContain('typescript.md') + expect(existsSync(join(dir, '.agents/code-style/dart.md'))).toBe(true) + expect(existsSync(join(dir, '.agents/code-style/general.md'))).toBe(true) } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/packages/cli/test/languages.test.ts b/packages/cli/test/languages.test.ts index b1a1de7..9251622 100644 --- a/packages/cli/test/languages.test.ts +++ b/packages/cli/test/languages.test.ts @@ -71,7 +71,8 @@ describe('parseLangFlag', () => { describe('languagesFromAgentsBlock', () => { const block = (body: string): string => `\n${body}\n\n` - const guide = (id: string): string => `node_modules/@pleaseai/code-style/rules/${id}.md` + const guide = (id: string): string => `.agents/code-style/${id}.md` + const legacyGuide = (id: string): string => `node_modules/@pleaseai/code-style/rules/${id}.md` test('is undefined without a block', () => { expect(languagesFromAgentsBlock(null)).toBeUndefined() @@ -91,4 +92,8 @@ describe('languagesFromAgentsBlock', () => { expect(languagesFromAgentsBlock(block(`read ${guide('general')} and ${guide('dart')}`))).toEqual(['dart']) expect(languagesFromAgentsBlock(block(`read ${guide('general')}`))).toEqual([]) }) + + test('reads the links of a block written before the guides were copied into the project', () => { + expect(languagesFromAgentsBlock(block(`read ${legacyGuide('general')} and ${legacyGuide('dart')}`))).toEqual(['dart']) + }) }) From 81bb9040fb2847db9c1200fe4e89382942264623 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 23 Oct 2026 23:56:55 +0900 Subject: [PATCH 2/4] test(cli): mirror guides.ts and cover legacy and multi-language runs Move the guide sync tests into test/guides.test.ts, add e2e runs for update on a project carrying the v0.1.1 block and init on a pubspec.yaml + Cargo.toml project, and move the doctor --lang test out of the no-package.json group. Refs #65 --- packages/cli/test/appliers.test.ts | 34 +------------- packages/cli/test/guides.test.ts | 70 ++++++++++++++++++++++++++++ packages/cli/test/index.test.ts | 74 +++++++++++++++++++++++++++--- 3 files changed, 139 insertions(+), 39 deletions(-) create mode 100644 packages/cli/test/guides.test.ts diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index 18b1f03..4fa5f93 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -220,7 +220,7 @@ describe('AGENTS.md rules block', () => { expect(body).not.toContain('.agents/code-style/typescript.md') }) - test('copies every guide the block links, plus the attribution files, into .agents/code-style', async () => { + test('copies every guide the block links into .agents/code-style', async () => { for (const languages of [undefined, ['dart', 'rust'], [...LANGUAGES]] as const) { rmSync(join(dir, '.agents'), { recursive: true, force: true }) await applyAgentsMd({ cwd: dir, autoAccept: true, languages: languages == null ? undefined : [...languages] }) @@ -230,38 +230,6 @@ describe('AGENTS.md rules block', () => { for (const path of linked) { expect(existsSync(join(dir, path))).toBe(true) } - for (const name of ['NOTICE.md', 'LICENSE-Apache-2.0.txt']) { - expect(existsSync(join(dir, '.agents/code-style', name))).toBe(true) - } - } - }) - - test('removes the guide of a deselected language and leaves other files alone', async () => { - await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart', 'rust'] }) - writeFileSync(join(dir, '.agents/code-style/team-notes.md'), 'mine') - writeFileSync(join(dir, '.agents/code-style/dart.md'), 'stale') - const result = await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['rust'] }) - expect(existsSync(join(dir, '.agents/code-style/dart.md'))).toBe(false) - expect(existsSync(join(dir, '.agents/code-style/rust.md'))).toBe(true) - expect(readFileSync(join(dir, '.agents/code-style/team-notes.md'), 'utf-8')).toBe('mine') - expect(result.updated).toContain('.agents/code-style/dart.md (removed)') - }) - - test('refreshes a modified guide from the package copy', async () => { - await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart'] }) - writeFileSync(join(dir, '.agents/code-style/general.md'), 'edited') - await applyAgentsMd({ cwd: dir, autoAccept: true, languages: ['dart'] }) - const shipped = readFileSync(join(import.meta.dir, '../rules/general.md'), 'utf-8') - expect(readFileSync(join(dir, '.agents/code-style/general.md'), 'utf-8')).toBe(shipped) - }) - - test('ships a guide for every language, covered by the package files', () => { - const pkgRoot = join(import.meta.dir, '..') - const { files } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')) as { files: string[] } - for (const id of LANGUAGES) { - const path = `rules/${id}.md` - expect(existsSync(join(pkgRoot, path))).toBe(true) - expect(files.some(entry => path.startsWith(`${entry}/`))).toBe(true) } }) }) diff --git a/packages/cli/test/guides.test.ts b/packages/cli/test/guides.test.ts new file mode 100644 index 0000000..c85b024 --- /dev/null +++ b/packages/cli/test/guides.test.ts @@ -0,0 +1,70 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { guidePath, syncGuides } from '../src/guides.js' +import { LANGUAGES } from '../src/languages.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-guides-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +describe('guidePath', () => { + test('points at the guide inside .agents/code-style', () => { + expect(guidePath('dart')).toBe('.agents/code-style/dart.md') + }) +}) + +describe('syncGuides', () => { + test('copies general, the selected guides and the attribution files', () => { + for (const languages of [['dart', 'rust'], [...LANGUAGES]] as const) { + rmSync(join(dir, '.agents'), { recursive: true, force: true }) + syncGuides(dir, [...languages]) + for (const name of ['general.md', ...languages.map(language => `${language}.md`), 'NOTICE.md', 'LICENSE-Apache-2.0.txt']) { + expect(existsSync(join(dir, '.agents/code-style', name))).toBe(true) + } + } + }) + + test('reports nothing on a second sync with identical inputs', () => { + const first = syncGuides(dir, ['dart']) + expect(first.created.length).toBeGreaterThan(0) + expect(syncGuides(dir, ['dart'])).toEqual({ created: [], updated: [] }) + }) + + test('removes the guide of a deselected language and leaves other files alone', () => { + syncGuides(dir, ['dart', 'rust']) + writeFileSync(join(dir, '.agents/code-style/team-notes.md'), 'mine') + writeFileSync(join(dir, '.agents/code-style/dart.md'), 'stale') + const result = syncGuides(dir, ['rust']) + expect(existsSync(join(dir, '.agents/code-style/dart.md'))).toBe(false) + expect(existsSync(join(dir, '.agents/code-style/rust.md'))).toBe(true) + expect(readFileSync(join(dir, '.agents/code-style/team-notes.md'), 'utf-8')).toBe('mine') + expect(result.updated).toContain('.agents/code-style/dart.md (removed)') + }) + + test('refreshes a modified guide from the package copy', () => { + syncGuides(dir, ['dart']) + writeFileSync(join(dir, '.agents/code-style/general.md'), 'edited') + const result = syncGuides(dir, ['dart']) + const shipped = readFileSync(join(import.meta.dir, '../rules/general.md'), 'utf-8') + expect(readFileSync(join(dir, '.agents/code-style/general.md'), 'utf-8')).toBe(shipped) + expect(result.updated).toEqual(['.agents/code-style/general.md']) + }) + + test('ships a guide for every language, covered by the package files', () => { + const pkgRoot = join(import.meta.dir, '..') + const { files } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')) as { files: string[] } + for (const id of LANGUAGES) { + const path = `rules/${id}.md` + expect(existsSync(join(pkgRoot, path))).toBe(true) + expect(files.some(entry => path.startsWith(`${entry}/`))).toBe(true) + } + }) +}) diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 4363c9c..8ca3b5e 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -62,8 +62,44 @@ describe('--lang languages', () => { rmSync(dir, { recursive: true, force: true }) } }) + + test('doctor --lang reports the given languages instead of the detected ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-lang-')) + try { + writeFileSync(join(dir, 'package.json'), '{}') + const res = run(['doctor', '--lang', 'dart'], dir) + expect(res.status).toBe(0) + expect(res.stdout).toContain('Selected languages (--lang): dart') + expect(res.stdout).not.toContain('typescript') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) +// The AGENTS.md block exactly as code-style-v0.1.1 wrote it. +const V011_BODY = [ + '# PleaseAI Code Style', + '', + 'These rules are managed by `@pleaseai/code-style`. Run `pleaseai-code-style update`', + 'to refresh this block. Do not edit between the marker comments — your changes', + 'will be overwritten.', + '', + '- Formatter: `@pleaseai/eslint-config` (wraps `@antfu/eslint-config`)', + '- No semicolons, single quotes, 2-space indent, trailing commas, LF line endings', + '- ESM only — never emit `require`/`module.exports`', + '- TypeScript: `strict: true`, prefer `type` over `interface`, no implicit `any`', + '- Prefer named exports, early returns, `async`/`await`, optional chaining', + '- File size target: ≤ 500 lines', + '- Tests live under the package\'s `test/` (or `tests/`) root, mirroring the source path', + ' (`src/foo/bar.ts` → `test/foo/bar.test.ts`); no `import.meta.vitest` in-source tests', + '- Conventional Commits for all commit messages', + '', + 'For the full rules (what an AI coding assistant needs to know before writing code),', + 'read `node_modules/@pleaseai/code-style/rules.md`.', +].join('\n') + describe('projects without a package.json', () => { const MARKERS = [ { marker: 'build.gradle.kts', language: 'java' }, @@ -128,14 +164,40 @@ describe('projects without a package.json', () => { } }) - test('doctor --lang reports the given languages instead of the detected ones', () => { - const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-lang-')) + test('pubspec.yaml and Cargo.toml together: init --yes links both guides in guide order', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-nopkg-')) + try { + writeFileSync(join(dir, 'pubspec.yaml'), '') + writeFileSync(join(dir, 'Cargo.toml'), '') + expect(run(['init', '--yes'], dir).status).toBe(0) + const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') + const linked = [...body.matchAll(/\.agents\/code-style\/([\w./-]+\.md)/g)].map(m => m[1]) + expect(linked).toEqual(['general.md', 'dart.md', 'rust.md']) + expect(existsSync(join(dir, '.agents/code-style/dart.md'))).toBe(true) + expect(existsSync(join(dir, '.agents/code-style/rust.md'))).toBe(true) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('existing JS projects', () => { + test('update on a JS project carrying the v0.1.1 block leaves no broken link', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-v011-')) try { writeFileSync(join(dir, 'package.json'), '{}') - const res = run(['doctor', '--lang', 'dart'], dir) - expect(res.status).toBe(0) - expect(res.stdout).toContain('Selected languages (--lang): dart') - expect(res.stdout).not.toContain('typescript') + writeFileSync(join(dir, 'AGENTS.md'), `\n${V011_BODY}\n\n`) + expect(run(['update'], dir).status).toBe(0) + const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') + expect(body).not.toContain('node_modules/@pleaseai/code-style') + expect(body).toContain('.agents/code-style/general.md') + expect(body).toContain('.agents/code-style/typescript.md') + const linked = [...body.matchAll(/\.agents\/code-style\/[\w./-]+\.md/g)].map(m => m[0]) + expect(linked.length).toBeGreaterThan(0) + for (const path of linked) { + expect(existsSync(join(dir, path))).toBe(true) + } } finally { rmSync(dir, { recursive: true, force: true }) From 8d352b001aa7cf47914200b080106e688d3ea5b4 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 24 Oct 2026 05:47:02 +0900 Subject: [PATCH 3/4] fix(cli): refuse symlinked guide paths and check linked guides in doctor - syncGuides validates .agents, .agents/code-style and every guide it writes or removes before changing anything, and stops on a symlink - doctor checks the guide paths the AGENTS.md block actually links, legacy node_modules links included - doctor skips the package-tool config files without a package.json - AGENTS.md pointers reuse guidePath - docs and usage say update refreshes the block and its guides Refs #65 --- apps/docs/src/content/docs/en/cli.mdx | 4 ++-- apps/docs/src/content/docs/ko/cli.mdx | 4 ++-- packages/cli/README.ko.md | 6 +++--- packages/cli/README.md | 8 ++++---- packages/cli/src/appliers.ts | 6 +++--- packages/cli/src/commands.ts | 21 ++++++++----------- packages/cli/src/guides.ts | 24 ++++++++++++++++------ packages/cli/src/i18n.ts | 7 +++++-- packages/cli/src/languages.ts | 14 ++++++++++++- packages/cli/test/guides.test.ts | 29 +++++++++++++++++++++++++-- packages/cli/test/index.test.ts | 20 +++++++++++++++++- packages/cli/test/languages.test.ts | 18 +++++++++++++++-- 12 files changed, 120 insertions(+), 41 deletions(-) diff --git a/apps/docs/src/content/docs/en/cli.mdx b/apps/docs/src/content/docs/en/cli.mdx index 533a329..2539384 100644 --- a/apps/docs/src/content/docs/en/cli.mdx +++ b/apps/docs/src/content/docs/en/cli.mdx @@ -48,7 +48,7 @@ pleaseai-code-style init --yes # non-interactive, accept defaults ### `update` -Refresh **only** the `AGENTS.md` rules block without touching packages or other config files. Use this after bumping `@pleaseai/code-style` to pull in the latest rules. +Refresh the `AGENTS.md` rules block and the guides in `.agents/code-style/` without touching packages or other config files. Use this after bumping `@pleaseai/code-style` to pull in the latest rules. ```bash pleaseai-code-style update @@ -122,7 +122,7 @@ These rules are managed by `@pleaseai/code-style`. Run My own content stays here untouched. ``` -The CLI **only** owns the content between the two marker comments — everything else in `AGENTS.md` is preserved verbatim. Running `update` twice in a row is a no-op; the block never duplicates. +In `AGENTS.md`, the CLI **only** owns the content between the two marker comments — everything else is preserved verbatim. Running `update` twice in a row is a no-op; the block never duplicates. The full canonical rules are copied into your project per language for AI assistants that prefer to read the long version, so they resolve without `node_modules`: start with `.agents/code-style/general.md` (every language), then the guide of your language (for example, `.agents/code-style/typescript.md` for TypeScript and JavaScript). `NOTICE.md` and `LICENSE-Apache-2.0.txt` in the same directory carry the attribution for the adapted guides. Commit `.agents/code-style/`: `init` and `update` rewrite it, overwrite its files and remove the guide of a language that is no longer selected (other files there are never touched). `doctor` checks that every guide the block links exists. diff --git a/apps/docs/src/content/docs/ko/cli.mdx b/apps/docs/src/content/docs/ko/cli.mdx index 7d33554..31d5fb0 100644 --- a/apps/docs/src/content/docs/ko/cli.mdx +++ b/apps/docs/src/content/docs/ko/cli.mdx @@ -48,7 +48,7 @@ pleaseai-code-style init --yes # 비대화형, 기본값 수락 ### `update` -패키지나 다른 설정 파일을 건드리지 않고 `AGENTS.md` 규칙 블록 **만** 새로 고칩니다. `@pleaseai/code-style`을 업그레이드한 뒤 최신 규칙을 적용할 때 사용하세요. +패키지나 다른 설정 파일을 건드리지 않고 `AGENTS.md` 규칙 블록과 `.agents/code-style/`의 가이드를 새로 고칩니다. `@pleaseai/code-style`을 업그레이드한 뒤 최신 규칙을 적용할 때 사용하세요. ```bash pleaseai-code-style update @@ -122,7 +122,7 @@ CLI는 `AGENTS.md`에 마커로 구분된 블록을 작성하므로, 주변에 내 콘텐츠는 그대로 유지됩니다. ``` -CLI는 두 마커 주석 사이의 내용 **만** 관리하며 — `AGENTS.md`의 나머지 내용은 그대로 보존됩니다. `update`를 연속으로 두 번 실행해도 no-op이며, 블록이 중복되지 않습니다. +`AGENTS.md`에서 CLI는 두 마커 주석 사이의 내용 **만** 관리하며 — 나머지 내용은 그대로 보존됩니다. `update`를 연속으로 두 번 실행해도 no-op이며, 블록이 중복되지 않습니다. 전체 원본 규칙은 AI 어시스턴트가 긴 버전을 읽기 원할 때 참고할 수 있도록 언어별로 나뉘어 프로젝트에 복사되므로 `node_modules` 없이도 읽을 수 있습니다. `.agents/code-style/general.md`(모든 언어)를 먼저 읽고, 사용하는 언어의 가이드를 읽으세요(예: TypeScript·JavaScript는 `.agents/code-style/typescript.md`). 같은 디렉터리의 `NOTICE.md`와 `LICENSE-Apache-2.0.txt`에 각색한 가이드의 출처 표기가 있습니다. `.agents/code-style/`은 커밋하세요. `init`과 `update`가 이 디렉터리를 다시 쓰며 파일을 덮어쓰고, 더 이상 선택하지 않은 언어의 가이드는 삭제합니다(디렉터리의 다른 파일은 건드리지 않습니다). `doctor`는 블록이 가리키는 가이드가 모두 있는지 확인합니다. diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index d7eb3a1..f9130da 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -18,7 +18,7 @@ pubspec, Cargo 전용)에서는 `init`이 AGENTS.md 블록과 가이드만 제 ```bash bunx @pleaseai/code-style # `init`과 동일 bunx @pleaseai/code-style init # 대화형 설정 -bunx @pleaseai/code-style update # AGENTS.md 규칙 블록만 다시 적용 +bunx @pleaseai/code-style update # AGENTS.md 규칙 블록과 가이드 다시 적용 bunx @pleaseai/code-style doctor # 현재 프로젝트 상태 확인 bunx @pleaseai/code-style check # 파일 이름과 테스트 경로 검사 (ADR-0022) ``` @@ -201,7 +201,7 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 하나라도 있 ## `AGENTS.md` 블록 -CLI는 아래 마커 사이의 내용만 관리합니다 — `AGENTS.md`의 나머지 내용은 +`AGENTS.md`에서 CLI는 아래 마커 사이의 내용만 관리합니다 — 나머지 내용은 사용자의 것이며 그대로 보존됩니다: ```md @@ -211,7 +211,7 @@ CLI는 아래 마커 사이의 내용만 관리합니다 — `AGENTS.md`의 나 ``` `@pleaseai/code-style`을 업그레이드한 뒤에는 `pleaseai-code-style update`를 -다시 실행하면 이 블록만 새로고침할 수 있습니다. 전체 규칙은 언어별로 나뉘어 프로젝트에 +다시 실행하면 이 블록과 블록이 가리키는 가이드를 새로고침할 수 있습니다. 전체 규칙은 언어별로 나뉘어 프로젝트에 복사되므로 `node_modules` 없이도 어떤 프로젝트에서나 읽을 수 있습니다. `.agents/code-style/general.md`(모든 언어)를 먼저 읽고, 사용하는 언어의 가이드를 읽으세요 (예: TypeScript·JavaScript는 `.agents/code-style/typescript.md`). 각색한 가이드의 출처 표기는 diff --git a/packages/cli/README.md b/packages/cli/README.md index cdce147..1a514a8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -18,7 +18,7 @@ pubspec or Cargo-only projects) `init` offers only the AGENTS.md block and its g ```bash bunx @pleaseai/code-style # same as `init` bunx @pleaseai/code-style init # interactive setup -bunx @pleaseai/code-style update # re-apply the AGENTS.md rules block only +bunx @pleaseai/code-style update # re-apply the AGENTS.md rules block and its guides bunx @pleaseai/code-style doctor # check current project status bunx @pleaseai/code-style check # check file names and test paths (ADR-0022) ``` @@ -383,8 +383,8 @@ does not catch lost tests. ## `AGENTS.md` block -The CLI owns only the content between these markers — everything else in -`AGENTS.md` is your content and is preserved verbatim: +In `AGENTS.md`, the CLI owns only the content between these markers — everything +else is your content and is preserved verbatim: ```md @@ -393,7 +393,7 @@ The CLI owns only the content between these markers — everything else in ``` Re-run `pleaseai-code-style update` any time you upgrade `@pleaseai/code-style` -to refresh just this block. The full rules are copied into your project, per +to refresh this block and the guides it links. The full rules are copied into your project, per language, so they resolve in every project (no `node_modules` needed): start with `.agents/code-style/general.md` (every language), then the guide of your language (for example `.agents/code-style/typescript.md` for TypeScript and diff --git a/packages/cli/src/appliers.ts b/packages/cli/src/appliers.ts index aac2922..5ff87b4 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -10,9 +10,9 @@ import { upsertMarkerBlock, writePackageJson, } from './fs-utils.js' -import { syncGuides } from './guides.js' +import { guidePath, syncGuides } from './guides.js' import { t } from './i18n.js' -import { detectLanguages, GUIDES_DIR, languagesFromAgentsBlock } from './languages.js' +import { detectLanguages, languagesFromAgentsBlock } from './languages.js' async function confirmOverwrite(file: string, auto: boolean): Promise { if (auto) { @@ -183,7 +183,7 @@ function wantsCodeOrganization(ctx: ToolContext): boolean { } function agentsBody(ctx: ToolContext, languages: Language[]): string { - const guides = ['general', ...languages].map(id => `\`${GUIDES_DIR}/${id}.md\``) + const guides = ['general', ...languages].map(id => `\`${guidePath(id)}\``) const pointer = guides.length > 1 ? `${guides.slice(0, -1).join(', ')} and ${guides.at(-1)}` : guides[0] diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index b99531f..13e62b6 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -13,9 +13,8 @@ import { readFileOrNull, readPackageJson, } from './fs-utils.js' -import { guidePath } from './guides.js' import { t } from './i18n.js' -import { detectLanguages, LANGUAGES, languagesFromAgentsBlock } from './languages.js' +import { detectLanguages, guidePathsFromAgentsBlock, LANGUAGES } from './languages.js' export interface CommandOptions { cwd: string @@ -166,7 +165,8 @@ export async function runUpdate(opts: CommandOptions): Promise { export function runDoctor(opts: CommandOptions): void { log.info(`${t('doctorHeading')}:`) - if (hasPackageJson(opts.cwd)) { + const packageBased = hasPackageJson(opts.cwd) + if (packageBased) { const pkg = readPackageJson(opts.cwd) log.info(` ${t('detectedPm')(detectPackageManager(opts.cwd))}`) @@ -188,21 +188,16 @@ export function runDoctor(opts: CommandOptions): void { log.info(` ${languages.length > 0 ? t('doctorLanguages')(languages.join(', ')) : t('doctorNoLanguages')}`) } - // File presence checks. - const files = [ - 'eslint.config.mjs', - '.editorconfig', - 'AGENTS.md', - 'sgconfig.yml', - ] + // File presence checks. Without a package.json, init offers only the AGENTS.md block. + const files = packageBased + ? ['eslint.config.mjs', '.editorconfig', 'AGENTS.md', 'sgconfig.yml'] + : ['AGENTS.md'] for (const f of files) { const exists = existsSync(resolve(opts.cwd, f)) log.info(` ${f} — ${exists ? t('doctorOk') : t('doctorMissing')}`) } - const linked = languagesFromAgentsBlock(readFileOrNull(resolve(opts.cwd, 'AGENTS.md'))) - for (const id of linked == null ? [] : ['general', ...linked]) { - const guide = guidePath(id) + for (const guide of guidePathsFromAgentsBlock(readFileOrNull(resolve(opts.cwd, 'AGENTS.md'))) ?? []) { const exists = existsSync(resolve(opts.cwd, guide)) log.info(` ${guide} — ${exists ? t('doctorOk') : t('doctorMissing')}`) } diff --git a/packages/cli/src/guides.ts b/packages/cli/src/guides.ts index 067309e..5ef1d26 100644 --- a/packages/cli/src/guides.ts +++ b/packages/cli/src/guides.ts @@ -1,7 +1,8 @@ import type { Language } from './languages.js' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { t } from './i18n.js' import { GUIDES_DIR, LANGUAGES } from './languages.js' const PACKAGE_RULES = fileURLToPath(new URL('../rules/', import.meta.url)) @@ -18,12 +19,23 @@ export function guidePath(id: string): string { return `${GUIDES_DIR}/${id}.md` } +// A committed symlink would redirect guide writes and deletions outside the project. +function assertNotSymlink(cwd: string, path: string): void { + if (lstatSync(resolve(cwd, path), { throwIfNoEntry: false })?.isSymbolicLink()) { + throw new Error(t('guideSymlink')(path)) + } +} + export function syncGuides(cwd: string, languages: Language[]): GuidesResult { const result: GuidesResult = { created: [], updated: [] } + const names = ['general.md', ...languages.map(language => `${language}.md`), ...ATTRIBUTION_FILES] + const removed = LANGUAGES.filter(language => !languages.includes(language)).map(language => `${GUIDES_DIR}/${language}.md`) + for (const path of ['.agents', GUIDES_DIR, ...names.map(name => `${GUIDES_DIR}/${name}`), ...removed]) { + assertNotSymlink(cwd, path) + } const dir = resolve(cwd, GUIDES_DIR) mkdirSync(dir, { recursive: true }) - const names = ['general.md', ...languages.map(language => `${language}.md`), ...ATTRIBUTION_FILES] for (const name of names) { const source = readFileSync(resolve(PACKAGE_RULES, name)) const target = resolve(dir, name) @@ -38,11 +50,11 @@ export function syncGuides(cwd: string, languages: Language[]): GuidesResult { } } - for (const language of LANGUAGES) { - const target = resolve(dir, `${language}.md`) - if (!languages.includes(language) && existsSync(target)) { + for (const label of removed) { + const target = resolve(cwd, label) + if (existsSync(target)) { rmSync(target) - result.updated.push(`${GUIDES_DIR}/${language}.md (removed)`) + result.updated.push(`${label} (removed)`) } } return result diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 65567a1..2a295a8 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -22,6 +22,7 @@ interface Messages { doctorHeading: string doctorOk: string doctorMissing: string + guideSymlink: (path: string) => string done: string cancelled: string unknownCommand: (cmd: string) => string @@ -59,6 +60,7 @@ const messages: Record = { doctorHeading: 'Project status', doctorOk: 'OK', doctorMissing: 'missing', + guideSymlink: path => `${path} is a symlink; replace it with a regular file or directory so guides are not written outside the project.`, done: 'Done!', cancelled: 'Cancelled.', unknownCommand: cmd => `Unknown command: ${cmd}`, @@ -77,7 +79,7 @@ const messages: Record = { Commands: init Interactive setup (select packages + write configs) - update Re-apply the AGENTS.md rules block only + update Re-apply the AGENTS.md rules block and its guides doctor Check current project status check Check file names, test paths, and shared test helpers (see check --help) @@ -123,6 +125,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e doctorHeading: '프로젝트 상태', doctorOk: '정상', doctorMissing: '없음', + guideSymlink: path => `${path}이(가) 심볼릭 링크입니다. 가이드가 프로젝트 밖에 쓰이지 않도록 일반 파일이나 디렉터리로 바꾸세요.`, done: '완료!', cancelled: '취소되었습니다.', unknownCommand: cmd => `알 수 없는 명령어: ${cmd}`, @@ -141,7 +144,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e 명령어: init 대화형 설치 (패키지 선택 + 설정 파일 생성) - update AGENTS.md 룰 블록만 다시 적용 + update AGENTS.md 룰 블록과 가이드 다시 적용 doctor 현재 프로젝트 상태 점검 check 파일 이름, 테스트 경로, 공유 테스트 헬퍼 검사 (check --help 참고) diff --git a/packages/cli/src/languages.ts b/packages/cli/src/languages.ts index 67b8006..8efe849 100644 --- a/packages/cli/src/languages.ts +++ b/packages/cli/src/languages.ts @@ -55,8 +55,12 @@ export function parseLangFlag(value: string | boolean | undefined): LangFlag { return { kind: 'languages', languages: inOrder(items) } } +function linkedGuidePath(block: string, id: string): string | undefined { + return [`${GUIDES_DIR}/${id}.md`, `${LEGACY_GUIDES_DIR}/${id}.md`].find(path => block.includes(path)) +} + function linksGuide(block: string, id: string): boolean { - return block.includes(`${GUIDES_DIR}/${id}.md`) || block.includes(`${LEGACY_GUIDES_DIR}/${id}.md`) + return linkedGuidePath(block, id) !== undefined } function isLanguageAwareBlock(block: string | undefined): block is string { @@ -70,3 +74,11 @@ export function languagesFromAgentsBlock(content: string | null): Language[] | u } return LANGUAGES.filter(language => linksGuide(block, language)) } + +export function guidePathsFromAgentsBlock(content: string | null): string[] | undefined { + const block = content == null ? undefined : readMarkerBlock(content) + if (!isLanguageAwareBlock(block)) { + return undefined + } + return ['general', ...LANGUAGES].flatMap(id => linkedGuidePath(block, id) ?? []) +} diff --git a/packages/cli/test/guides.test.ts b/packages/cli/test/guides.test.ts index c85b024..0bd4d5b 100644 --- a/packages/cli/test/guides.test.ts +++ b/packages/cli/test/guides.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' @@ -27,7 +27,8 @@ describe('syncGuides', () => { rmSync(join(dir, '.agents'), { recursive: true, force: true }) syncGuides(dir, [...languages]) for (const name of ['general.md', ...languages.map(language => `${language}.md`), 'NOTICE.md', 'LICENSE-Apache-2.0.txt']) { - expect(existsSync(join(dir, '.agents/code-style', name))).toBe(true) + const shipped = readFileSync(join(import.meta.dir, '../rules', name), 'utf-8') + expect(readFileSync(join(dir, '.agents/code-style', name), 'utf-8')).toBe(shipped) } } }) @@ -58,6 +59,30 @@ describe('syncGuides', () => { expect(result.updated).toEqual(['.agents/code-style/general.md']) }) + test('refuses to write a guide through a symlinked file or directory', () => { + const outside = mkdtempSync(join(tmpdir(), 'pleaseai-cli-outside-')) + try { + writeFileSync(join(outside, 'target.md'), 'outside') + mkdirSync(join(dir, '.agents/code-style'), { recursive: true }) + symlinkSync(join(outside, 'target.md'), join(dir, '.agents/code-style/general.md')) + expect(() => syncGuides(dir, ['dart'])).toThrow('.agents/code-style/general.md is a symlink') + expect(readFileSync(join(outside, 'target.md'), 'utf-8')).toBe('outside') + + rmSync(join(dir, '.agents/code-style/general.md')) + symlinkSync(join(outside, 'target.md'), join(dir, '.agents/code-style/NOTICE.md')) + expect(() => syncGuides(dir, ['dart'])).toThrow('.agents/code-style/NOTICE.md is a symlink') + expect(existsSync(join(dir, '.agents/code-style/general.md'))).toBe(false) + + rmSync(join(dir, '.agents'), { recursive: true, force: true }) + symlinkSync(outside, join(dir, '.agents')) + expect(() => syncGuides(dir, ['dart'])).toThrow('.agents is a symlink') + expect(existsSync(join(outside, 'code-style'))).toBe(false) + } + finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + test('ships a guide for every language, covered by the package files', () => { const pkgRoot = join(import.meta.dir, '..') const { files } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')) as { files: string[] } diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 8ca3b5e..7a387a5 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -127,6 +127,7 @@ describe('projects without a package.json', () => { expect(doctor.status).toBe(0) expect(doctor.stdout).toContain(`${guide} — OK`) expect(doctor.stdout).not.toContain('package manager') + expect(doctor.stdout).not.toContain('eslint.config.mjs') } finally { rmSync(dir, { recursive: true, force: true }) @@ -147,6 +148,23 @@ describe('projects without a package.json', () => { } }) + test('doctor checks the guide paths a legacy node_modules block links', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-legacy-')) + try { + const legacy = 'node_modules/@pleaseai/code-style/rules' + writeFileSync(join(dir, 'AGENTS.md'), `\nread \`${legacy}/general.md\` and \`${legacy}/dart.md\`\n\n`) + mkdirSync(join(dir, legacy), { recursive: true }) + writeFileSync(join(dir, legacy, 'general.md'), '') + const res = run(['doctor'], dir) + expect(res.stdout).toContain(`${legacy}/general.md — OK`) + expect(res.stdout).toContain(`${legacy}/dart.md — missing`) + expect(res.stdout).not.toContain('.agents/code-style') + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + test('update rewrites a legacy node_modules block to the project copies and keeps its languages', () => { const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-legacy-')) try { diff --git a/packages/cli/test/languages.test.ts b/packages/cli/test/languages.test.ts index 9251622..01f95e9 100644 --- a/packages/cli/test/languages.test.ts +++ b/packages/cli/test/languages.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { detectLanguages, languagesFromAgentsBlock, parseLangFlag } from '../src/languages.js' +import { detectLanguages, guidePathsFromAgentsBlock, languagesFromAgentsBlock, parseLangFlag } from '../src/languages.js' let dir: string @@ -79,8 +79,9 @@ describe('languagesFromAgentsBlock', () => { expect(languagesFromAgentsBlock(`see ${guide('general')} and ${guide('dart')}`)).toBeUndefined() }) - test('is undefined for a legacy block that does not link the general guide', () => { + test('is undefined for a block that does not link the general guide', () => { expect(languagesFromAgentsBlock(block(`read ${guide('typescript')}`))).toBeUndefined() + expect(languagesFromAgentsBlock(block(`read ${legacyGuide('typescript')}`))).toBeUndefined() }) test('is undefined for the v0.1.1 block, so update falls back to detection', () => { @@ -97,3 +98,16 @@ describe('languagesFromAgentsBlock', () => { expect(languagesFromAgentsBlock(block(`read ${legacyGuide('general')} and ${legacyGuide('dart')}`))).toEqual(['dart']) }) }) + +describe('guidePathsFromAgentsBlock', () => { + const block = (body: string): string => `\n${body}\n\n` + + test('returns the guide paths exactly as the block links them', () => { + expect(guidePathsFromAgentsBlock(block('read `.agents/code-style/general.md` and `.agents/code-style/dart.md`'))).toEqual(['.agents/code-style/general.md', '.agents/code-style/dart.md']) + expect(guidePathsFromAgentsBlock(block('read `node_modules/@pleaseai/code-style/rules/general.md` and `node_modules/@pleaseai/code-style/rules/dart.md`'))).toEqual(['node_modules/@pleaseai/code-style/rules/general.md', 'node_modules/@pleaseai/code-style/rules/dart.md']) + }) + + test('is undefined for a block that does not link the general guide', () => { + expect(guidePathsFromAgentsBlock(block('read `node_modules/@pleaseai/code-style/rules.md`'))).toBeUndefined() + }) +}) From a715ce615d26bfd84abd417c7f8df6f390cd85ed Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 24 Oct 2026 05:53:26 +0900 Subject: [PATCH 4/4] fix(cli): check guide paths before init installs and unlink removed guide symlinks - init checks the guide paths for symlinks before installing packages whenever the AGENTS.md block is selected, so a symlinked .agents stops it before anything is written - removing a deselected guide only unlinks it, so the symlink check now covers the written paths only, and dangling links are removed too Refs #65 --- packages/cli/src/commands.ts | 5 +++++ packages/cli/src/guides.ts | 20 +++++++++++++++----- packages/cli/test/guides.test.ts | 21 ++++++++++++++++++++- packages/cli/test/index.test.ts | 22 +++++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 13e62b6..470d9ef 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -13,6 +13,7 @@ import { readFileOrNull, readPackageJson, } from './fs-utils.js' +import { assertGuidesWritable } from './guides.js' import { t } from './i18n.js' import { detectLanguages, guidePathsFromAgentsBlock, LANGUAGES } from './languages.js' @@ -118,6 +119,10 @@ export async function runInit(opts: CommandOptions): Promise { languages = chosen } } + if (selectedTools.some(tool => tool.id === 'agents-md')) { + // Fail before installing anything rather than leave a half-configured project. + assertGuidesWritable(opts.cwd, languages ?? []) + } // 1. Install packages (de-duped) for selected tools. const packagesToInstall = Array.from( diff --git a/packages/cli/src/guides.ts b/packages/cli/src/guides.ts index 5ef1d26..c538551 100644 --- a/packages/cli/src/guides.ts +++ b/packages/cli/src/guides.ts @@ -26,13 +26,22 @@ function assertNotSymlink(cwd: string, path: string): void { } } +function guideNames(languages: Language[]): string[] { + return ['general.md', ...languages.map(language => `${language}.md`), ...ATTRIBUTION_FILES] +} + +// Removing a deselected guide only unlinks a symlink, so only the written paths are checked. +export function assertGuidesWritable(cwd: string, languages: Language[]): void { + for (const path of ['.agents', GUIDES_DIR, ...guideNames(languages).map(name => `${GUIDES_DIR}/${name}`)]) { + assertNotSymlink(cwd, path) + } +} + export function syncGuides(cwd: string, languages: Language[]): GuidesResult { const result: GuidesResult = { created: [], updated: [] } - const names = ['general.md', ...languages.map(language => `${language}.md`), ...ATTRIBUTION_FILES] + assertGuidesWritable(cwd, languages) + const names = guideNames(languages) const removed = LANGUAGES.filter(language => !languages.includes(language)).map(language => `${GUIDES_DIR}/${language}.md`) - for (const path of ['.agents', GUIDES_DIR, ...names.map(name => `${GUIDES_DIR}/${name}`), ...removed]) { - assertNotSymlink(cwd, path) - } const dir = resolve(cwd, GUIDES_DIR) mkdirSync(dir, { recursive: true }) @@ -52,7 +61,8 @@ export function syncGuides(cwd: string, languages: Language[]): GuidesResult { for (const label of removed) { const target = resolve(cwd, label) - if (existsSync(target)) { + // lstat, unlike existsSync, also sees a dangling symlink. + if (lstatSync(target, { throwIfNoEntry: false }) !== undefined) { rmSync(target) result.updated.push(`${label} (removed)`) } diff --git a/packages/cli/test/guides.test.ts b/packages/cli/test/guides.test.ts index 0bd4d5b..82f24e3 100644 --- a/packages/cli/test/guides.test.ts +++ b/packages/cli/test/guides.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' @@ -83,6 +83,25 @@ describe('syncGuides', () => { } }) + test('unlinks a symlinked guide of a deselected language without touching its target', () => { + const outside = mkdtempSync(join(tmpdir(), 'pleaseai-cli-outside-')) + try { + writeFileSync(join(outside, 'target.md'), 'outside') + mkdirSync(join(dir, '.agents/code-style'), { recursive: true }) + symlinkSync(join(outside, 'target.md'), join(dir, '.agents/code-style/rust.md')) + symlinkSync(join(outside, 'gone.md'), join(dir, '.agents/code-style/go.md')) + const { updated } = syncGuides(dir, ['dart']) + expect(updated).toContain('.agents/code-style/rust.md (removed)') + expect(updated).toContain('.agents/code-style/go.md (removed)') + expect(lstatSync(join(dir, '.agents/code-style/rust.md'), { throwIfNoEntry: false })).toBeUndefined() + expect(lstatSync(join(dir, '.agents/code-style/go.md'), { throwIfNoEntry: false })).toBeUndefined() + expect(readFileSync(join(outside, 'target.md'), 'utf-8')).toBe('outside') + } + finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + test('ships a guide for every language, covered by the package files', () => { const pkgRoot = join(import.meta.dir, '..') const { files } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')) as { files: string[] } diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 7a387a5..f5663c9 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -165,6 +165,26 @@ describe('projects without a package.json', () => { } }) + test('init stops on a symlinked guide directory before writing anything', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-nopkg-')) + const outside = mkdtempSync(join(tmpdir(), 'pleaseai-cli-outside-')) + try { + writeFileSync(join(dir, 'pubspec.yaml'), '') + symlinkSync(outside, join(dir, '.agents')) + for (const args of [['init', '--yes'], ['init', '--yes', '--lang', 'dart']]) { + const res = run(args, dir) + expect(res.status).toBe(1) + expect(res.stderr).toContain('.agents is a symlink') + expect(existsSync(join(dir, 'AGENTS.md'))).toBe(false) + } + expect(existsSync(join(outside, 'code-style'))).toBe(false) + } + finally { + rmSync(dir, { recursive: true, force: true }) + rmSync(outside, { recursive: true, force: true }) + } + }) + test('update rewrites a legacy node_modules block to the project copies and keeps its languages', () => { const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-legacy-')) try {