diff --git a/.changeset/guided-maintainer-adoption.md b/.changeset/guided-maintainer-adoption.md new file mode 100644 index 00000000..76eb881e --- /dev/null +++ b/.changeset/guided-maintainer-adoption.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add guided adoption of existing package-owned skills with a read-only JSON plan, explicit batch registration and distribution choices, and interactive confirmation. Preserve authored guidance and prior records, reject stale plans, leave semantic review pending, and keep CI noninteractive. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 803d4256..44c21bc6 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -14,11 +14,16 @@ import type { import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' -import type { MaintainerCommandOptions } from './commands/maintainer.js' +import type { + MaintainerCommandOptions, + MaintainerCommandRuntime, +} from './commands/maintainer.js' import type { ReviewCommandOptions } from './commands/review.js' import type { ValidateCommandOptions } from './commands/validate.js' -function createCli(runtime: InstallCommandRuntime = {}): CAC { +function createCli( + runtime: InstallCommandRuntime & MaintainerCommandRuntime = {}, +): CAC { const cli = cac('intent') cli.usage(' [options]') @@ -191,7 +196,9 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { 'maintainer [name]', 'Set up, author, synchronize, and check library skills', ) - .usage('maintainer [name] [options]') + .usage( + 'maintainer [name] [options]', + ) .option( '--artifacts ', 'Established planning directory, relative to the repository root', @@ -200,7 +207,14 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { '--package ', 'Owning package directory, relative to the repository root', ) - .option('--path ', 'SKILL.md path, relative to the owning package') + .option( + '--path ', + 'Skill path for add, or repository-relative custom directory for adopt', + ) + .option( + '--apply ', + 'Apply reviewed adoption choices from a JSON plan', + ) .option('--domain ', 'Domain for a new skill') .option( '--distribution ', @@ -225,12 +239,15 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { 'Prerequisite skill; repeat for multiple skills', ) .option('--base ', 'Git revision to review against') - .option('--json', 'Output status or review as JSON') + .option('--json', 'Output an adoption plan, status, or review as JSON') .option( '--record ', 'Record outcomes from an annotated review report', ) .example('maintainer setup') + .example('maintainer adopt') + .example('maintainer adopt --json') + .example('maintainer adopt --apply adoption.json') .example( 'maintainer add caching --domain queries --description "Use when caching queries." --source "src/**"', ) @@ -246,7 +263,7 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { ) => { const { runMaintainerCommand } = await import('./commands/maintainer.js') - await runMaintainerCommand(action, name, options) + await runMaintainerCommand(action, name, options, runtime) }, ) @@ -366,7 +383,7 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { export async function main( argv: Array = process.argv.slice(2), - runtime: InstallCommandRuntime = {}, + runtime: InstallCommandRuntime & MaintainerCommandRuntime = {}, ) { try { const cli = createCli(runtime) diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 530a93b8..e150b459 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -1,10 +1,13 @@ -import { dirname, relative } from 'node:path' +import { readFileSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { isCI } from 'std-env' import { fail } from '../shared/cli-error.js' import { resolveMaintainerProject, setupRecords, } from '../maintainer/project.js' import { addSkill } from '../maintainer/add.js' +import { createAdoptionPlan, planAdoptionChanges } from '../maintainer/adopt.js' import { planMaintainerSync } from '../maintainer/sync.js' import { withMaintainerLock, writeChanges } from '../maintainer/files.js' import { createReview } from '../review/review.js' @@ -21,6 +24,13 @@ import { import { runReviewCommand } from './review.js' import { runValidateCommand } from './validate.js' import type { DistributionOptions } from '../maintainer/distribution.js' +import type { AdoptionPrompts } from '../maintainer/adopt.js' + +export interface MaintainerCommandRuntime { + isTTY?: boolean + isCI?: boolean + adoptionPrompts?: AdoptionPrompts +} export interface MaintainerCommandOptions extends DistributionOptions { artifacts?: string @@ -33,15 +43,18 @@ export interface MaintainerCommandOptions extends DistributionOptions { base?: string json?: boolean record?: string + apply?: string } export async function runMaintainerCommand( action: string, name: string | undefined, options: MaintainerCommandOptions, + runtime: MaintainerCommandRuntime = {}, ): Promise { const allowed: Record> = { setup: ['artifacts', 'distribution', 'repository', 'pluginName', 'skill'], + adopt: ['artifacts', 'json', 'path', 'apply'], add: [ 'artifacts', 'package', @@ -58,7 +71,7 @@ export async function runMaintainerCommand( } if (!allowed[action]) fail( - `Unknown maintainer action: ${action}. Expected setup, add, status, sync, review, or check.`, + `Unknown maintainer action: ${action}. Expected setup, adopt, add, status, sync, review, or check.`, ) if (name !== undefined && action !== 'add') fail(`maintainer ${action} does not take a skill name.`) @@ -71,6 +84,66 @@ export async function runMaintainerCommand( return } const project = resolveMaintainerProject(process.cwd(), options.artifacts) + if (action === 'adopt') { + let input: unknown + if (options.apply) { + if (options.json || options.path) + fail('--apply cannot be combined with --json or --path.') + input = JSON.parse(readFileSync(resolve(options.apply), 'utf8')) + } else { + const plan = createAdoptionPlan(project, options.path) + if (options.json) { + console.log(JSON.stringify(plan, null, 2)) + return + } + if ( + (runtime.isCI ?? isCI) || + !(runtime.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY)) + ) + fail( + 'Use maintainer adopt --json to preview, then --apply with explicit choices in noninteractive sessions.', + ) + for (const skill of plan.skills) + console.log( + `${JSON.stringify(skill.id)}: ${skill.status}${skill.problems.length ? ` (${skill.problems.join('; ')})` : ''}`, + ) + const prompts = + runtime.adoptionPrompts ?? + ( + await import('../maintainer/adoption-prompts.js') + ).createAdoptionPrompts() + const chosen = await prompts.choose(plan) + if (chosen === null) { + console.log('Adoption canceled. No files changed.') + return + } + const preview = planAdoptionChanges(project, chosen) + const files = preview.changes.map((change) => + relative(project.root, change.path), + ) + if (!(await prompts.confirm(chosen, files))) { + console.log('Adoption canceled. No files changed.') + return + } + input = chosen + } + await withMaintainerLock(project.root, () => { + const plan = planAdoptionChanges(project, input) + writeChanges(project.root, plan.changes) + writeIntentSkillsBlock({ + ...buildMaintainerGuidanceBlock( + detectIntentCommandPackageManager(project.root), + ), + root: project.root, + namespace: 'intent-maintainer', + skipWhenEmpty: false, + }) + console.log( + `Registered ${plan.paths.length} skill(s). Authored task coverage and source review remain required.`, + ) + }) + return + } if (['setup', 'add', 'sync'].includes(action)) { await withMaintainerLock(project.root, () => { if (action === 'setup') { @@ -90,6 +163,9 @@ export async function runMaintainerCommand( console.log( 'Next: intent maintainer add --domain --description --source . Use --package for a workspace package. Use intent meta generate-skill for the authoring procedure.', ) + console.log( + 'For existing skills, run intent maintainer adopt to review registrations.', + ) const distribution = readDistribution(project) if (!distribution) console.log(distributionChoice) console.log( diff --git a/packages/intent/src/maintainer/add.ts b/packages/intent/src/maintainer/add.ts index 859dea38..dc5aedd3 100644 --- a/packages/intent/src/maintainer/add.ts +++ b/packages/intent/src/maintainer/add.ts @@ -39,125 +39,145 @@ export function addSkill( name: string | undefined, options: AddSkillOptions, ): string { - if (!name || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) - throw new Error( - 'Choose a skill name of at most 64 lowercase letters, numbers, and hyphens.', - ) - if (!options.domain?.trim()) - throw new Error('Choose the task domain with --domain .') - const entries = skillEntries(project) - if (entries.some((entry) => (entry.slug ?? entry.name) === name)) - throw new Error( - `Skill ${name} is already registered. Edit its SKILL.md, then run intent maintainer sync.`, - ) - const packageDir = options.package === '.' ? undefined : options.package - const entry: SkillEntry = { - name, - slug: name, - domain: options.domain, - ...(packageDir ? { package: packageDir } : {}), - path: options.path ?? `skills/${name}/SKILL.md`, - } - const path = skillPath(project, entry) - if (basename(dirname(path)) !== name) - throw new Error('The skill name must match its parent directory.') - if (entries.some((existing) => skillPath(project, existing) === path)) - throw new Error(`Skill path is already registered: ${entry.path}`) - const packageRoot = packageDir - ? projectPath(project.root, packageDir) - : project.root - const manifestPath = projectPath( - project.root, - packageDir ? `${packageDir}/package.json` : 'package.json', - ) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) - if ( - resolveProjectContext({ cwd: project.root, targetPath: path }) - .packageRoot !== packageRoot - ) - throw new Error('The skill path must belong to the selected package.') - let frontmatter: Record - const changes: Array = [] - if (existsSync(path)) { - if (options.description || options.source || options.requires) + const plan = planAddSkills(project, [{ name, options }]) + writeChanges(project.root, plan.changes) + return plan.paths[0]! +} + +export function planAddSkills( + project: MaintainerProject, + additions: Array<{ name: string | undefined; options: AddSkillOptions }>, + initialChanges: Array = [], +) { + const changes = [...initialChanges] + const entries = skillEntries(project, changes) + const tree = readRecord(project, 'skill_tree.yaml', changes) + const map = readRecord(project, 'domain_map.yaml', changes) + const specPath = recordPath(project, 'skill_spec.md') + const specChange = changes.find((change) => change.path === specPath) + const spec = existsSync(specPath) ? readFileSync(specPath, 'utf8') : null + let nextSpec = specChange?.content ?? spec + if (nextSpec === null) + throw new Error('Missing skill_spec.md. Run intent maintainer setup.') + const paths: Array = [] + for (const { name, options } of additions) { + if (!name || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) throw new Error( - 'To register an existing skill, supply its --path and --domain; edit its frontmatter directly before running sync.', + 'Choose a skill name of at most 64 lowercase letters, numbers, and hyphens.', ) - const parsed = parseFrontmatter(path) - if (!isObject(parsed) || parsed.name !== name) + if (!options.domain?.trim()) + throw new Error('Choose the task domain with --domain .') + if (entries.some((entry) => (entry.slug ?? entry.name) === name)) throw new Error( - 'Existing skill has invalid frontmatter or a different name.', + `Skill ${name} is already registered. Edit its SKILL.md, then run intent maintainer sync.`, ) - frontmatter = parsed - } else { - if (!options.description?.trim()) - throw new Error('A new skill needs --description .') - const sources = stringList( - options.source === undefined ? [] : [options.source].flat(), - 'sources', - ) - if (!sources.length) - throw new Error('A new skill needs at least one --source .') - frontmatter = { - name, - description: options.description, - metadata: { library: manifest.name }, - sources, - ...(options.requires - ? { requires: stringList([options.requires].flat(), 'requires') } - : {}), - } - changes.push({ - path, - source: null, - content: `---\n${stringify(frontmatter)}---\n\n${authoringMarker}\n\nWrite the task procedure, working examples, source-backed pitfalls, and completion checks. Add metadata.purpose in your own words. Remove the authoring marker after writing and checking the guidance.\n`, - }) - } - if ( - typeof frontmatter.description !== 'string' || - !frontmatter.description.trim() - ) - throw new Error('A skill needs a non-empty description.') - entry.description = frontmatter.description - entry.sources = stringList(frontmatter.sources ?? [], 'sources') - entry.requires = stringList(frontmatter.requires ?? [], 'requires') - if ( - isObject(frontmatter.metadata) && - typeof frontmatter.metadata.purpose === 'string' - ) - entry.purpose = frontmatter.metadata.purpose - const tree = readRecord(project, 'skill_tree.yaml') - tree.document.addIn(['skills'], entry) - changes.push({ - path: tree.path, - source: tree.source, - content: tree.document.toString(), - }) - const map = readRecord(project, 'domain_map.yaml') - const mapSkills: Array> = map.document.toJS().skills - if (!mapSkills.some((skill) => skill.slug === name)) { - map.document.addIn(['skills'], { + const packageDir = options.package === '.' ? undefined : options.package + const entry: SkillEntry = { name, slug: name, domain: options.domain, - description: entry.purpose ?? entry.description, - ...(packageDir ? { packages: [manifest.name] } : {}), - tasks: [], - covers: [], - }) + ...(packageDir ? { package: packageDir } : {}), + path: options.path ?? `skills/${name}/SKILL.md`, + } + const path = skillPath(project, entry) + if (basename(dirname(path)) !== name) + throw new Error('The skill name must match its parent directory.') + if (entries.some((existing) => skillPath(project, existing) === path)) + throw new Error(`Skill path is already registered: ${entry.path}`) + const packageRoot = packageDir + ? projectPath(project.root, packageDir) + : project.root + const manifestPath = projectPath( + project.root, + packageDir ? `${packageDir}/package.json` : 'package.json', + ) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if ( + resolveProjectContext({ cwd: project.root, targetPath: path }) + .packageRoot !== packageRoot + ) + throw new Error('The skill path must belong to the selected package.') + let frontmatter: Record + if (existsSync(path)) { + if (options.description || options.source || options.requires) + throw new Error( + 'To register an existing skill, supply its --path and --domain; edit its frontmatter directly before running sync.', + ) + const parsed = parseFrontmatter(path) + if (!isObject(parsed) || parsed.name !== name) + throw new Error( + 'Existing skill has invalid frontmatter or a different name.', + ) + frontmatter = parsed + } else { + if (!options.description?.trim()) + throw new Error('A new skill needs --description .') + const sources = stringList( + options.source === undefined ? [] : [options.source].flat(), + 'sources', + ) + if (!sources.length) + throw new Error('A new skill needs at least one --source .') + frontmatter = { + name, + description: options.description, + metadata: { library: manifest.name }, + sources, + ...(options.requires + ? { requires: stringList([options.requires].flat(), 'requires') } + : {}), + } + changes.push({ + path, + source: null, + content: `---\n${stringify(frontmatter)}---\n\n${authoringMarker}\n\nWrite the task procedure, working examples, source-backed pitfalls, and completion checks. Add metadata.purpose in your own words. Remove the authoring marker after writing and checking the guidance.\n`, + }) + } + if ( + typeof frontmatter.description !== 'string' || + !frontmatter.description.trim() + ) + throw new Error('A skill needs a non-empty description.') + entry.description = frontmatter.description + entry.sources = stringList(frontmatter.sources ?? [], 'sources') + entry.requires = stringList(frontmatter.requires ?? [], 'requires') + if ( + isObject(frontmatter.metadata) && + typeof frontmatter.metadata.purpose === 'string' + ) + entry.purpose = frontmatter.metadata.purpose + tree.document.addIn(['skills'], entry) + entries.push(entry) + const mapSkills: Array> = map.document.toJS().skills + if (!mapSkills.some((skill) => skill.slug === name)) { + map.document.addIn(['skills'], { + name, + slug: name, + domain: options.domain, + description: entry.purpose ?? entry.description, + ...(packageDir ? { packages: [manifest.name] } : {}), + tasks: [], + covers: [], + }) + } + nextSpec = `${nextSpec.trimEnd()}\n\n- Registered \`${name}\` in \`${packageDir ?? '.'}\` (domain \`${options.domain}\`). Task coverage, decisions, and checks still need to be recorded.\n` + paths.push(join(packageDir ?? '', entry.path)) + } + if (additions.length) { + changes.push( + { + path: tree.path, + source: tree.source, + content: tree.document.toString(), + }, + { path: map.path, source: map.source, content: map.document.toString() }, + { path: specPath, source: spec, content: nextSpec }, + ) + } + return { + changes: [ + ...new Map(changes.map((change) => [change.path, change])).values(), + ], + paths, } - changes.push({ - path: map.path, - source: map.source, - content: map.document.toString(), - }) - const specPath = recordPath(project, 'skill_spec.md') - const spec = readFileSync(specPath, 'utf8') - changes.push({ - path: specPath, - source: spec, - content: `${spec.trimEnd()}\n\n- Registered \`${name}\` in \`${packageDir ?? '.'}\` (domain \`${options.domain}\`). Task coverage, decisions, and checks still need to be recorded.\n`, - }) - writeChanges(project.root, changes) - return join(packageDir ?? '', entry.path) } diff --git a/packages/intent/src/maintainer/adopt.ts b/packages/intent/src/maintainer/adopt.ts new file mode 100644 index 00000000..7ee62c87 --- /dev/null +++ b/packages/intent/src/maintainer/adopt.ts @@ -0,0 +1,355 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { basename, dirname, relative } from 'node:path' +import { resolveProjectContext } from '../core/project-context.js' +import { resolveWorkspacePackages } from '../setup/workspace-patterns.js' +import { parseFrontmatter } from '../shared/utils.js' +import { planAddSkills, stringList } from './add.js' +import { + inferDistributionRepository, + planDistributionChoice, + readDistribution, +} from './distribution.js' +import { + isObject, + planSetupRecords, + projectPath, + readRecord, + recordPath, + skillEntries, + skillPath, +} from './project.js' +import type { MaintainerProject } from './project.js' + +export interface AdoptionSkill { + id: string + name: string + package: string + path: string + description: string + domain: string + status: + | 'unregistered' + | 'registered' + | 'missing' + | 'invalid' + | 'conflict' + | 'planned' + | 'retired' + problems: Array + selected: boolean +} + +export function createAdoptionPlan( + project: MaintainerProject, + directory?: string, +) { + if (directory) projectPath(project.root, directory) + const files = execFileSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + ], + { cwd: project.root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, + ) + .split('\0') + .filter(Boolean) + const records = ['domain_map.yaml', 'skill_spec.md', 'skill_tree.yaml'].map( + (name) => { + const path = recordPath(project, name) + return [path, existsSync(path) ? readFileSync(path, 'utf8') : null] + }, + ) + const hasTree = existsSync(recordPath(project, 'skill_tree.yaml')) + const entries = hasTree ? skillEntries(project) : [] + const registered = new Map( + entries.map((entry) => [ + relative(project.root, skillPath(project, entry)).replaceAll('\\', '/'), + entry, + ]), + ) + const mapPath = recordPath(project, 'domain_map.yaml') + const mapped = existsSync(mapPath) + ? readRecord(project, 'domain_map.yaml').document.toJS().skills + : [] + const paths = [ + ...new Set([ + ...files.filter( + (path) => + basename(path) === 'SKILL.md' && + !path + .split('/') + .some((part) => part.startsWith('.') || part === 'node_modules') && + (/(^|\/)skills\//.test(path) || + (directory && path.startsWith(`${directory}/`))), + ), + ...registered.keys(), + ]), + ].sort() + const snapshot: Array = [ + records, + ...[ + 'package.json', + 'pnpm-workspace.yaml', + 'AGENTS.md', + 'CLAUDE.md', + '.cursorrules', + '.github/copilot-instructions.md', + '.claude-plugin/plugin.json', + '.cursor-plugin/plugin.json', + ].map((name) => { + const path = projectPath(project.root, name) + return [name, existsSync(path) ? readFileSync(path, 'utf8') : null] + }), + ] + const packageRoots = new Set([ + project.root, + ...resolveWorkspacePackages( + project.root, + resolveProjectContext({ cwd: project.root }).workspacePatterns, + ), + ]) + const skills = paths.flatMap((id) => { + const entry = registered.get(id) + const candidate: AdoptionSkill = { + id, + name: String(entry?.slug ?? entry?.name ?? basename(dirname(id))), + package: entry?.package ?? '', + path: entry?.path ?? id, + description: '', + domain: typeof entry?.domain === 'string' ? entry.domain : '', + status: entry ? 'registered' : 'unregistered', + problems: [], + selected: false, + } + if (entry?.status === 'planned' || entry?.status === 'retired') { + candidate.status = entry.status + return [candidate] + } + try { + const absolute = projectPath(project.root, id) + if (!existsSync(absolute)) { + candidate.status = 'missing' + candidate.problems.push('Registered skill file is missing.') + snapshot.push([id, null]) + return [candidate] + } + const context = resolveProjectContext({ + cwd: project.root, + targetPath: absolute, + }) + if (!context.packageRoot) + throw new Error('Skill has no owning package.json.') + if ( + !entry && + !packageRoots.has(context.packageRoot) && + !(directory && id.startsWith(`${directory}/`)) + ) + return [] + candidate.package = relative( + project.root, + context.packageRoot, + ).replaceAll('\\', '/') + candidate.path = relative(context.packageRoot, absolute).replaceAll( + '\\', + '/', + ) + const manifest = projectPath( + project.root, + candidate.package + ? `${candidate.package}/package.json` + : 'package.json', + ) + snapshot.push([ + id, + readFileSync(absolute, 'utf8'), + readFileSync(manifest, 'utf8'), + ]) + const frontmatter = parseFrontmatter(absolute) + if ( + !frontmatter || + typeof frontmatter.name !== 'string' || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(frontmatter.name) || + frontmatter.name.length > 64 || + frontmatter.name !== basename(dirname(id)) || + (entry && frontmatter.name !== candidate.name) + ) + throw new Error( + 'Skill name must match its directory and registered identity.', + ) + candidate.name = frontmatter.name + if ( + typeof frontmatter.description !== 'string' || + !frontmatter.description.trim() + ) + throw new Error('Skill needs a non-empty description.') + candidate.description = frontmatter.description + stringList(frontmatter.sources ?? [], 'sources') + stringList(frontmatter.requires ?? [], 'requires') + const mapping = mapped.find( + (value: unknown) => + typeof value === 'object' && + value !== null && + 'slug' in value && + value.slug === candidate.name, + ) + if (!candidate.domain && typeof mapping?.domain === 'string') + candidate.domain = mapping.domain + if (entry && (entry.package ?? '') !== candidate.package) + throw new Error('Skill ownership differs from its registration.') + } catch (error) { + candidate.status = 'invalid' + candidate.problems.push( + error instanceof Error ? error.message : String(error), + ) + } + return [candidate] + }) + for (const candidate of skills) { + if ( + skills.some( + (other) => other.id !== candidate.id && other.name === candidate.name, + ) + ) { + candidate.status = 'conflict' + candidate.problems.push( + 'Another skill has the same name. Resolve the identity before adoption.', + ) + } + } + return { + schemaVersion: 1 as const, + root: project.root, + artifacts: project.artifacts, + directory, + fingerprint: createHash('sha256') + .update(JSON.stringify([project, directory, snapshot, skills])) + .digest('hex'), + distribution: (hasTree ? readDistribution(project) : undefined) ?? { + mode: 'unconfigured' as const, + repository: inferDistributionRepository(project), + }, + skills, + } +} + +export type AdoptionPlan = ReturnType + +export interface AdoptionPrompts { + choose: (plan: AdoptionPlan) => Promise + confirm: (plan: AdoptionPlan, files: Array) => Promise +} + +export function planAdoptionChanges( + project: MaintainerProject, + input: unknown, +) { + if ( + !isObject(input) || + input.schemaVersion !== 1 || + input.root !== project.root || + input.artifacts !== project.artifacts || + !Array.isArray(input.skills) || + (input.directory !== undefined && typeof input.directory !== 'string') + ) + throw new Error( + 'Use an adoption plan from maintainer adopt --json in this repository.', + ) + const current = createAdoptionPlan(project, input.directory) + if (input.fingerprint !== current.fingerprint) + throw new Error( + 'Skills or planning records changed. Create a new adoption plan before applying.', + ) + if (input.skills.length !== current.skills.length) + throw new Error( + 'Keep every adoption plan entry; change selected and domain only.', + ) + const seen = new Set() + const additions = input.skills.flatMap((value: unknown) => { + if ( + !isObject(value) || + typeof value.id !== 'string' || + typeof value.selected !== 'boolean' || + typeof value.domain !== 'string' || + seen.has(value.id) + ) + throw new Error( + 'Each adoption choice needs a unique id, selected boolean, and domain string.', + ) + seen.add(value.id) + const candidate = current.skills.find((skill) => skill.id === value.id) + if ( + !candidate || + ['name', 'package', 'path', 'status'].some( + (key) => value[key] !== candidate[key as keyof AdoptionSkill], + ) + ) + throw new Error('Adoption identities changed. Create a new plan.') + if (!value.selected) return [] + if (candidate.status !== 'unregistered') + throw new Error(`Cannot adopt ${candidate.id}: ${candidate.status}.`) + if (!value.domain.trim()) + throw new Error(`Choose a domain for ${candidate.name}.`) + return [ + { + name: candidate.name, + options: { + package: candidate.package || undefined, + path: candidate.path, + domain: value.domain.trim(), + }, + }, + ] + }) + const plan = planAddSkills(project, additions, planSetupRecords(project)) + const choice = input.distribution + if ( + !isObject(choice) || + !['repo', 'none', 'unconfigured'].includes(String(choice.mode)) + ) + throw new Error( + 'Choose repository distribution, none, or leave the current choice unchanged.', + ) + if ( + choice.mode === 'unconfigured' && + current.distribution.mode !== 'unconfigured' + ) + throw new Error( + 'Preserve the distribution choice or select an explicit opt-out.', + ) + const distribution = planDistributionChoice( + project, + choice.mode === 'unconfigured' + ? {} + : { + distribution: String(choice.mode), + ...(choice.mode === 'repo' + ? { + repository: + typeof choice.repository === 'string' + ? choice.repository + : undefined, + pluginName: + typeof choice.name === 'string' ? choice.name : undefined, + skill: stringList(choice.skills, 'distribution.skills'), + } + : {}), + }, + plan.changes, + ) + if (distribution) { + const index = plan.changes.findIndex( + (change) => change.path === distribution.path, + ) + if (index < 0) plan.changes.push(distribution) + else plan.changes[index] = distribution + } + return plan +} diff --git a/packages/intent/src/maintainer/adoption-prompts.ts b/packages/intent/src/maintainer/adoption-prompts.ts new file mode 100644 index 00000000..590992b6 --- /dev/null +++ b/packages/intent/src/maintainer/adoption-prompts.ts @@ -0,0 +1,124 @@ +import { stdin, stdout } from 'node:process' +import { stripVTControlCharacters } from 'node:util' +import { + autocompleteMultiselect, + confirm, + isCancel, + select, + text, +} from '@clack/prompts' +import type { AdoptionPrompts } from './adopt.js' + +export function createAdoptionPrompts(): AdoptionPrompts { + const io = { input: stdin, output: stdout } + return { + async choose(plan) { + const available = plan.skills.filter( + (skill) => skill.status === 'unregistered', + ) + if (available.length) { + const selected = await autocompleteMultiselect({ + ...io, + message: 'Choose existing library skills to register', + options: available.map((skill) => ({ + value: skill.id, + label: skill.name, + hint: stripVTControlCharacters(skill.id), + })), + initialValues: [], + required: false, + maxItems: 6, + }) + if (isCancel(selected)) return null + for (const skill of available) { + skill.selected = selected.includes(skill.id) + if (!skill.selected || skill.domain.trim()) continue + const domain = await text({ + ...io, + message: `Domain for ${skill.name}`, + validate: (value) => + value?.trim() ? undefined : 'Enter the task domain.', + }) + if (isCancel(domain)) return null + skill.domain = domain.trim() + } + } + const mode = await select({ + ...io, + message: 'Repository distribution', + initialValue: 'keep', + options: [ + { + value: 'keep', + label: + plan.distribution.mode === 'unconfigured' + ? 'Decide later' + : `Keep current choice (${plan.distribution.mode})`, + }, + { value: 'repo', label: 'Choose skills for repository distribution' }, + { value: 'none', label: 'Package-only distribution' }, + ], + }) + if (isCancel(mode)) return null + if (mode === 'none') plan.distribution = { mode: 'none' } + if (mode === 'repo') { + const candidates = plan.skills.filter( + (skill) => skill.status === 'registered' || skill.selected, + ) + if (!candidates.length) + throw new Error( + 'Select skills to register before configuring exports.', + ) + const selected = await autocompleteMultiselect({ + ...io, + message: 'Select repository exports and their local prerequisites', + options: candidates.map((skill) => ({ + value: skill.name, + label: skill.name, + hint: stripVTControlCharacters(skill.id), + })), + initialValues: + 'skills' in plan.distribution ? plan.distribution.skills : [], + required: true, + maxItems: 6, + }) + if (isCancel(selected)) return null + let repository = plan.distribution.repository ?? '' + if (!repository) { + const answer = await text({ + ...io, + message: 'GitHub repository (owner/repo)', + validate: (value) => + value?.trim() ? undefined : 'Enter the GitHub repository.', + }) + if (isCancel(answer)) return null + repository = answer.trim() + } + plan.distribution = { + ...plan.distribution, + mode: 'repo', + repository, + skills: selected, + } + } + return plan + }, + async confirm(plan, files) { + console.log('\nProposed registrations:') + for (const skill of plan.skills.filter((entry) => entry.selected)) + console.log( + stripVTControlCharacters(` ${skill.id} -> ${skill.domain}`), + ) + console.log(`Distribution: ${plan.distribution.mode}`) + console.log('Planning files:') + for (const file of files) + console.log(` ${stripVTControlCharacters(file)}`) + const answer = await confirm({ + ...io, + message: 'Apply these choices and install maintainer guidance?', + initialValue: false, + }) + return !isCancel(answer) && answer + }, + } +} diff --git a/packages/intent/src/maintainer/distribution.ts b/packages/intent/src/maintainer/distribution.ts index a37624c2..32dfaf3d 100644 --- a/packages/intent/src/maintainer/distribution.ts +++ b/packages/intent/src/maintainer/distribution.ts @@ -22,7 +22,7 @@ export interface DistributionOptions { skill?: string | Array } -interface Distribution { +export interface Distribution { mode: 'repo' | 'none' repository?: string name?: string @@ -35,9 +35,13 @@ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ export function readDistribution( project: MaintainerProject, + changes: ReadonlyArray = [], ): Distribution | undefined { - const value: unknown = readRecord(project, 'skill_tree.yaml').document.toJS() - .distribution + const value: unknown = readRecord( + project, + 'skill_tree.yaml', + changes, + ).document.toJS().distribution if (value === undefined) return undefined if (!isObject(value) || !['repo', 'none'].includes(String(value.mode))) throw new Error('skill_tree.yaml distribution.mode must be repo or none.') @@ -66,10 +70,35 @@ function readJson(path: string): Record { return value } +export function inferDistributionRepository( + project: MaintainerProject, +): string { + const manifest = readJson(projectPath(project.root, 'package.json')) + const declared = isObject(manifest.repository) + ? manifest.repository.url + : manifest.repository + return typeof declared === 'string' + ? declared + .replace(/^git\+/, '') + .replace(/^https?:\/\/github\.com\//, '') + .replace(/^git@github\.com:/, '') + .replace(/\.git$/, '') + : '' +} + export function configureDistribution( project: MaintainerProject, options: DistributionOptions, ): void { + const change = planDistributionChoice(project, options) + if (change) writeChanges(project.root, [change]) +} + +export function planDistributionChoice( + project: MaintainerProject, + options: DistributionOptions, + changes: ReadonlyArray = [], +): FileChange | undefined { if (!options.distribution) { if (options.repository || options.pluginName || options.skill) throw new Error( @@ -79,7 +108,7 @@ export function configureDistribution( } if (!['repo', 'none'].includes(options.distribution)) throw new Error('--distribution must be repo or none.') - const previous = readDistribution(project) + const previous = readDistribution(project, changes) let distribution: Distribution if (options.distribution === 'none') { if (options.repository || options.pluginName || options.skill) @@ -88,21 +117,10 @@ export function configureDistribution( ) distribution = { ...previous, mode: 'none' } } else { - const manifest = readJson(projectPath(project.root, 'package.json')) - const repositoryField = manifest.repository - const declared = isObject(repositoryField) - ? repositoryField.url - : repositoryField const repository = options.repository ?? previous?.repository ?? - (typeof declared === 'string' - ? declared - .replace(/^git\+/, '') - .replace(/^https?:\/\/github\.com\//, '') - .replace(/^git@github\.com:/, '') - .replace(/\.git$/, '') - : '') + inferDistributionRepository(project) if (!repositoryPattern.test(repository)) throw new Error( 'Choose a GitHub repository with --repository .', @@ -136,7 +154,7 @@ export function configureDistribution( throw new Error( 'Select public skills explicitly with --skill (repeat for multiple skills).', ) - const entries = skillEntries(project) + const entries = skillEntries(project, changes) for (const selected of skills) { const entry = entries.find( (skill) => (skill.slug ?? skill.name) === selected, @@ -150,12 +168,14 @@ export function configureDistribution( } distribution = { mode: 'repo', repository, name, skills } } - const tree = readRecord(project, 'skill_tree.yaml') + const tree = readRecord(project, 'skill_tree.yaml', changes) if (JSON.stringify(previous) === JSON.stringify(distribution)) return tree.document.set('distribution', distribution) - writeChanges(project.root, [ - { path: tree.path, source: tree.source, content: tree.document.toString() }, - ]) + return { + path: tree.path, + source: tree.source, + content: tree.document.toString(), + } } function jsonChange( diff --git a/packages/intent/src/maintainer/project.ts b/packages/intent/src/maintainer/project.ts index 4aaf6182..044868a3 100644 --- a/packages/intent/src/maintainer/project.ts +++ b/packages/intent/src/maintainer/project.ts @@ -1,14 +1,10 @@ import { execFileSync } from 'node:child_process' -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'node:fs' +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { basename, dirname, join, relative } from 'node:path' import { parseDocument, stringify } from 'yaml' import { resolveProjectContext } from '../core/project-context.js' +import { writeChanges } from './files.js' +import type { FileChange } from './files.js' export const authoringMarker = '' const recordNames = ['domain_map.yaml', 'skill_spec.md', 'skill_tree.yaml'] @@ -89,12 +85,18 @@ export function recordPath(project: MaintainerProject, name: string): string { ) } -export function readRecord(project: MaintainerProject, name: string) { +export function readRecord( + project: MaintainerProject, + name: string, + changes: ReadonlyArray = [], +) { const path = recordPath(project, name) - if (!existsSync(path)) + const pending = changes.find((change) => change.path === path) + const source = existsSync(path) ? readFileSync(path, 'utf8') : null + const content = pending?.content ?? source + if (content === null) throw new Error(`Missing ${name}. Run intent maintainer setup.`) - const source = readFileSync(path, 'utf8') - const document = parseDocument(source) + const document = parseDocument(content) const parsed: unknown = document.toJS() if ( document.errors.length || @@ -117,10 +119,14 @@ export interface SkillEntry extends Record { package?: string } -export function skillEntries(project: MaintainerProject): Array { +export function skillEntries( + project: MaintainerProject, + changes: ReadonlyArray = [], +): Array { const entries: Array = readRecord( project, 'skill_tree.yaml', + changes, ).document.toJS().skills const names = new Set() const paths = new Set() @@ -160,6 +166,14 @@ export function skillPath( } export function setupRecords(project: MaintainerProject): Array { + const changes = planSetupRecords(project) + writeChanges(project.root, changes) + return changes.map((change) => relative(project.root, change.path)) +} + +export function planSetupRecords( + project: MaintainerProject, +): Array { const { root } = project const context = resolveProjectContext({ cwd: root }) if (!context.packageRoot) @@ -201,13 +215,11 @@ export function setupRecords(project: MaintainerProject): Array { if (name.endsWith('.yaml') && existsSync(recordPath(project, name))) readRecord(project, name) } - const created: Array = [] + const changes: Array = [] for (const [name, content] of Object.entries(defaults)) { const path = recordPath(project, name) if (existsSync(path)) continue - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content, { flag: 'wx' }) - created.push(relative(root, path)) + changes.push({ path, source: null, content }) } - return created + return changes } diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index b067c623..99e804f1 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -145,7 +145,7 @@ describe('packed release', () => { } expect(run(['scaffold']).status).toBe(1) expect(run(['maintainer', '--help']).stdout).toContain( - 'setup|add|status|sync|review|check', + 'setup|adopt|add|status|sync|review|check', ) }) diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index f5da52de..5ead42dc 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { parse } from 'yaml' import { main } from '../src/cli.js' import { createReview } from '../src/review/review.js' +import type { AdoptionPlan } from '../src/maintainer/adopt.js' let root: string let previousCwd: string @@ -223,6 +224,295 @@ it('preserves a planning record located directly at the repository root', async ) }) +it('previews existing library skills without writing or including agent and dependency skills', async () => { + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + write('packages/client/package.json', '{"name":"@library/client"}\n') + for (const directory of [ + 'skills/root-task', + 'packages/client/skills/query', + '.agents/skills/agent-only', + '.github/skills/review', + 'node_modules/dependency/skills/dependency', + ]) { + const name = directory.split('/').at(-1) + write( + `${directory}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for ${name}.\nsources: [package.json]\n---\nExisting guidance.\n`, + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills).toEqual([ + expect.objectContaining({ + name: 'query', + package: 'packages/client', + path: 'skills/query/SKILL.md', + status: 'unregistered', + selected: false, + }), + expect.objectContaining({ + name: 'root-task', + package: '', + path: 'skills/root-task/SKILL.md', + status: 'unregistered', + selected: false, + }), + ]) + expect(existsSync(join(root, '_artifacts'))).toBe(false) + expect(existsSync(join(root, '.intent'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) +}) + +it('excludes nested fixture packages unless their directory is explicitly requested', async () => { + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + for (const directory of ['packages/client', 'tests/fixtures/client']) { + write(`${directory}/package.json`, '{"name":"client"}\n') + write( + `${directory}/skills/query/SKILL.md`, + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills.map((skill: { id: string }) => skill.id)).toEqual([ + 'packages/client/skills/query/SKILL.md', + ]) + vi.mocked(console.log).mockClear() + expect( + await main([ + 'maintainer', + 'adopt', + '--path', + 'tests/fixtures/client/skills', + '--json', + ]), + ).toBe(0) + const explicit = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(explicit.skills).toHaveLength(2) +}) + +it('adopts a confirmed batch without rewriting skills or approving their content', async () => { + const guidance = + '---\nname: query\ndescription: Use when querying.\nmetadata:\n purpose: Preserve this purpose.\nsources: [package.json]\n---\nAuthored guidance.\n' + write('skills/query/SKILL.md', guidance) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + plan.skills[0].selected = true + plan.skills[0].domain = 'queries' + plan.distribution = { mode: 'none' } + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + expect(read('skills/query/SKILL.md')).toBe(guidance) + expect( + parse(read('skills/_artifacts/skill_tree.yaml')).skills[0], + ).toMatchObject({ + name: 'query', + domain: 'queries', + purpose: 'Preserve this purpose.', + }) + expect( + parse(read('skills/_artifacts/domain_map.yaml')).skills[0].tasks, + ).toEqual([]) + expect(read('skills/_artifacts/skill_spec.md')).toContain( + 'intent:needs-authoring', + ) + expect(read('AGENTS.md')).toContain('maintainer check') + expect(existsSync(join(root, '.intent/review-state.json'))).toBe(false) + expect(await main(['maintainer', 'check'])).toBe(1) + const before = ['skill_tree.yaml', 'domain_map.yaml', 'skill_spec.md'].map( + (name) => read(`skills/_artifacts/${name}`), + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const repeated = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(repeated.skills[0].status).toBe('registered') + write('adoption.json', JSON.stringify(repeated)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + expect( + ['skill_tree.yaml', 'domain_map.yaml', 'skill_spec.md'].map((name) => + read(`skills/_artifacts/${name}`), + ), + ).toEqual(before) +}) + +it('rejects invalid batch choices and stale adoption plans before creating records', async () => { + for (const name of ['query', 'cache']) + write( + `skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: ${name}\n---\nGuidance.\n`, + ) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + for (const candidate of plan.skills) candidate.selected = true + plan.skills[0].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + plan.skills[1].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + write( + 'skills/query/SKILL.md', + read('skills/query/SKILL.md') + 'Later change.\n', + ) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + 'changed', + ) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) +}) + +it('requires interactive confirmation and keeps cancellation read-only', async () => { + write( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + const choose = vi.fn((plan: AdoptionPlan) => { + plan.skills[0]!.selected = true + plan.skills[0]!.domain = 'queries' + plan.distribution = { mode: 'none' } + return Promise.resolve(plan) + }) + const confirm = vi.fn(() => Promise.resolve(false)) + const runtime = { + isTTY: true, + isCI: false, + adoptionPrompts: { choose, confirm }, + } + expect(await main(['maintainer', 'adopt'], runtime)).toBe(0) + expect(confirm).toHaveBeenCalledOnce() + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + confirm.mockResolvedValue(true) + expect(await main(['maintainer', 'adopt'], runtime)).toBe(0) + expect(parse(read('skills/_artifacts/skill_tree.yaml')).skills[0].name).toBe( + 'query', + ) +}) + +it('requires explicit noninteractive adoption and reports custom-root conflicts', async () => { + write( + 'guidance/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + expect(await main(['maintainer', 'adopt'], { isTTY: false })).toBe(1) + expect(existsSync(join(root, '.intent'))).toBe(false) + vi.mocked(console.log).mockClear() + expect( + await main(['maintainer', 'adopt', '--path', 'guidance', '--json']), + ).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills[0].id).toBe('guidance/query/SKILL.md') + write('skills/query/SKILL.md', read('guidance/query/SKILL.md')) + vi.mocked(console.log).mockClear() + expect( + await main(['maintainer', 'adopt', '--path', 'guidance', '--json']), + ).toBe(0) + const conflicts = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect( + conflicts.skills.map((skill: { status: string }) => skill.status), + ).toEqual(['conflict', 'conflict']) +}) + +it('never prompts in CI even when a terminal is attached', async () => { + const choose = vi.fn(() => Promise.resolve(null)) + const confirm = vi.fn(() => Promise.resolve(false)) + const runtime = { + isTTY: true, + isCI: true, + adoptionPrompts: { choose, confirm }, + } + expect(await main(['maintainer', 'adopt'], runtime)).toBe(1) + expect(await main(['maintainer', 'adopt', '--json'], runtime)).toBe(0) + expect(choose).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + expect(existsSync(join(root, '.intent'))).toBe(false) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) +}) + +it('keeps planned and retired entries separate from missing active skills', async () => { + expect(await main(['maintainer', 'setup'])).toBe(0) + write( + 'skills/_artifacts/skill_tree.yaml', + 'skills:\n - name: future\n path: skills/future/SKILL.md\n status: planned\n - name: old\n path: skills/old/SKILL.md\n status: retired\n - name: missing\n path: skills/missing/SKILL.md\n', + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills.map((skill: { status: string }) => skill.status)).toEqual([ + 'planned', + 'missing', + 'retired', + ]) +}) + +it('rejects adoption after repository instructions change', async () => { + write( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + plan.skills[0].selected = true + plan.skills[0].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + write('AGENTS.md', 'New maintainer instructions.\n') + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(read('AGENTS.md')).toBe('New maintainer instructions.\n') + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) +}) + +it('adopts two packages and saves an explicit distribution selection', async () => { + write( + 'package.json', + '{"name":"library","repository":"https://github.com/acme/library"}\n', + ) + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + for (const name of ['query', 'cache']) { + write(`packages/${name}/package.json`, `{"name":"@library/${name}"}\n`) + write( + `packages/${name}/skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for ${name}.\nsources: [package.json]\n---\nAuthored ${name} guidance.\n`, + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + for (const candidate of plan.skills) { + candidate.selected = true + candidate.domain = 'queries' + } + plan.distribution = { + mode: 'repo', + repository: 'acme/library', + skills: ['query'], + } + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + const tree = parse(read('_artifacts/skill_tree.yaml')) + expect( + tree.skills.map((skill: { package: string }) => skill.package), + ).toEqual(['packages/cache', 'packages/query']) + expect(tree.distribution).toEqual({ + mode: 'repo', + repository: 'acme/library', + name: 'acme-library', + skills: ['query'], + }) + expect(existsSync(join(root, '.claude-plugin'))).toBe(false) +}) + afterEach(() => { process.chdir(previousCwd) vi.restoreAllMocks()