diff --git a/cli/__tests__/apply-init.test.mts b/cli/__tests__/apply-init.test.mts index 686f9c4f..7157aed4 100644 --- a/cli/__tests__/apply-init.test.mts +++ b/cli/__tests__/apply-init.test.mts @@ -43,6 +43,7 @@ describe('selection helpers', () => { structure: true, }); expect(s.guards).toEqual(['size', 'fanout', 'dup', 'clone', 'decisions', 'qavis-advisory']); + expect(s.agentTargets).toEqual(['claude', 'cursor']); }); it('normalizeSelection fills missing keys + drops unknown guards', () => { @@ -50,6 +51,10 @@ describe('selection helpers', () => { expect(s.biome).toBe(false); expect(s.tsconfig).toBe(true); expect(s.guards).toEqual(['size']); + expect(normalizeSelection({ agentTargets: null as never }).agentTargets).toEqual([ + 'claude', + 'cursor', + ]); }); it('parseFlags reads --no-* and --guards and --remove-deselected', () => { diff --git a/cli/commands/init.mts b/cli/commands/init.mts index cd7d1d98..986d1b01 100644 --- a/cli/commands/init.mts +++ b/cli/commands/init.mts @@ -50,6 +50,7 @@ import { replaceGuardBlock, } from '../lib/husky/husky-block.mts'; import { installSelfHostHook, isDevkitRepo, selfHostSelection } from '../lib/husky/self-host.mts'; +import { LEGACY_AGENT_PROVIDERS } from '../lib/install/agent-providers.mts'; import { ensureDevkitCacheGitignore } from '../lib/install/gitignore-cache.mts'; import { ensureFallowGitignore, @@ -292,8 +293,7 @@ function selectionFromFlags(flags: InitFlags): Selection { sel.searchSteering = flags.searchSteering && !flags.no.has('search-steering'); sel.agentHooks = flags.agentHooks && !flags.no.has('agent-hooks'); sel.searchCode = flags.searchCode && !flags.no.has('search-code'); - // Agent surfaces: both by default; --no-claude / --no-cursor drop one (don't double-install). - // ponytail: --no-claude --no-cursor leaves [] → skills/agents sync nowhere (explicit, allowed). + // Fresh defaults minus explicit --no-; selecting none is allowed. sel.agentTargets = AGENT_TARGETS.filter((t) => !flags.no.has(t)); return sel; } @@ -1068,7 +1068,7 @@ function pruneDeselectedSurfaces( hookComponents: string[], dryRun: boolean, ) { - const prunedTargets = AGENT_TARGETS.filter((t) => !agentTargets.includes(t)); + const prunedTargets = LEGACY_AGENT_PROVIDERS.filter((t) => !agentTargets.includes(t)); // Settings file holding hook registrations differs per surface (Claude settings.json vs Cursor // hooks.json) — searchSteering writes one without a hooks/ script dir, so check it too. const settingsFile: Record = { diff --git a/cli/commands/upgrade.mts b/cli/commands/upgrade.mts index 6bbe3ead..8fc3f41a 100644 --- a/cli/commands/upgrade.mts +++ b/cli/commands/upgrade.mts @@ -27,7 +27,6 @@ import { previewGrandfather, } from '../../gate-engine/ratchets/size-disable.mts'; import { - AGENT_TARGETS, applyOverlayConstraints, GUARD_OPTIONS, newBundledGates, @@ -38,6 +37,7 @@ import { detectGitRoot } from '../lib/detect-git-root.mts'; import { detectStack } from '../lib/detect-stack.mts'; import { packageDir, readJson } from '../lib/fs-helpers.mts'; import { selfHostSelection } from '../lib/husky/self-host.mts'; +import { resolveExistingAgentProviders } from '../lib/install/agent-providers.mts'; import { syncHookScripts } from '../lib/install/install-hooks.mts'; import doctor from './doctor.mts'; import { applyInit } from './init.mts'; @@ -111,15 +111,11 @@ export default async function upgrade(args: string[], cwd: string): Promise - existsSync(join(gitRoot, `.${t}`, 'skills')) || existsSync(join(gitRoot, `.${t}`, 'agents')), - ); - const agentTargets = rawTargets ?? (inferred.length ? inferred : AGENT_TARGETS); + const agentTargets = resolveExistingAgentProviders(gitRoot, rawTargets, ['skills', 'agents']); // Self-host (the devkit repo dogfooding itself): there is no published pin, no emitted-config // migration (configs are hand-owned), and the selection is FIXED (selfHostSelection — not the diff --git a/cli/lib/components.mts b/cli/lib/components.mts index 7ae305cb..afa1e74e 100644 --- a/cli/lib/components.mts +++ b/cli/lib/components.mts @@ -8,6 +8,11 @@ * (--yes / non-TTY), and the guard sub-gate set (the husky `# devkit-guards` lines). */ +import { + FRESH_DEFAULT_AGENT_PROVIDERS, + normalizeAgentProviders, +} from './install/agent-providers.mts'; + /** The recommended-on gate-engine sub-gates (the --yes / non-TTY default guard set). */ export const RECOMMENDED_GUARD_IDS = [ 'size', @@ -27,12 +32,11 @@ export const RECOMMENDED_GUARD_IDS = [ export const GUARD_IDS = [...RECOMMENDED_GUARD_IDS, 'review', 'sentry']; /** - * The agent surfaces devkit can sync skills/agents/agent-hooks into: Claude (`.claude/`) and - * Cursor (`.cursor/`). `selection.agentTargets` picks the subset to write to (default both) so a - * repo that only uses one tool doesn't get a redundant copy in the other's dir. Surface `` - * maps to the `./` dir (claude → .claude, cursor → .cursor). + * Compatibility name for the agent surfaces the current projection layer can sync into. Provider + * support/default policy now lives in agent-providers.mts; keeping this legacy name on the fresh + * Claude/Cursor default prevents the model-only slice from activating Codex projection early. */ -export const AGENT_TARGETS = ['claude', 'cursor']; +export const AGENT_TARGETS: string[] = [...FRESH_DEFAULT_AGENT_PROVIDERS]; /** * The top-level components, in wizard order. `recommended` seeds the --yes / non-TTY @@ -150,7 +154,7 @@ export function defaultSelection(): Selection { // Recommended-on: a fresh repo has no giants (or they're grandfathered by init's freeze), so the // cap is pure upside. Deselectable in the wizard / via --no-line-growth. lineGrowth: true, - agentTargets: [...AGENT_TARGETS], + agentTargets: [...FRESH_DEFAULT_AGENT_PROVIDERS], guards: [...RECOMMENDED_GUARD_IDS], }; } @@ -183,8 +187,8 @@ export function normalizeSelection(partial: Partial = {}): Selection return { ...base, ...partial, - agentTargets: partial.agentTargets - ? partial.agentTargets.filter((t) => AGENT_TARGETS.includes(t)) + agentTargets: Array.isArray(partial.agentTargets) + ? normalizeAgentProviders(partial.agentTargets) : base.agentTargets, guards: partial.guards ? partial.guards.filter((g) => GUARD_IDS.includes(g)) : base.guards, }; diff --git a/cli/lib/install/agent-providers.mts b/cli/lib/install/agent-providers.mts new file mode 100644 index 00000000..50c785d7 --- /dev/null +++ b/cli/lib/install/agent-providers.mts @@ -0,0 +1,51 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Every agent provider the staged provider stack knows how to model. */ +export const SUPPORTED_AGENT_PROVIDERS = ['claude', 'codex', 'cursor'] as const; +export type AgentProvider = (typeof SUPPORTED_AGENT_PROVIDERS)[number]; + +/** Providers devkit could historically own before provider support was recorded explicitly. */ +export const LEGACY_AGENT_PROVIDERS = [ + 'claude', + 'cursor', +] as const satisfies readonly AgentProvider[]; + +/** Fresh-install defaults for this slice. A later activation can change only this policy. */ +export const FRESH_DEFAULT_AGENT_PROVIDERS = [ + 'claude', + 'cursor', +] as const satisfies readonly AgentProvider[]; + +export type AgentAssetKind = 'skills' | 'agents' | 'hooks'; + +const ALL_AGENT_ASSET_KINDS = ['skills', 'agents', 'hooks'] as const; +const SUPPORTED_AGENT_PROVIDER_NAMES = new Set(SUPPORTED_AGENT_PROVIDERS); + +export function isAgentProvider(value: unknown): value is AgentProvider { + return typeof value === 'string' && SUPPORTED_AGENT_PROVIDER_NAMES.has(value); +} + +/** Validate and de-duplicate a recorded provider array without supplying defaults. */ +export function normalizeAgentProviders(values: readonly unknown[]): AgentProvider[] { + return [...new Set(values.filter(isAgentProvider))]; +} + +/** + * Resolve providers for an existing install. A recorded array is authoritative even when empty. + * Only a legacy config with no `agentTargets` key may infer ownership from disk, and that inference + * is intentionally limited to historical Claude/Cursor paths. Arbitrary `.codex` or `.agents` + * content may be user-owned and is never treated as devkit ownership evidence. + */ +export function resolveExistingAgentProviders( + root: string, + recorded?: readonly unknown[] | null, + kinds: readonly AgentAssetKind[] = ALL_AGENT_ASSET_KINDS, +): AgentProvider[] { + if (recorded != null) return normalizeAgentProviders(recorded); + + const inferred = LEGACY_AGENT_PROVIDERS.filter((provider) => + kinds.some((kind) => existsSync(join(root, `.${provider}`, kind))), + ); + return inferred.length ? inferred : [...LEGACY_AGENT_PROVIDERS]; +} diff --git a/cli/lib/install/agent-providers.test.mts b/cli/lib/install/agent-providers.test.mts new file mode 100644 index 00000000..543f8ce1 --- /dev/null +++ b/cli/lib/install/agent-providers.test.mts @@ -0,0 +1,65 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + FRESH_DEFAULT_AGENT_PROVIDERS, + LEGACY_AGENT_PROVIDERS, + resolveExistingAgentProviders, + SUPPORTED_AGENT_PROVIDERS, +} from './agent-providers.mts'; + +const roots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'devkit-agent-providers-')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('agent provider model', () => { + it('separates supported providers from legacy and fresh defaults', () => { + expect(SUPPORTED_AGENT_PROVIDERS).toEqual(['claude', 'codex', 'cursor']); + expect(LEGACY_AGENT_PROVIDERS).toEqual(['claude', 'cursor']); + expect(FRESH_DEFAULT_AGENT_PROVIDERS).toEqual(['claude', 'cursor']); + }); +}); + +describe('resolveExistingAgentProviders', () => { + it('keeps every explicit recorded array authoritative, including an empty array', () => { + const root = tempRoot(); + mkdirSync(join(root, '.claude', 'skills'), { recursive: true }); + mkdirSync(join(root, '.cursor', 'agents'), { recursive: true }); + + expect(resolveExistingAgentProviders(root, [])).toEqual([]); + expect(resolveExistingAgentProviders(root, null)).toEqual(['claude', 'cursor']); + expect( + resolveExistingAgentProviders(root, ['codex', 'claude', 'codex', 'unsupported']), + ).toEqual(['codex', 'claude']); + }); + + it('infers only historical Claude and Cursor surfaces for a legacy config', () => { + const root = tempRoot(); + mkdirSync(join(root, '.claude', 'skills'), { recursive: true }); + mkdirSync(join(root, '.cursor', 'agents'), { recursive: true }); + + expect(resolveExistingAgentProviders(root, undefined, ['skills'])).toEqual(['claude']); + expect(resolveExistingAgentProviders(root, undefined, ['agents'])).toEqual(['cursor']); + }); + + it('never infers ownership from existing .codex or .agents directories', () => { + const root = tempRoot(); + mkdirSync(join(root, '.codex', 'agents'), { recursive: true }); + mkdirSync(join(root, '.agents', 'skills'), { recursive: true }); + + expect(resolveExistingAgentProviders(root)).toEqual(['claude', 'cursor']); + }); + + it('falls back to the historical pair when a legacy config has no inferred surface', () => { + expect(resolveExistingAgentProviders(tempRoot())).toEqual(['claude', 'cursor']); + }); +});