diff --git a/README.md b/README.md index 62012ab1..905f8e36 100644 --- a/README.md +++ b/README.md @@ -194,24 +194,24 @@ ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device trans ccs api import ./glm.ccs-profile.json # Import exported profile bundle ``` -### Runtime Aliases (`argv[0]` pattern) +### Runtime Aliases (built-in bins / `argv[0]` pattern) -Built-in Droid runtime aliases: +Built-in Droid runtime aliases are installed with the package: ```bash -ln -s "$(command -v ccs)" /usr/local/bin/ccs-droid -ln -s "$(command -v ccs)" /usr/local/bin/ccsd ccs-droid glm # explicit alias ccsd glm # legacy shortcut ``` -Need additional alias names? Use `CCS_TARGET_ALIASES` (preferred) or keep using -legacy `CCS_DROID_ALIASES`: +Need additional alias names? First create the matching symlink or another launcher that +preserves the invoked basename, then map that name with `CCS_TARGET_ALIASES` (preferred) or legacy +`CCS_DROID_ALIASES`: ```bash -CCS_TARGET_ALIASES='droid=mydroid,team-droid' +ln -s "$(command -v ccs)" /usr/local/bin/mydroid +CCS_TARGET_ALIASES='droid=mydroid' # Legacy fallback still supported: -CCS_DROID_ALIASES='mydroid,team-droid' +CCS_DROID_ALIASES='mydroid' ``` For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 6c4e1647..1fba949e 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -86,7 +86,7 @@ Spawn Target Process - Spawns: `droid -m custom:ccs- ` - Model config includes baseUrl, apiKey, provider -**Runtime alias pattern (argv[0]-style):** +**Runtime alias pattern (built-in bins / argv[0]-style):** ``` ccs → Target: claude (default) diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 5d81005e..23a29a97 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -355,27 +355,28 @@ ccs --target droid glm ### Runtime Alias Pattern ```bash -# Create aliases/symlinks to auto-select droid target -ln -s /path/to/ccs /path/to/ccs-droid -ln -s /path/to/ccs /path/to/ccsd - -# Usage +# Built-in package bin aliases ccs-droid glm -→ Target: droid (detected from argv[0]) +→ Target: droid (forced by runtime alias) → droid -m custom:ccs-glm "args..." # Legacy shortcut still works ccsd glm -→ Target: droid (detected from argv[0]) +→ Target: droid (forced by runtime alias) → droid -m custom:ccs-glm "args..." ``` On Windows, `ccs-droid.cmd`, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. -Additional alias names can be configured at runtime via `CCS_TARGET_ALIASES` -(preferred, `target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` -(comma-separated). Example: -`CCS_TARGET_ALIASES=droid=mydroid,team-droid` +Additional alias names can be configured at runtime after you create a matching +symlink or another launcher that preserves the invoked basename. Use `CCS_TARGET_ALIASES` (preferred, +`target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` (comma-separated). +Example: + +```bash +ln -s /path/to/ccs /path/to/mydroid +CCS_TARGET_ALIASES=droid=mydroid +``` --- diff --git a/package.json b/package.json index ef687955..c0dd9ea1 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "types": "dist/ccs.d.ts", "bin": { "ccs": "dist/ccs.js", - "ccs-droid": "dist/ccs.js", - "ccsd": "dist/ccs.js" + "ccs-droid": "dist/bin/droid-runtime.js", + "ccsd": "dist/bin/droid-runtime.js" }, "files": [ "dist/", diff --git a/scripts/add-shebang.js b/scripts/add-shebang.js index 96055446..e58074e8 100755 --- a/scripts/add-shebang.js +++ b/scripts/add-shebang.js @@ -4,36 +4,49 @@ const fs = require('fs'); const path = require('path'); +function getExecutablePaths() { + const packageJsonPath = path.join(__dirname, '../package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const binEntries = packageJson.bin || {}; + const uniqueRelativePaths = [...new Set(Object.values(binEntries))]; + + return uniqueRelativePaths.map((relativePath) => path.join(__dirname, '..', relativePath)); +} + /** - * Add shebang to dist/ccs.js and make executable + * Add shebangs to all published bin entrypoints and make them executable. * Run after: tsc */ function addShebang() { - const ccsPath = path.join(__dirname, '../dist/ccs.js'); - - if (!fs.existsSync(ccsPath)) { - console.error('[X] dist/ccs.js not found. Run tsc first.'); + const executablePaths = getExecutablePaths(); + const missingPaths = executablePaths.filter((executablePath) => !fs.existsSync(executablePath)); + if (missingPaths.length > 0) { + console.error(`[X] Missing built executable(s): ${missingPaths.join(', ')}. Run tsc first.`); process.exit(1); } - let content = fs.readFileSync(ccsPath, 'utf8'); + for (const executablePath of executablePaths) { + let content = fs.readFileSync(executablePath, 'utf8'); - // Add shebang if missing - if (!content.startsWith('#!/usr/bin/env node')) { - content = '#!/usr/bin/env node\n' + content; - fs.writeFileSync(ccsPath, content); - console.log('[OK] Shebang added to dist/ccs.js'); - } + if (!content.startsWith('#!/usr/bin/env node')) { + content = '#!/usr/bin/env node\n' + content; + fs.writeFileSync(executablePath, content); + console.log(`[OK] Shebang added to ${path.relative(path.join(__dirname, '..'), executablePath)}`); + } - // Make executable (Unix-like systems) - if (process.platform !== 'win32') { - try { - fs.chmodSync(ccsPath, 0o755); - console.log('[OK] dist/ccs.js is now executable'); - } catch (err) { - console.warn('[!] Could not chmod dist/ccs.js:', err.message); + if (process.platform !== 'win32') { + try { + fs.chmodSync(executablePath, 0o755); + console.log( + `[OK] ${path.relative(path.join(__dirname, '..'), executablePath)} is now executable` + ); + } catch (err) { + console.warn( + `[!] Could not chmod ${path.relative(path.join(__dirname, '..'), executablePath)}: ${err.message}` + ); + } } } } -addShebang(); \ No newline at end of file +addShebang(); diff --git a/src/bin/droid-runtime.ts b/src/bin/droid-runtime.ts new file mode 100644 index 00000000..de641d2a --- /dev/null +++ b/src/bin/droid-runtime.ts @@ -0,0 +1,2 @@ +process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; +require('../ccs'); diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index eced5f8a..a7a5999d 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -3,8 +3,8 @@ * * Resolves which CLI target to use based on: * 1. --target flag (highest priority) - * 2. Per-profile config - * 3. argv[0] detection (busybox/symlink pattern) + * 2. Runtime alias entrypoint / argv[0] detection + * 3. Per-profile config * 4. Default: 'claude' */ @@ -20,14 +20,20 @@ const BUILTIN_ARGV0_TARGET_MAP: Record = { ccsd: 'droid', }; const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; +const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET'; const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = { droid: 'CCS_DROID_ALIASES', }; +const RESERVED_BIN_NAMES = new Set(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]); function addAliasToMap(map: Record, alias: string, target: TargetType): void { const normalizedAlias = alias.trim().toLowerCase(); - if (!normalizedAlias || !ALIAS_NAME_REGEX.test(normalizedAlias)) { + if ( + !normalizedAlias || + !ALIAS_NAME_REGEX.test(normalizedAlias) || + RESERVED_BIN_NAMES.has(normalizedAlias) + ) { return; } @@ -87,6 +93,16 @@ function buildArgv0TargetMap(): Record { return map; } +function resolveEntrypointTarget(): TargetType | null { + const rawTarget = process.env[INTERNAL_ENTRY_TARGET_ENV_VAR]; + if (!rawTarget) { + return null; + } + + const normalizedTarget = rawTarget.trim().toLowerCase(); + return isValidTarget(normalizedTarget) ? normalizedTarget : null; +} + /** * Valid target types for --target flag validation. */ @@ -172,13 +188,12 @@ export function resolveTargetType( return parsed.targetOverride; } - // 2. Check per-profile config - if (profileConfig?.target !== undefined) { - return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + // 2. Check runtime alias entrypoint / argv[0] + const entrypointTarget = resolveEntrypointTarget(); + if (entrypointTarget) { + return entrypointTarget; } - // 3. Check argv[0] (busybox pattern) - // Strip common wrapper extensions for Windows shims/wrappers const rawBin = path.basename(process.argv[1] || process.argv0 || ''); const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, '').toLowerCase(); const argv0TargetMap = buildArgv0TargetMap(); @@ -187,6 +202,11 @@ export function resolveTargetType( return argv0Target; } + // 3. Check per-profile config + if (profileConfig?.target !== undefined) { + return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + } + // 4. Default return 'claude'; } diff --git a/tests/npm/cross-platform.test.js b/tests/npm/cross-platform.test.js index f99c56a8..ed2c68d8 100644 --- a/tests/npm/cross-platform.test.js +++ b/tests/npm/cross-platform.test.js @@ -129,7 +129,23 @@ describe('cross-platform', () => { assert(packageJson.bin, 'package.json should have bin field'); assert(packageJson.bin.ccs, 'bin field should specify ccs command'); + assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command'); + assert(packageJson.bin.ccsd, 'bin field should specify ccsd command'); + assert.notStrictEqual( + packageJson.bin['ccs-droid'], + packageJson.bin.ccs, + 'ccs-droid should use a dedicated runtime entrypoint' + ); + assert.strictEqual( + packageJson.bin['ccs-droid'], + packageJson.bin.ccsd, + 'legacy ccsd alias should share the dedicated droid runtime entrypoint' + ); + assert( + fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])), + 'dedicated droid runtime entrypoint should exist' + ); assert(packageJson.scripts, 'package.json should have scripts field'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 7628ed63..6e60a177 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -8,6 +8,7 @@ describe('resolveTargetType', () => { const originalArgv = process.argv; const originalDroidAliases = process.env.CCS_DROID_ALIASES; const originalTargetAliases = process.env.CCS_TARGET_ALIASES; + const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; afterEach(() => { process.argv = originalArgv; @@ -22,6 +23,12 @@ describe('resolveTargetType', () => { } else { process.env.CCS_TARGET_ALIASES = originalTargetAliases; } + + if (originalInternalEntryTarget === undefined) { + delete process.env.CCS_INTERNAL_ENTRY_TARGET; + } else { + process.env.CCS_INTERNAL_ENTRY_TARGET = originalInternalEntryTarget; + } }); it('should return claude as default', () => { @@ -82,12 +89,29 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'droid=team-droid'; + process.env.CCS_DROID_ALIASES = 'legacy-droid'; + + process.argv = ['node', 'team-droid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'legacy-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should ignore invalid custom alias entries', () => { process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,'; process.argv = ['node', '../bad']; expect(resolveTargetType([])).toBe('claude'); }); + it('should detect internal entry target for dedicated package bin entrypoints', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should normalize argv[0] and custom aliases case-insensitively', () => { process.env.CCS_DROID_ALIASES = 'DroidCaps'; process.argv = ['node', 'DROIDCAPS']; @@ -134,9 +158,41 @@ describe('resolveTargetType', () => { expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); }); - it('should prioritize profile config over argv[0]', () => { + it('should prioritize --target over internal entry target', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); + }); + + it('should prioritize runtime alias over profile config', () => { process.argv = ['node', 'ccsd']; - expect(resolveTargetType([], { target: 'claude' })).toBe('claude'); + expect(resolveTargetType([], { target: 'claude' })).toBe('droid'); + }); + + it('should prioritize internal entry target over profile config', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'claude' })).toBe('droid'); + }); + + it('should keep reserved command names authoritative', () => { + process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid'; + process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid'; + + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('claude'); + + process.argv = ['node', 'ccs-droid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'ccsd']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'mydroid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'legacy-droid']; + expect(resolveTargetType([])).toBe('droid'); }); it('should throw for invalid --target value', () => { diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 8cb6835d..bc6c8f5f 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -93,8 +93,8 @@ export function ProviderInfoTab({ command={`ccs-droid ${provider} "your prompt"`} /> diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx index 535b4b4e..6c6122c0 100644 --- a/ui/src/components/profiles/editor/info-section.tsx +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -103,14 +103,14 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- ccs {profileName} --target claude "prompt" + ccs {profileName} --target {isDroidTarget ? 'claude' : 'droid'} "prompt" diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index fe32c183..ca256fe2 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -526,23 +526,23 @@ export function ProfileCreateDialog({

{targetValue === 'droid' ? ( <> - Preferred explicit alias:{' '} + {t('profileEditor.targetHintPreferredAlias')}{' '} ccs-droid. ) : ( <> - Run with ccs by - default. + {t('profileEditor.targetHintClaudeDefault')}{' '} + ccs. )} {targetValue === 'droid' ? ( <> {' '} - Legacy ccsd still - works for Droid. + {t('profileEditor.targetHintLegacyAlias')}{' '} + ccsd. ) : null}{' '} - You can still override each run with{' '} + {t('profileEditor.targetHintOverride')}{' '} --target.

diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 237be3e4..099156d6 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -619,9 +619,14 @@ const resources = { quickUsage: 'Quick Usage', runWithProfile: 'Run with profile', runOnDroid: 'Run on Droid', + runOnDroidWithFlag: 'Run on Droid (--target)', droidAliasExplicit: 'Droid alias (explicit)', overrideToClaude: 'Override to Claude', overrideToClaudeExplicit: 'Override to Claude (explicit)', + targetHintPreferredAlias: 'Preferred explicit alias:', + targetHintClaudeDefault: 'Default command:', + targetHintLegacyAlias: 'Legacy shortcut still works:', + targetHintOverride: 'You can still override each run with', setAsDefault: 'Set as default', provider: 'Provider', custom: 'Custom', @@ -1790,9 +1795,14 @@ const resources = { quickUsage: '快速使用', runWithProfile: '使用配置运行', runOnDroid: '在 Droid 上运行', + runOnDroidWithFlag: '通过 --target 在 Droid 上运行', droidAliasExplicit: 'Droid 别名(显式)', overrideToClaude: '切换为 Claude', overrideToClaudeExplicit: '切换为 Claude(显式)', + targetHintPreferredAlias: '推荐显式别名:', + targetHintClaudeDefault: '默认命令:', + targetHintLegacyAlias: '旧快捷方式仍可用:', + targetHintOverride: '你仍可在每次运行时用', setAsDefault: '设为默认', provider: '提供商', custom: '自定义', @@ -2986,9 +2996,14 @@ const resources = { quickUsage: 'Sử dụng nhanh', runWithProfile: 'Chạy với hồ sơ', runOnDroid: 'Chạy trên Droid', + runOnDroidWithFlag: 'Chạy trên Droid (--target)', droidAliasExplicit: 'Bí danh Droid (rõ ràng)', overrideToClaude: 'Ghi đè lên Claude', overrideToClaudeExplicit: 'Ghi đè lên Claude (rõ ràng)', + targetHintPreferredAlias: 'Bí danh rõ ràng nên dùng:', + targetHintClaudeDefault: 'Lệnh mặc định:', + targetHintLegacyAlias: 'Lối tắt cũ vẫn dùng được:', + targetHintOverride: 'Bạn vẫn có thể ghi đè mỗi lần chạy bằng', setAsDefault: 'Đặt làm mặc định', provider: 'Nhà cung cấp', custom: 'Tùy chỉnh', @@ -4206,9 +4221,14 @@ const resources = { quickUsage: 'クイック実行', runWithProfile: 'このプロファイルで実行', runOnDroid: 'Droid で実行', + runOnDroidWithFlag: '--target で Droid 実行', droidAliasExplicit: 'Droid エイリアス(明示)', overrideToClaude: 'Claude に切り替え', overrideToClaudeExplicit: 'Claude に切り替え(明示)', + targetHintPreferredAlias: '推奨の明示エイリアス:', + targetHintClaudeDefault: 'デフォルトコマンド:', + targetHintLegacyAlias: '従来ショートカットも利用可能:', + targetHintOverride: '実行ごとに次でも上書きできます', setAsDefault: 'デフォルトに設定', provider: 'プロバイダー', custom: 'カスタム',