diff --git a/apps/docs/src/content/docs/en/cli.mdx b/apps/docs/src/content/docs/en/cli.mdx index ed20c08..e73e3b0 100644 --- a/apps/docs/src/content/docs/en/cli.mdx +++ b/apps/docs/src/content/docs/en/cli.mdx @@ -131,10 +131,15 @@ The full canonical rules are shipped per language for AI assistants that prefer | Flag | Description | | --- | --- | | `--yes`, `-y` | Accept defaults and overwrite existing files without prompting | +| `--lang ` | Languages for the AGENTS.md rules block, comma-separated (`init`/`doctor`: detected; `update`: the languages already in the block; see below) | | `--locale ` | Force the CLI locale (defaults to `$LC_ALL` / `$LANG`) | | `--help`, `-h` | Print help | | `--version`, `-v` | Print the CLI version | +## Languages + +`--lang ` selects the programming languages the AGENTS.md rules block links to, for example `--lang typescript,dart`. Accepted values: `typescript`, `dart`, `rust`, `java`, `go`, `python`, `ruby`, `csharp`, `html-css`. Without it, `init` detects languages from marker files in the project root: `package.json` or `tsconfig.json` (typescript), `pubspec.yaml` (dart), `Cargo.toml` (rust), `build.gradle`, `build.gradle.kts` or `pom.xml` (java), `go.mod` (go), `pyproject.toml` (python). Interactive `init` lets you adjust the detected set. `update` keeps the languages the existing block links unless `--lang` is given. An unknown `--lang` value exits with an error (exit code 1) listing the accepted languages. `doctor` reports the detected languages. + ## Localisation The CLI auto-detects your locale from `LC_ALL`, `LANG`, or `LC_MESSAGES` and currently ships Korean and English messages. Override with `--locale ko` or `--locale en`: @@ -143,7 +148,7 @@ The CLI auto-detects your locale from `LC_ALL`, `LANG`, or `LC_MESSAGES` and cur pleaseai-code-style --locale ko init ``` -`--lang ko` and `--lang en` still set the locale for now, with a deprecation warning. When both flags are given, `--locale` wins. Any other `--lang` value, or a `--locale` value other than `ko` or `en`, exits with an error (exit code 1). A future release uses `--lang` to select programming languages. +`--lang ko` and `--lang en` are the deprecated locale alias: they still set the locale for now, with a deprecation warning, and select no languages. When both `--lang ko|en` and `--locale` are given, `--locale` wins. A `--locale` value other than `ko` or `en` also exits with an error (exit code 1). ## CI Usage diff --git a/apps/docs/src/content/docs/ko/cli.mdx b/apps/docs/src/content/docs/ko/cli.mdx index 224e67c..9ac0dc1 100644 --- a/apps/docs/src/content/docs/ko/cli.mdx +++ b/apps/docs/src/content/docs/ko/cli.mdx @@ -131,10 +131,15 @@ CLI는 두 마커 주석 사이의 내용 **만** 관리하며 — `AGENTS.md` | 플래그 | 설명 | | --- | --- | | `--yes`, `-y` | 기본값을 수락하고 기존 파일을 확인 없이 덮어씀 | +| `--lang ` | AGENTS.md 룰 블록에 넣을 언어, 쉼표로 구분 (`init`/`doctor`: 자동 감지, `update`: 블록에 이미 있는 언어, 아래 참고) | | `--locale ` | CLI 언어 강제 지정 (기본값은 `$LC_ALL` / `$LANG`) | | `--help`, `-h` | 도움말 출력 | | `--version`, `-v` | CLI 버전 출력 | +## 언어 + +`--lang `는 AGENTS.md 룰 블록이 가리킬 프로그래밍 언어를 고릅니다. 예: `--lang typescript,dart`. 지원 값은 `typescript`, `dart`, `rust`, `java`, `go`, `python`, `ruby`, `csharp`, `html-css`입니다. 옵션이 없으면 `init`이 프로젝트 루트의 마커 파일로 언어를 감지합니다. `package.json` 또는 `tsconfig.json`(typescript), `pubspec.yaml`(dart), `Cargo.toml`(rust), `build.gradle`, `build.gradle.kts`, `pom.xml`(java), `go.mod`(go), `pyproject.toml`(python)이 해당합니다. 대화형 `init`은 감지된 목록을 조정할 수 있게 합니다. `update`는 `--lang`을 주지 않으면 기존 블록이 가리키는 언어를 유지합니다. 알 수 없는 `--lang` 값은 지원하는 언어 목록과 함께 오류로 종료합니다(종료 코드 1). `doctor`는 감지된 언어를 보여줍니다. + ## 현지화 CLI는 `LC_ALL`, `LANG`, `LC_MESSAGES`에서 locale을 자동 감지하며, 현재 한국어와 영어 메시지를 제공합니다. `--locale ko` 또는 `--locale en`으로 강제 지정할 수 있습니다: @@ -143,7 +148,7 @@ CLI는 `LC_ALL`, `LANG`, `LC_MESSAGES`에서 locale을 자동 감지하며, 현 pleaseai-code-style --locale ko init ``` -`--lang ko`와 `--lang en`도 당분간은 로케일을 지정하지만 사용 중단 경고를 출력합니다. 두 플래그를 함께 주면 `--locale`이 우선합니다. 그 밖의 `--lang` 값이나 지원하지 않는 `--locale` 값은 무시되지 않고 오류로 종료합니다(종료 코드 1). 이후 릴리스에서는 `--lang`이 프로그래밍 언어 선택에 쓰입니다. +`--lang ko`와 `--lang en`은 사용 중단된 로케일 별칭입니다. 당분간은 로케일을 지정하며 사용 중단 경고를 출력하고, 언어는 선택하지 않습니다. `--lang ko|en`과 `--locale`을 함께 주면 `--locale`이 우선합니다. 지원하지 않는 `--locale` 값도 오류로 종료합니다(종료 코드 1). ## CI에서의 사용 diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index edcf83b..1d7e729 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -220,19 +220,32 @@ CLI는 아래 마커 사이의 내용만 관리합니다 — `AGENTS.md`의 나 | 플래그 | 설명 | | --- | --- | | `--yes`, `-y` | 기본값을 수락하고 기존 파일을 확인 없이 덮어씀 | +| `--lang ` | AGENTS.md 룰 블록에 넣을 언어, 쉼표로 구분 (`init`/`doctor`: 자동 감지, `update`: 블록에 이미 있는 언어, 아래 참고) | | `--locale ` | CLI 언어 강제 지정 (기본값은 `$LANG`) | | `--help`, `-h` | 도움말 출력 | | `--version`, `-v` | 버전 출력 | +## 언어 + +`--lang `는 AGENTS.md 룰 블록이 가리킬 프로그래밍 언어를 고릅니다. 예: +`--lang typescript,dart`. 지원 값은 `typescript`, `dart`, `rust`, `java`, `go`, +`python`, `ruby`, `csharp`, `html-css`입니다. 옵션이 없으면 `init`이 프로젝트 루트의 +마커 파일로 언어를 감지합니다. `package.json` 또는 `tsconfig.json`(typescript), +`pubspec.yaml`(dart), `Cargo.toml`(rust), `build.gradle`, `build.gradle.kts`, +`pom.xml`(java), `go.mod`(go), `pyproject.toml`(python)이 해당합니다. 대화형 `init`은 +감지된 목록을 조정할 수 있게 합니다. `update`는 `--lang`을 주지 않으면 기존 블록이 +가리키는 언어를 유지합니다. 알 수 없는 `--lang` 값은 지원하는 언어 목록과 함께 오류로 +종료합니다(종료 코드 1). `doctor`는 감지된 언어를 보여줍니다. + ## 현지화 CLI는 `LC_ALL` / `LANG` / `LC_MESSAGES`에서 locale을 자동 감지하며, 현재 한국어와 영어 메시지를 제공합니다. `--locale ko` 또는 `--locale en`으로 강제 지정할 수 있습니다. -`--lang ko`와 `--lang en`도 당분간은 로케일을 지정하지만 사용 중단 경고를 출력합니다. -두 플래그를 함께 주면 `--locale`이 우선합니다. 그 밖의 `--lang` 값이나 지원하지 않는 -`--locale` 값은 무시되지 않고 오류로 종료합니다(종료 코드 1). 이후 릴리스에서는 -`--lang`이 프로그래밍 언어 선택에 쓰입니다. +`--lang ko`와 `--lang en`은 사용 중단된 로케일 별칭입니다. 당분간은 로케일을 지정하며 +사용 중단 경고를 출력하고, 언어는 선택하지 않습니다. `--lang ko|en`과 `--locale`을 +함께 주면 `--locale`이 우선합니다. 지원하지 않는 `--locale` 값도 오류로 +종료합니다(종료 코드 1). ## 라이선스 diff --git a/packages/cli/README.md b/packages/cli/README.md index 6c8862b..f11b5f9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -404,20 +404,34 @@ JavaScript). | Flag | Description | | --- | --- | | `--yes`, `-y` | Accept defaults, overwrite existing files without prompting | +| `--lang ` | Languages for the AGENTS.md rules block, comma-separated (`init`/`doctor`: detected; `update`: the languages already in the block; see below) | | `--locale ` | Force the CLI locale (defaults to `$LANG`) | | `--help`, `-h` | Print help | | `--version`, `-v` | Print version | +## Languages + +`--lang ` selects the programming languages the AGENTS.md rules block links +to, for example `--lang typescript,dart`. Accepted values: `typescript`, `dart`, +`rust`, `java`, `go`, `python`, `ruby`, `csharp`, `html-css`. Without it, `init` +detects languages from marker files in the project root: `package.json` or +`tsconfig.json` (typescript), `pubspec.yaml` (dart), `Cargo.toml` (rust), +`build.gradle`, `build.gradle.kts` or `pom.xml` (java), `go.mod` (go), +`pyproject.toml` (python). Interactive `init` lets you adjust the detected set. +`update` keeps the languages the existing block links unless `--lang` is given. +An unknown `--lang` value exits with an error (exit code 1) listing the accepted +languages. `doctor` reports the detected languages. + ## Localisation The CLI auto-detects your locale from `LC_ALL` / `LANG` / `LC_MESSAGES` and currently ships Korean and English messages. Override with `--locale ko` or `--locale en`. -`--lang ko` and `--lang en` still set the locale for now, with a deprecation -warning. When both flags are given, `--locale` wins. Any other `--lang` value, or -a `--locale` value other than `ko` or `en`, exits with an error (exit code 1). A -future release uses `--lang` to select programming languages. +`--lang ko` and `--lang en` are the deprecated locale alias: they still set the +locale for now, with a deprecation warning, and select no languages. When both +`--lang ko|en` and `--locale` are given, `--locale` wins. A `--locale` value other +than `ko` or `en` also exits with an error (exit code 1). ## License diff --git a/packages/cli/rules.md b/packages/cli/rules.md index 56b75d4..535b141 100644 --- a/packages/cli/rules.md +++ b/packages/cli/rules.md @@ -14,5 +14,7 @@ apply to every language, then the guide for the language you are writing: - [`rules/csharp.md`](./rules/csharp.md) — C# - [`rules/html-css.md`](./rules/html-css.md) — HTML and CSS -`pleaseai-code-style update` points your `AGENTS.md` block at the general and -TypeScript guides; the other guides are read from this index for now. +`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 +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 5e43c3e..8aea6db 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -1,3 +1,4 @@ +import type { Language } from './languages.js' import type { Tool, ToolApplyResult, ToolContext } from './tools.js' import { copyFileSync, existsSync, writeFileSync } from 'node:fs' import { dirname, resolve as joinPath } from 'node:path' @@ -10,6 +11,7 @@ import { writePackageJson, } from './fs-utils.js' import { t } from './i18n.js' +import { detectLanguages, languagesFromAgentsBlock, RULES_PREFIX } from './languages.js' async function confirmOverwrite(file: string, auto: boolean): Promise { if (auto) { @@ -136,25 +138,25 @@ async function applyEditorConfig(ctx: ToolContext): Promise { // --- AGENTS.md -------------------------------------------------------------- -const AGENTS_BODY = `# Passion Factory Code Style +const AGENTS_INTRO = `# Passion Factory 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\`) +` + +const AGENTS_TYPESCRIPT = `- 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/general.md\` and -\`node_modules/@pleaseai/code-style/rules/typescript.md\`.` +const AGENTS_GENERAL = `- File size target: ≤ 500 lines +` const AGENTS_CODE_ORGANIZATION = `- Code organization (named exports, file names, error files, test paths, shared helpers): \`node_modules/@pleaseai/ast-grep-config/README.md\`; check with \`ast-grep scan\` and \`please-style check\` @@ -179,10 +181,15 @@ function wantsCodeOrganization(ctx: ToolContext): boolean { } } -function agentsBody(ctx: ToolContext): string { - return wantsCodeOrganization(ctx) - ? AGENTS_BODY.replace('- Conventional', `${AGENTS_CODE_ORGANIZATION}- Conventional`) - : AGENTS_BODY +function agentsBody(ctx: ToolContext, languages: Language[]): string { + const guides = ['general', ...languages].map(id => `\`${RULES_PREFIX}/${id}.md\``) + const pointer = guides.length > 1 + ? `${guides.slice(0, -1).join(', ')} and ${guides.at(-1)}` + : guides[0] + return `${AGENTS_INTRO}${languages.includes('typescript') ? AGENTS_TYPESCRIPT : ''}${AGENTS_GENERAL}${wantsCodeOrganization(ctx) ? AGENTS_CODE_ORGANIZATION : ''}- Conventional Commits for all commit messages + +For the full rules (what an AI coding assistant needs to know before writing code), +read ${pointer}.` } export async function applyAgentsMd(ctx: ToolContext): Promise { @@ -190,7 +197,8 @@ export async function applyAgentsMd(ctx: ToolContext): Promise const target = joinPath(ctx.cwd, 'AGENTS.md') const label = 'AGENTS.md' const existing = readFileOrNull(target) - const next = upsertMarkerBlock(existing, agentsBody(ctx)) + const languages = ctx.languages ?? languagesFromAgentsBlock(existing) ?? detectLanguages(ctx.cwd) + const next = upsertMarkerBlock(existing, agentsBody(ctx, languages)) if (existing == null) { writeFileSync(target, next) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 706de10..2115fad 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,3 +1,4 @@ +import type { Language } from './languages.js' import type { Tool, ToolApplyResult, ToolContext } from './tools.js' import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' @@ -12,10 +13,12 @@ import { readPackageJson, } from './fs-utils.js' import { t } from './i18n.js' +import { detectLanguages, LANGUAGES } from './languages.js' export interface CommandOptions { cwd: string autoAccept: boolean + languages?: Language[] } function assertProjectRoot(cwd: string): void { @@ -94,6 +97,27 @@ export async function runInit(opts: CommandOptions): Promise { .map(id => TOOLS.find(tool => tool.id === id)) .filter((tool): tool is Tool => tool != null) + let languages = opts.languages + if (languages === undefined && selectedTools.some(tool => tool.id === 'agents-md')) { + const detected = detectLanguages(opts.cwd) + if (opts.autoAccept) { + languages = detected + } + else { + const chosen = await multiselect({ + message: t('selectLanguages'), + options: LANGUAGES.map(value => ({ value, label: value })), + initialValues: detected, + required: false, + }) + if (isCancel(chosen)) { + cancel(t('cancelled')) + process.exit(0) + } + languages = chosen + } + } + // 1. Install packages (de-duped) for selected tools. const packagesToInstall = Array.from( new Set(selectedTools.flatMap(tool => tool.packages)), @@ -115,7 +139,7 @@ export async function runInit(opts: CommandOptions): Promise { } // 2. Apply each tool's side-effects. - const ctx: ToolContext = { cwd: opts.cwd, autoAccept: opts.autoAccept, selectedToolIds: selectedTools.map(tool => tool.id) } + const ctx: ToolContext = { cwd: opts.cwd, autoAccept: opts.autoAccept, selectedToolIds: selectedTools.map(tool => tool.id), languages } const results: ToolApplyResult[] = [] for (const tool of selectedTools) { results.push(await tool.apply(ctx)) @@ -130,7 +154,7 @@ export async function runInit(opts: CommandOptions): Promise { export async function runUpdate(opts: CommandOptions): Promise { assertProjectRoot(opts.cwd) intro(t('appTitle')) - const result = await applyAgentsMd({ cwd: opts.cwd, autoAccept: true, selectedToolIds: [] }) + const result = await applyAgentsMd({ cwd: opts.cwd, autoAccept: true, selectedToolIds: [], languages: opts.languages }) reportResults(result) log.success(t('agentsBlockUpdated')) outro(t('done')) @@ -156,6 +180,14 @@ export function runDoctor(opts: CommandOptions): void { log.info(` ${tool.label} — ${suffix}`) } + if (opts.languages != null) { + log.info(` ${t('doctorSelectedLanguages')(opts.languages.join(', '))}`) + } + else { + const languages = detectLanguages(opts.cwd) + log.info(` ${languages.length > 0 ? t('doctorLanguages')(languages.join(', ')) : t('doctorNoLanguages')}`) + } + // File presence checks. const files = [ 'eslint.config.mjs', diff --git a/packages/cli/src/fs-utils.ts b/packages/cli/src/fs-utils.ts index b023e75..eda5208 100644 --- a/packages/cli/src/fs-utils.ts +++ b/packages/cli/src/fs-utils.ts @@ -70,6 +70,15 @@ export function buildMarkerBlock(body: string): string { return `${BEGIN_MARKER}\n${body.trim()}\n${END_MARKER}` } +export function readMarkerBlock(content: string): string | undefined { + const begin = content.indexOf(BEGIN_MARKER) + const end = content.indexOf(END_MARKER) + if (begin === -1 || end === -1 || end < begin) { + return undefined + } + return content.slice(begin + BEGIN_MARKER.length, end) +} + export function upsertMarkerBlock(existing: string | null, body: string): string { const block = buildMarkerBlock(body) if (existing == null) { diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index e08e083..732172c 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -1,4 +1,5 @@ import process from 'node:process' +import { LANGUAGES } from './languages.js' export type Locale = 'en' | 'ko' @@ -29,7 +30,12 @@ interface Messages { optionBeforeCheck: (option: string) => string deprecatedLangFlag: (locale: Locale) => string ignoredLangFlag: (locale: Locale) => string - invalidLocaleFlag: (flag: LocaleFlag, value: string) => string + invalidLocaleFlag: (value: string) => string + invalidLangFlag: (value: string) => string + selectLanguages: string + doctorLanguages: (languages: string) => string + doctorNoLanguages: string + doctorSelectedLanguages: (languages: string) => string usage: string checkUsage: string } @@ -60,11 +66,14 @@ const messages: Record = { unknownCommand: cmd => `Unknown command: ${cmd}`, unexpectedArguments: args => `Unexpected arguments: ${args}`, optionBeforeCheck: option => `${option} is a check option; put it after \`check\` (please-style check ${option})`, - deprecatedLangFlag: locale => `--lang ${locale} is deprecated; use --locale ${locale}. A future release will use --lang to select programming languages.`, - ignoredLangFlag: locale => `--lang ${locale} is deprecated and ignored because --locale is set; remove it. A future release will use --lang to select programming languages.`, - invalidLocaleFlag: (flag, value) => flag === '--locale' - ? `Unknown --locale value: "${value}". Use ko or en.` - : `Unknown --lang value: "${value}". To set the CLI locale, use --locale .`, + deprecatedLangFlag: locale => `--lang ${locale} is deprecated; use --locale ${locale}. Use --lang to select programming languages.`, + ignoredLangFlag: locale => `--lang ${locale} is deprecated and ignored because --locale is set; remove it. Use --lang to select programming languages.`, + invalidLocaleFlag: value => `Unknown --locale value: "${value}". Use ko or en.`, + invalidLangFlag: value => `Unknown --lang value: "${value}". Use a comma-separated list of: ${LANGUAGES.join(', ')}. To set the CLI locale, use --locale .`, + selectLanguages: 'Select the languages for the AGENTS.md rules block', + doctorLanguages: languages => `Detected languages: ${languages}`, + doctorNoLanguages: 'Detected languages: none', + doctorSelectedLanguages: languages => `Selected languages (--lang): ${languages}`, usage: `Usage: pleaseai-code-style [options] @@ -76,6 +85,7 @@ Commands: Options: --yes, -y Accept defaults, overwrite existing files + --lang Languages for the rules block, comma-separated (default: detected) --locale Force CLI locale --help, -h Show this message --version, -v Print version`, @@ -121,11 +131,14 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e unknownCommand: cmd => `알 수 없는 명령어: ${cmd}`, unexpectedArguments: args => `예상하지 못한 인자: ${args}`, optionBeforeCheck: option => `${option}는 check 옵션입니다. \`check\` 뒤에 쓰세요 (please-style check ${option})`, - deprecatedLangFlag: locale => `--lang ${locale} 옵션은 더 이상 권장하지 않습니다. 대신 --locale ${locale} 옵션을 쓰세요. 이후 릴리스에서 --lang은 프로그래밍 언어 선택에 쓰입니다.`, - ignoredLangFlag: locale => `--lang ${locale} 옵션은 더 이상 권장하지 않으며, --locale 옵션이 있어 무시합니다. 이 옵션을 지우세요. 이후 릴리스에서 --lang은 프로그래밍 언어 선택에 쓰입니다.`, - invalidLocaleFlag: (flag, value) => flag === '--locale' - ? `알 수 없는 --locale 값: "${value}". ko 또는 en을 쓰세요.` - : `알 수 없는 --lang 값: "${value}". CLI 로케일은 --locale 으로 지정하세요.`, + deprecatedLangFlag: locale => `--lang ${locale} 옵션은 더 이상 권장하지 않습니다. 대신 --locale ${locale} 옵션을 쓰세요. 프로그래밍 언어는 --lang으로 고르세요.`, + ignoredLangFlag: locale => `--lang ${locale} 옵션은 더 이상 권장하지 않으며, --locale 옵션이 있어 무시합니다. 이 옵션을 지우세요. 프로그래밍 언어는 --lang으로 고르세요.`, + invalidLocaleFlag: value => `알 수 없는 --locale 값: "${value}". ko 또는 en을 쓰세요.`, + invalidLangFlag: value => `알 수 없는 --lang 값: "${value}". 쉼표로 구분한 목록으로 지정하세요: ${LANGUAGES.join(', ')}. CLI 로케일은 --locale 으로 지정하세요.`, + selectLanguages: 'AGENTS.md 룰 블록에 넣을 언어를 선택하세요', + doctorLanguages: languages => `감지된 언어: ${languages}`, + doctorNoLanguages: '감지된 언어: 없음', + doctorSelectedLanguages: languages => `선택한 언어(--lang): ${languages}`, usage: `사용법: pleaseai-code-style [options] @@ -137,6 +150,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e 옵션: --yes, -y 기본값 수락, 기존 파일 덮어쓰기 + --lang 룰 블록에 넣을 언어, 쉼표로 구분 (기본: 자동 감지) --locale CLI 로케일 강제 지정 --help, -h 이 메시지 표시 --version, -v 버전 출력`, @@ -167,8 +181,6 @@ export function detectLocale(override?: string): Locale { return env.toLowerCase().startsWith('ko') ? 'ko' : 'en' } -export type LocaleFlag = '--locale' | '--lang' - type FlagValue = string | boolean | undefined export interface LangWarning { @@ -178,7 +190,7 @@ export interface LangWarning { export type LocaleFlags = | { kind: 'ok', locale: Locale, langWarning?: LangWarning } - | { kind: 'invalid', locale: Locale, flag: LocaleFlag, value: string } + | { kind: 'invalid', locale: Locale, value: string } function asLocale(value: FlagValue): Locale | undefined { return value === 'ko' || value === 'en' ? value : undefined @@ -193,10 +205,7 @@ export function resolveLocaleFlags(flags: { locale?: FlagValue, lang?: FlagValue const fromLang = asLocale(flags.lang) const locale = fromLocale ?? fromLang ?? detectLocale() if (flags.locale !== undefined && fromLocale === undefined) { - return { kind: 'invalid', locale, flag: '--locale', value: flagText(flags.locale) } - } - if (flags.lang !== undefined && fromLang === undefined) { - return { kind: 'invalid', locale, flag: '--lang', value: flagText(flags.lang) } + return { kind: 'invalid', locale, value: flagText(flags.locale) } } if (fromLang === undefined) { return { kind: 'ok', locale } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fafd3c7..92190b6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { parseArgs } from 'node:util' import { runCheck } from './check/run-check.js' import { runDoctor, runInit, runUpdate } from './commands.js' import { resolveLocaleFlags, setLocale, t } from './i18n.js' +import { parseLangFlag } from './languages.js' const GLOBAL_OPTIONS = { yes: { type: 'boolean', short: 'y' }, @@ -27,7 +28,7 @@ const { values: allValues, positionals, tokens } = parseArgs({ const localeFlags = resolveLocaleFlags({ locale: allValues.locale, lang: allValues.lang }) setLocale(localeFlags.locale) if (localeFlags.kind === 'invalid') { - process.stderr.write(`${t('invalidLocaleFlag')(localeFlags.flag, localeFlags.value)}\n`) + process.stderr.write(`${t('invalidLocaleFlag')(localeFlags.value)}\n`) process.exit(1) } else if (localeFlags.langWarning !== undefined) { @@ -35,6 +36,13 @@ else if (localeFlags.langWarning !== undefined) { process.stderr.write(`${t(kind === 'deprecated' ? 'deprecatedLangFlag' : 'ignoredLangFlag')(locale)}\n`) } +const langFlag = parseLangFlag(allValues.lang) +if (langFlag.kind === 'invalid') { + process.stderr.write(`${t('invalidLangFlag')(langFlag.value)}\n`) + process.exit(1) +} +const languages = langFlag.kind === 'languages' ? langFlag.languages : undefined + const command = positionals[0] ?? 'init' // `check` parses its own flags strictly, so only what precedes it is global // (`please-style check -h` shows the check usage, not the global one). @@ -74,13 +82,13 @@ const cwd = process.cwd() try { switch (command) { case 'init': - await runInit({ cwd, autoAccept }) + await runInit({ cwd, autoAccept, languages }) break case 'update': - await runUpdate({ cwd, autoAccept }) + await runUpdate({ cwd, autoAccept, languages }) break case 'doctor': - runDoctor({ cwd, autoAccept }) + runDoctor({ cwd, autoAccept, languages }) break case 'check': // exitCode, not exit(): a large --json report must flush before exiting. diff --git a/packages/cli/src/languages.ts b/packages/cli/src/languages.ts new file mode 100644 index 0000000..9c01c62 --- /dev/null +++ b/packages/cli/src/languages.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { readMarkerBlock } from './fs-utils.js' + +export const LANGUAGES = ['typescript', 'dart', 'rust', 'java', 'go', 'python', 'ruby', 'csharp', 'html-css'] as const + +export type Language = typeof LANGUAGES[number] + +export const RULES_PREFIX = 'node_modules/@pleaseai/code-style/rules' + +const MARKERS: Array<{ language: Language, files: string[] }> = [ + { language: 'typescript', files: ['package.json', 'tsconfig.json'] }, + { language: 'dart', files: ['pubspec.yaml'] }, + { language: 'rust', files: ['Cargo.toml'] }, + { language: 'java', files: ['build.gradle', 'build.gradle.kts', 'pom.xml'] }, + { language: 'go', files: ['go.mod'] }, + { language: 'python', files: ['pyproject.toml'] }, +] + +function isLanguage(value: string): value is Language { + return (LANGUAGES as readonly string[]).includes(value) +} + +function inOrder(languages: Iterable): Language[] { + const set = new Set(languages) + return LANGUAGES.filter(language => set.has(language)) +} + +export function detectLanguages(cwd: string): Language[] { + return inOrder(MARKERS + .filter(({ files }) => files.some(f => existsSync(resolve(cwd, f)))) + .map(({ language }) => language)) +} + +export type LangFlag + = | { kind: 'none' } + | { kind: 'languages', languages: Language[] } + | { kind: 'invalid', value: string } + +const LEGACY_LOCALE_ALIASES: ReadonlyArray = ['ko', 'en'] + +export function parseLangFlag(value: string | boolean | undefined): LangFlag { + if (value === undefined || LEGACY_LOCALE_ALIASES.includes(value)) { + return { kind: 'none' } + } + if (typeof value !== 'string') { + return { kind: 'invalid', value: '' } + } + const items = value.split(',').map(item => item.trim()).filter(item => item !== '') + if (items.length === 0 || !items.every(isLanguage)) { + return { kind: 'invalid', value } + } + return { kind: 'languages', languages: inOrder(items) } +} + +function isLanguageAwareBlock(block: string | undefined): block is string { + return block !== undefined && block.includes(`${RULES_PREFIX}/general.md`) +} + +export function languagesFromAgentsBlock(content: string | null): Language[] | undefined { + const block = content == null ? undefined : readMarkerBlock(content) + if (!isLanguageAwareBlock(block)) { + return undefined + } + return LANGUAGES.filter(language => block.includes(`${RULES_PREFIX}/${language}.md`)) +} diff --git a/packages/cli/src/tools.ts b/packages/cli/src/tools.ts index ddf3fa4..73a7383 100644 --- a/packages/cli/src/tools.ts +++ b/packages/cli/src/tools.ts @@ -1,3 +1,5 @@ +import type { Language } from './languages.js' + /** * Registry of tools this CLI knows how to install + configure. * @@ -14,6 +16,7 @@ export interface ToolContext { autoAccept: boolean /** Tools chosen in this run; unset when the caller has no selection (the code-organization pointer is then kept). */ selectedToolIds?: ToolId[] + languages?: Language[] } export interface ToolApplyResult { diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index ee27a36..bb9c6bb 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { log } from '@clack/prompts' import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { applyAgentsMd, applyAstGrep, findTool } from '../src/appliers.js' +import { LANGUAGES } from '../src/languages.js' let dir: string @@ -145,6 +146,10 @@ describe('editorconfig tool', () => { // --------------------------------------------------------------------------- describe('AGENTS.md rules block', () => { + beforeEach(() => { + writeFileSync(join(dir, 'package.json'), '{}') + }) + test('puts tests under the test root instead of next to the source', async () => { await applyAgentsMd({ cwd: dir, autoAccept: true }) const body = readFileSync(join(dir, 'AGENTS.md'), 'utf-8') @@ -194,4 +199,34 @@ describe('AGENTS.md rules block', () => { await applyAgentsMd({ cwd: dir, autoAccept: true, selectedToolIds: [] }) expect(readFileSync(join(dir, 'AGENTS.md'), 'utf-8')).toContain('please-style check') }) + + 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']) + expect(body).not.toContain('@pleaseai/eslint-config') + expect(body).not.toContain('ESM only') + expect(body).not.toContain('import.meta.vitest') + expect(body).toContain('- File size target') + expect(body).toContain('- Conventional Commits') + }) + + test('keeps the languages of the existing block when none are given, even if detection differs', async () => { + 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') + }) + + 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/i18n.test.ts b/packages/cli/test/i18n.test.ts index 88157b1..5290fe2 100644 --- a/packages/cli/test/i18n.test.ts +++ b/packages/cli/test/i18n.test.ts @@ -70,13 +70,13 @@ describe('resolveLocaleFlags', () => { expect(resolveLocaleFlags({ locale: 'en', lang: 'ko' })).toEqual({ kind: 'ok', locale: 'en', langWarning: { kind: 'ignored', locale: 'ko' } }) }) - test('an unknown --lang value is invalid instead of ignored', () => { - expect(resolveLocaleFlags({ lang: 'dart' })).toMatchObject({ kind: 'invalid', flag: '--lang', value: 'dart' }) - expect(resolveLocaleFlags({ lang: true })).toMatchObject({ kind: 'invalid', flag: '--lang', value: '' }) + test('a --lang language list does not affect the locale', () => { + expect(resolveLocaleFlags({ lang: 'dart,rust', locale: 'ko' })).toEqual({ kind: 'ok', locale: 'ko' }) + expect(resolveLocaleFlags({ lang: true, locale: 'en' })).toEqual({ kind: 'ok', locale: 'en' }) }) test('an unknown --locale value is invalid', () => { - expect(resolveLocaleFlags({ locale: 'fr' })).toMatchObject({ kind: 'invalid', flag: '--locale', value: 'fr' }) + expect(resolveLocaleFlags({ locale: 'fr' })).toMatchObject({ kind: 'invalid', value: 'fr' }) }) }) diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 016f076..5ea330e 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -1,13 +1,16 @@ import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'bun:test' const CLI = fileURLToPath(new URL('../src/index.ts', import.meta.url)) -function run(args: string[]): { status: number | null, stdout: string, stderr: string } { +function run(args: string[], cwd?: string): { status: number | null, stdout: string, stderr: string } { const env = { ...process.env, LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8', LC_MESSAGES: 'en_US.UTF-8' } - const res = spawnSync('bun', [CLI, ...args], { encoding: 'utf-8', env }) + const res = spawnSync('bun', [CLI, ...args], { encoding: 'utf-8', env, cwd, stdio: ['ignore', 'pipe', 'pipe'] }) return { status: res.status, stdout: res.stdout, stderr: res.stderr } } @@ -32,11 +35,45 @@ describe('locale flags', () => { expect(res.stdout).toContain('Usage:') expect(res.stderr).toContain('--lang ko is deprecated and ignored because --locale is set; remove it.') }) +}) - test('an unknown --lang value exits non-zero instead of being ignored', () => { - const res = run(['--lang', 'dart', '--help']) +describe('--lang languages', () => { + test('an unknown value exits non-zero and lists the accepted languages', () => { + const res = run(['--lang', 'foo', '--help']) expect(res.status).toBe(1) expect(res.stdout).toBe('') - expect(res.stderr).toContain('Unknown --lang value: "dart"') + expect(res.stderr).toContain('Unknown --lang value: "foo"') + expect(res.stderr).toContain('typescript') + expect(res.stderr).toContain('html-css') + expect(res.stderr).toContain('--locale ') + }) + + test('update --lang dart links the dart guide instead of the TypeScript one', () => { + const dir = mkdtempSync(join(tmpdir(), 'pleaseai-cli-lang-')) + try { + writeFileSync(join(dir, 'package.json'), '{}') + 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') + } + finally { + 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 }) + } }) }) diff --git a/packages/cli/test/languages.test.ts b/packages/cli/test/languages.test.ts new file mode 100644 index 0000000..b1a1de7 --- /dev/null +++ b/packages/cli/test/languages.test.ts @@ -0,0 +1,94 @@ +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' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pleaseai-lang-test-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function touch(...files: string[]): void { + for (const f of files) { + writeFileSync(join(dir, f), '') + } +} + +describe('detectLanguages', () => { + test.each([ + ['package.json', 'typescript'], + ['tsconfig.json', 'typescript'], + ['build.gradle', 'java'], + ['build.gradle.kts', 'java'], + ['pom.xml', 'java'], + ['pubspec.yaml', 'dart'], + ['Cargo.toml', 'rust'], + ['go.mod', 'go'], + ['pyproject.toml', 'python'], + ])('%s marks %s', (marker, language) => { + touch(marker) + expect(detectLanguages(dir)).toEqual([language]) + }) + + test('reports every language with a root marker, in guide order', () => { + touch('pubspec.yaml', 'package.json') + expect(detectLanguages(dir)).toEqual(['typescript', 'dart']) + }) + + test('returns nothing without a marker', () => { + expect(detectLanguages(dir)).toEqual([]) + }) +}) + +describe('parseLangFlag', () => { + test('an unset flag and the locale aliases select no languages', () => { + expect(parseLangFlag(undefined)).toEqual({ kind: 'none' }) + expect(parseLangFlag('ko')).toEqual({ kind: 'none' }) + expect(parseLangFlag('en')).toEqual({ kind: 'none' }) + }) + + test('a list is trimmed, deduped, and ordered as the guides', () => { + expect(parseLangFlag('dart')).toEqual({ kind: 'languages', languages: ['dart'] }) + expect(parseLangFlag(' rust, typescript,,rust ')).toEqual({ kind: 'languages', languages: ['typescript', 'rust'] }) + }) + + test('an unknown item, an empty list, or a missing value is invalid', () => { + expect(parseLangFlag('dart,foo')).toEqual({ kind: 'invalid', value: 'dart,foo' }) + expect(parseLangFlag(',')).toEqual({ kind: 'invalid', value: ',' }) + expect(parseLangFlag(true)).toEqual({ kind: 'invalid', value: '' }) + }) + + test('ko or en inside a list is invalid', () => { + expect(parseLangFlag('ko,dart')).toEqual({ kind: 'invalid', value: 'ko,dart' }) + }) +}) + +describe('languagesFromAgentsBlock', () => { + const block = (body: string): string => `\n${body}\n\n` + const guide = (id: string): string => `node_modules/@pleaseai/code-style/rules/${id}.md` + + test('is undefined without a block', () => { + expect(languagesFromAgentsBlock(null)).toBeUndefined() + expect(languagesFromAgentsBlock(`see ${guide('general')} and ${guide('dart')}`)).toBeUndefined() + }) + + test('is undefined for a legacy block that does not link the general guide', () => { + expect(languagesFromAgentsBlock(block(`read ${guide('typescript')}`))).toBeUndefined() + }) + + test('is undefined for the v0.1.1 block, so update falls back to detection', () => { + const released = block('For the full rules (what an AI coding assistant needs to know before writing code),\nread `node_modules/@pleaseai/code-style/rules.md`.') + expect(languagesFromAgentsBlock(released)).toBeUndefined() + }) + + test('lists the languages the block links', () => { + expect(languagesFromAgentsBlock(block(`read ${guide('general')} and ${guide('dart')}`))).toEqual(['dart']) + expect(languagesFromAgentsBlock(block(`read ${guide('general')}`))).toEqual([]) + }) +})