diff --git a/manifests/install-components.json b/manifests/install-components.json index 039d55cf..0cc630a1 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -531,31 +531,31 @@ "family": "locale", "description": "Japanese (ja-JP) translated reference docs installed to ~/.claude/docs/ja-JP/.", "modules": [ - "docs-ja-JP" + "docs-ja-jp" ] }, { - "id": "locale:zh-CN", + "id": "locale:zh-cn", "family": "locale", "description": "Simplified Chinese (zh-CN) translated reference docs installed to ~/.claude/docs/zh-CN/.", "modules": [ - "docs-zh-CN" + "docs-zh-cn" ] }, { - "id": "locale:ko-KR", + "id": "locale:ko-kr", "family": "locale", "description": "Korean (ko-KR) translated reference docs installed to ~/.claude/docs/ko-KR/.", "modules": [ - "docs-ko-KR" + "docs-ko-kr" ] }, { - "id": "locale:pt-BR", + "id": "locale:pt-br", "family": "locale", "description": "Brazilian Portuguese (pt-BR) translated reference docs installed to ~/.claude/docs/pt-BR/.", "modules": [ - "docs-pt-BR" + "docs-pt-br" ] }, { @@ -575,19 +575,19 @@ ] }, { - "id": "locale:vi-VN", + "id": "locale:vi-vn", "family": "locale", "description": "Vietnamese (vi-VN) translated reference docs installed to ~/.claude/docs/vi-VN/.", "modules": [ - "docs-vi-VN" + "docs-vi-vn" ] }, { - "id": "locale:zh-TW", + "id": "locale:zh-tw", "family": "locale", "description": "Traditional Chinese (zh-TW) translated reference docs installed to ~/.claude/docs/zh-TW/.", "modules": [ - "docs-zh-TW" + "docs-zh-tw" ] } ] diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 4cf7e709..5f9c8754 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -695,7 +695,7 @@ "stability": "stable" }, { - "id": "docs-ja-JP", + "id": "docs-ja-jp", "kind": "docs", "description": "Japanese (ja-JP) translated reference docs for agents, commands, skills, and rules.", "paths": [ @@ -710,7 +710,7 @@ "stability": "stable" }, { - "id": "docs-zh-CN", + "id": "docs-zh-cn", "kind": "docs", "description": "Simplified Chinese (zh-CN) translated reference docs for agents, commands, skills, and rules.", "paths": [ @@ -725,7 +725,7 @@ "stability": "stable" }, { - "id": "docs-ko-KR", + "id": "docs-ko-kr", "kind": "docs", "description": "Korean (ko-KR) translated reference docs for agents, commands, skills, and rules.", "paths": [ @@ -740,7 +740,7 @@ "stability": "stable" }, { - "id": "docs-pt-BR", + "id": "docs-pt-br", "kind": "docs", "description": "Brazilian Portuguese (pt-BR) translated reference docs for agents, commands, skills, and rules.", "paths": [ @@ -785,7 +785,7 @@ "stability": "stable" }, { - "id": "docs-vi-VN", + "id": "docs-vi-vn", "kind": "docs", "description": "Vietnamese (vi-VN) translated reference docs for agents, commands, skills, and rules.", "paths": [ @@ -800,7 +800,7 @@ "stability": "stable" }, { - "id": "docs-zh-TW", + "id": "docs-zh-tw", "kind": "docs", "description": "Traditional Chinese (zh-TW) translated reference docs for agents, commands, skills, and rules.", "paths": [ diff --git a/package.json b/package.json index 72d9735d..af498b8f 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,14 @@ "agent.yaml", "agents/", "commands/", + "docs/ja-JP/", + "docs/ko-KR/", + "docs/pt-BR/", + "docs/ru/", + "docs/tr/", + "docs/vi-VN/", + "docs/zh-CN/", + "docs/zh-TW/", "hooks/", "install.ps1", "install.sh", diff --git a/schemas/install-components.schema.json b/schemas/install-components.schema.json index c77878da..d72f41b0 100644 --- a/schemas/install-components.schema.json +++ b/schemas/install-components.schema.json @@ -26,7 +26,7 @@ "properties": { "id": { "type": "string", - "pattern": "^(baseline|lang|framework|capability|agent|skill):[a-z0-9-]+$" + "pattern": "^(baseline|lang|framework|capability|agent|skill|locale):[a-z0-9-]+$" }, "family": { "type": "string", @@ -36,7 +36,8 @@ "framework", "capability", "agent", - "skill" + "skill", + "locale" ] }, "description": { diff --git a/schemas/install-modules.schema.json b/schemas/install-modules.schema.json index 12573377..252f8e82 100644 --- a/schemas/install-modules.schema.json +++ b/schemas/install-modules.schema.json @@ -26,7 +26,8 @@ "hooks", "platform", "orchestration", - "skills" + "skills", + "docs" ] }, "description": { diff --git a/scripts/ci/validate-install-manifests.js b/scripts/ci/validate-install-manifests.js index 26c4c5ad..7f1f4f24 100644 --- a/scripts/ci/validate-install-manifests.js +++ b/scripts/ci/validate-install-manifests.js @@ -21,6 +21,7 @@ const COMPONENT_FAMILY_PREFIXES = { language: 'lang:', framework: 'framework:', capability: 'capability:', + locale: 'locale:', }; function readJson(filePath, label) { @@ -163,9 +164,12 @@ function validateInstallManifests() { if (profiles.full) { const fullModules = new Set(profiles.full.modules); - for (const moduleId of moduleIds) { - if (!fullModules.has(moduleId)) { - console.error(`ERROR: full profile is missing module ${moduleId}`); + for (const module of modules) { + if (module.kind === 'docs' && module.defaultInstall === false) { + continue; + } + if (!fullModules.has(module.id)) { + console.error(`ERROR: full profile is missing module ${module.id}`); hasErrors = true; } } diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index d7a5e4ee..a8311fc1 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -555,6 +555,12 @@ function createLegacyCompatInstallPlan(options = {}) { const sourceRoot = options.sourceRoot || getSourceRoot(); const projectRoot = options.projectRoot || process.cwd(); const target = options.target || 'claude'; + const includeComponentIds = Array.isArray(options.includeComponentIds) + ? [...options.includeComponentIds] + : []; + const excludeComponentIds = Array.isArray(options.excludeComponentIds) + ? [...options.excludeComponentIds] + : []; validateLegacyTarget(target); @@ -571,14 +577,14 @@ function createLegacyCompatInstallPlan(options = {}) { target, profileId: null, moduleIds: selection.moduleIds, - includeComponentIds: [], - excludeComponentIds: [], + includeComponentIds, + excludeComponentIds, legacyLanguages: selection.legacyLanguages, legacyMode: true, requestProfileId: null, requestModuleIds: [], - requestIncludeComponentIds: [], - requestExcludeComponentIds: [], + requestIncludeComponentIds: includeComponentIds, + requestExcludeComponentIds: excludeComponentIds, mode: 'legacy-compat', }); } diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index 14e6769b..c27d5e23 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -18,17 +18,17 @@ const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({ 'ja': 'locale:ja', 'ja-JP': 'locale:ja', - 'zh-CN': 'locale:zh-CN', - 'zh': 'locale:zh-CN', - 'ko-KR': 'locale:ko-KR', - 'ko': 'locale:ko-KR', - 'pt-BR': 'locale:pt-BR', - 'pt': 'locale:pt-BR', + 'zh-CN': 'locale:zh-cn', + 'zh': 'locale:zh-cn', + 'ko-KR': 'locale:ko-kr', + 'ko': 'locale:ko-kr', + 'pt-BR': 'locale:pt-br', + 'pt': 'locale:pt-br', 'ru': 'locale:ru', 'tr': 'locale:tr', - 'vi-VN': 'locale:vi-VN', - 'vi': 'locale:vi-VN', - 'zh-TW': 'locale:zh-TW', + 'vi-VN': 'locale:vi-vn', + 'vi': 'locale:vi-vn', + 'zh-TW': 'locale:zh-tw', }); function listSupportedLocales() { diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 52a79e1e..4cf5c043 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -62,7 +62,11 @@ function parseInstallArgs(argv) { } index += 1; } else if (arg === '--locale') { - parsed.locale = args[index + 1] || null; + const locale = args[index + 1] || ''; + if (!locale || locale.startsWith('--')) { + throw new Error('Missing value for --locale'); + } + parsed.locale = locale; index += 1; } else if (arg === '--dry-run') { parsed.dryRun = true; @@ -85,6 +89,7 @@ function normalizeInstallRequest(options = {}) { ? options.config : null; const profileId = options.profileId || config?.profileId || null; + const target = options.target || config?.target || 'claude'; const moduleIds = validateInstallModuleIds( dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) ); @@ -95,9 +100,15 @@ function normalizeInstallRequest(options = {}) { `Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}` ); } - const includeComponentIds = dedupeStrings([ + if (locale && target !== 'claude') { + throw new Error('--locale can only be used with --target claude'); + } + const requestedIncludeComponentIds = dedupeStrings([ ...(config?.includeComponentIds || []), ...(options.includeComponentIds || []), + ]); + const includeComponentIds = dedupeStrings([ + ...requestedIncludeComponentIds, ...(localeComponentId ? [localeComponentId] : []), ]); const excludeComponentIds = dedupeStrings([ @@ -108,13 +119,16 @@ function normalizeInstallRequest(options = {}) { ...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []), ...(Array.isArray(options.languages) ? options.languages : []), ]).map(language => language.toLowerCase())); - const target = options.target || config?.target || 'claude'; const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; + const hasNonLocaleManifestSelection = Boolean(profileId) + || moduleIds.length > 0 + || requestedIncludeComponentIds.length > 0 + || excludeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; - if (usingManifestMode && legacyLanguages.length > 0) { + if (hasNonLocaleManifestSelection && legacyLanguages.length > 0) { throw new Error( - 'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, --locale, or manifest config selections' + 'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections' ); } @@ -123,7 +137,9 @@ function normalizeInstallRequest(options = {}) { } return { - mode: usingManifestMode ? 'manifest' : 'legacy-compat', + mode: legacyLanguages.length > 0 + ? 'legacy-compat' + : (usingManifestMode ? 'manifest' : 'legacy-compat'), target, profileId, moduleIds, diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index 91a340c8..55f55bfb 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -28,6 +28,8 @@ function createInstallPlanFromRequest(request, options = {}) { return createLegacyCompatInstallPlan({ target: request.target, legacyLanguages: request.legacyLanguages, + includeComponentIds: request.includeComponentIds, + excludeComponentIds: request.excludeComponentIds, projectRoot: options.projectRoot, homeDir: options.homeDir, claudeRulesDir: options.claudeRulesDir, diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 18e0ec37..614c8ee2 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -52,6 +52,29 @@ function runTests() { assert.deepStrictEqual(parsed.languages, []); })) passed++; else failed++; + if (test('parses --locale argument', () => { + const parsed = parseInstallArgs([ + 'node', + 'scripts/install-apply.js', + '--locale', 'ja' + ]); + + assert.strictEqual(parsed.locale, 'ja'); + assert.deepStrictEqual(parsed.languages, []); + })) passed++; else failed++; + + if (test('requires a --locale value', () => { + assert.throws( + () => parseInstallArgs([ + 'node', + 'scripts/install-apply.js', + '--locale', + '--dry-run' + ]), + /Missing value for --locale/ + ); + })) passed++; else failed++; + if (test('normalizes legacy language installs into a canonical request', () => { const request = normalizeInstallRequest({ target: 'claude', @@ -67,6 +90,69 @@ function runTests() { assert.strictEqual(request.profileId, null); })) passed++; else failed++; + if (test('normalizes locale-only installs as manifest component requests', () => { + const request = normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + languages: [], + locale: 'ja', + }); + + assert.strictEqual(request.mode, 'manifest'); + assert.strictEqual(request.target, 'claude'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']); + assert.deepStrictEqual(request.legacyLanguages, []); + })) passed++; else failed++; + + if (test('allows legacy language installs to include a locale component', () => { + const request = normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + languages: ['typescript'], + locale: 'ja-JP', + }); + + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.legacyLanguages, ['typescript']); + assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']); + })) passed++; else failed++; + + if (test('rejects unsupported locale codes', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + languages: [], + locale: 'fr', + }), + /Unsupported locale/ + ); + })) passed++; else failed++; + + if (test('rejects --locale for non-Claude targets', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'cursor', + profileId: null, + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + languages: [], + locale: 'ja', + }), + /--locale can only be used with --target claude/ + ); + })) passed++; else failed++; + if (test('normalizes manifest installs into a canonical request', () => { const request = normalizeInstallRequest({ target: 'cursor', diff --git a/tests/lib/locale-install.test.js b/tests/lib/locale-install.test.js new file mode 100644 index 00000000..6e108b18 --- /dev/null +++ b/tests/lib/locale-install.test.js @@ -0,0 +1,172 @@ +/** + * Tests for --locale translated docs installs. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { + listInstallComponents, + resolveInstallPlan, +} = require('../../scripts/lib/install-manifests'); + +function normalizePlanPath(value) { + return String(value || '').replace(/\\/g, '/'); +} + +function runInstallApply(args, options = {}) { + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + return execFileSync('node', [scriptPath, ...args], { + cwd: options.cwd || process.cwd(), + env: { ...process.env, ...(options.env || {}) }, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing --locale translated docs installs ===\n'); + + let passed = 0; + let failed = 0; + + if (test('component catalog includes locale entries', () => { + const components = listInstallComponents({ family: 'locale' }); + assert.ok(components.some(component => component.id === 'locale:ja')); + assert.ok(components.some(component => component.id === 'locale:zh-cn')); + assert.ok(components.every(component => component.family === 'locale')); + })) passed++; else failed++; + + if (test('locale component resolves to the translated docs module', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-plan-')); + try { + const plan = resolveInstallPlan({ + includeComponentIds: ['locale:ja'], + target: 'claude', + homeDir, + }); + + assert.deepStrictEqual(plan.selectedModuleIds, ['docs-ja-jp']); + assert.ok( + plan.operations.some(operation => ( + normalizePlanPath(operation.sourceRelativePath) === 'docs/ja-JP' + && normalizePlanPath(operation.destinationPath).endsWith('/.claude/docs/ja-JP') + )), + 'Should map docs/ja-JP to ~/.claude/docs/ja-JP' + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: --locale ja dry-run includes docs-ja-jp operations', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-dry-run-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-dry-run-project-')); + + try { + const output = runInstallApply([ + '--locale', 'ja', + '--dry-run', + '--json', + ], { + cwd: projectDir, + env: { HOME: homeDir }, + }); + const json = JSON.parse(output); + + assert.strictEqual(json.plan.mode, 'manifest'); + assert.deepStrictEqual(json.plan.includedComponentIds, ['locale:ja']); + assert.deepStrictEqual(json.plan.selectedModuleIds, ['docs-ja-jp']); + assert.ok( + json.plan.operations.some(operation => ( + normalizePlanPath(operation.sourceRelativePath) === 'docs/ja-JP/README.md' + && normalizePlanPath(operation.destinationPath).endsWith('/.claude/docs/ja-JP/README.md') + )), + 'Should copy translated README into ~/.claude/docs/ja-JP' + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: legacy language plus --locale keeps legacy install and docs', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-legacy-dry-run-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-legacy-dry-run-project-')); + + try { + const output = runInstallApply([ + 'typescript', + '--locale', 'ja', + '--dry-run', + '--json', + ], { + cwd: projectDir, + env: { HOME: homeDir }, + }); + const json = JSON.parse(output); + + assert.strictEqual(json.plan.mode, 'legacy-compat'); + assert.deepStrictEqual(json.plan.legacyLanguages, ['typescript']); + assert.ok(json.plan.includedComponentIds.includes('locale:ja')); + assert.ok(json.plan.selectedModuleIds.includes('framework-language')); + assert.ok(json.plan.selectedModuleIds.includes('docs-ja-jp')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: --locale ja installs translated docs side-by-side', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-install-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-install-project-')); + + try { + runInstallApply([ + '--locale', 'ja', + ], { + cwd: projectDir, + env: { HOME: homeDir }, + }); + + const claudeRoot = path.join(homeDir, '.claude'); + assert.ok( + fs.existsSync(path.join(claudeRoot, 'docs', 'ja-JP', 'README.md')), + 'Should install Japanese README under docs/ja-JP' + ); + assert.ok( + !fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'configure-ecc', 'SKILL.md')), + 'Locale-only install should not install English skills' + ); + + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.deepStrictEqual(state.request.includeComponents, ['locale:ja']); + assert.deepStrictEqual(state.resolution.selectedModules, ['docs-ja-jp']); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();