Skip to content
Merged
2 changes: 1 addition & 1 deletion .changeset/guided-maintainer-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@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.
`maintainer setup` replaces `maintainer adopt`, which is removed along with its JSON plan and `--apply`; update any script that called it. Setup registers existing package-owned skills found under `skills/` directories that the skill tree does not record yet. Skill contents are preserved; the domain comes from `metadata.domain`, the domain map, or the parent directory, and defaults to `uncategorized` for the maintainer to edit. Invalid or conflicting skills are reported and left unregistered. Agent skill directories, dependencies, and packages outside the workspace are not scanned.
72 changes: 72 additions & 0 deletions benchmarks/intent/maintainer.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, bench, describe } from 'vitest'
import { createTempDir, writeFile, writeJson, writeSkill } from './helpers.ts'

const cliPath = fileURLToPath(
new URL('../../packages/intent/dist/cli.mjs', import.meta.url),
)

for (const count of [20, 200]) {
describe(`maintainer setup with ${count} existing skills`, () => {
let root: string | undefined
function setup() {
if (root) return
root = createTempDir('maintainer-setup')
writeJson(join(root, 'package.json'), {
name: '@bench/library',
version: '1.0.0',
})
writeFile(
join(root, '.github/workflows/check-skills.yml'),
'# Existing repository workflow\n',
)
for (let index = 0; index < count; index++) {
writeSkill(root, `task-${index}`, {
description: `Use for task ${index}.`,
sources: ['package.json'],
})
}
execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], {
cwd: root,
})
}
function teardown() {
if (root) rmSync(root, { recursive: true, force: true })
root = undefined
}
beforeAll(setup)
afterAll(teardown)

bench(
'registers the complete batch',
() => {
setup()
// Repeat first setup, without including fixture creation or a network lookup.
for (const path of ['skills/_artifacts', '.intent', 'AGENTS.md']) {
rmSync(join(root!, path), { recursive: true, force: true })
}
const result = spawnSync(
process.execPath,
[cliPath, 'maintainer', 'setup'],
{
cwd: root,
encoding: 'utf8',
timeout: 30_000,
},
)
if (
result.status !== 0 ||
result.stdout.split('Registered skills/').length - 1 !== count
) {
throw new Error(
`Incomplete registration: ${result.stdout}${result.stderr}`,
)
}
},
{ warmupIterations: 3, time: 3_000, setup, teardown },
)
})
}
16 changes: 3 additions & 13 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function createCli(
'Set up, author, synchronize, and check library skills',
)
.usage(
'maintainer <setup|adopt|add|remove|status|sync|review|check> [name] [options]',
'maintainer <setup|add|remove|status|sync|review|check> [name] [options]',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
.option(
'--artifacts <directory>',
Expand All @@ -207,14 +207,7 @@ function createCli(
'--package <directory>',
'Owning package directory, relative to the repository root',
)
.option(
'--path <path>',
'Skill path for add, or repository-relative custom directory for adopt',
)
.option(
'--apply <file>',
'Apply reviewed adoption choices from a JSON plan',
)
.option('--path <path>', 'Skill path relative to the owning package')
.option('--domain <slug>', 'Domain for a new skill')
.option(
'--distribution <mode>',
Expand Down Expand Up @@ -247,15 +240,12 @@ function createCli(
'--interactive',
'Inspect and record maintainer review outcomes in a terminal',
)
.option('--json', 'Output an adoption plan, status, or review as JSON')
.option('--json', 'Output status or review as JSON')
.option(
'--record <file>',
'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/**"',
)
Expand Down
133 changes: 44 additions & 89 deletions packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { dirname, relative } from 'node:path'
import { isCI } from 'std-env'
import { resolveProjectContext } from '../core/project-context.js'
import { fail } from '../shared/cli-error.js'
Expand All @@ -8,9 +7,9 @@ import {
resolveMaintainerProject,
setupRecords,
} from '../maintainer/project.js'
import { addSkill } from '../maintainer/add.js'
import { addSkill, planAddSkills } from '../maintainer/add.js'
import { findExistingSkills } from '../maintainer/existing.js'
import { retireSkill } from '../maintainer/remove.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'
Expand All @@ -28,13 +27,11 @@ 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'
import type { ReviewPrompts } from '../review/interactive.js'

export interface MaintainerCommandRuntime {
isTTY?: boolean
isCI?: boolean
adoptionPrompts?: AdoptionPrompts
reviewPrompts?: ReviewPrompts
}

Expand All @@ -55,11 +52,6 @@ const optionHelp: Record<string, [flag: string, description: string]> = {
'Owning package directory, relative to the repository root (default: the package that owns the current directory)',
],
path: ['--path <path>', 'Skill path relative to the owning package'],
adoptPath: [
'--path <directory>',
'Repository-relative custom skill directory to scan',
],
apply: ['--apply <file>', 'Apply reviewed adoption choices from a JSON plan'],
domain: ['--domain <slug>', 'Task domain for the skill'],
distribution: [
'--distribution <mode>',
Expand Down Expand Up @@ -89,9 +81,10 @@ const optionHelp: Record<string, [flag: string, description: string]> = {
export const maintainerActions: Record<string, MaintainerAction> = {
setup: {
usage: 'maintainer setup [--distribution repo|none] [options]',
summary: 'Initialize planning records, agent instructions, and CI.',
summary:
'Initialize planning records, register existing skills, install agent instructions and CI.',
writes:
'skill_tree.yaml, domain_map.yaml, skill_spec.md, the intent-maintainer block in AGENTS.md (or the existing agent instruction file), and .github/workflows/check-skills.yml when it does not exist.',
'skill_tree.yaml, domain_map.yaml, skill_spec.md (registering any SKILL.md under skills/ that is not yet recorded), the intent-maintainer block in AGENTS.md (or the existing agent instruction file), and .github/workflows/check-skills.yml when it does not exist.',
options: [
'artifacts',
'distribution',
Expand All @@ -100,16 +93,6 @@ export const maintainerActions: Record<string, MaintainerAction> = {
'skill',
].map((key) => optionHelp[key]!),
},
adopt: {
usage:
'maintainer adopt [--json | --apply <plan.json>] [--path <directory>]',
summary: 'Register existing skills from a reviewed plan.',
writes:
'The three planning records and the agent instruction block. Skill contents stay as authored.',
options: ['artifacts', 'json', 'adoptPath', 'apply'].map(
(key) => optionHelp[key]!,
),
},
add: {
usage:
'maintainer add <name> --domain <slug> [--description <text> --source <path>...] [options]',
Expand Down Expand Up @@ -212,7 +195,6 @@ export interface MaintainerCommandOptions extends DistributionOptions {
base?: string
json?: boolean
record?: string
apply?: string
interactive?: boolean
}

Expand All @@ -239,7 +221,6 @@ export async function runMaintainerCommand(
): Promise<void> {
const allowed: Record<string, Array<string>> = {
setup: ['artifacts', 'distribution', 'repository', 'pluginName', 'skill'],
adopt: ['artifacts', 'json', 'path', 'apply'],
add: [
'artifacts',
'package',
Expand All @@ -258,7 +239,7 @@ export async function runMaintainerCommand(
}
if (!allowed[action])
fail(
`Unknown maintainer action: ${action}. Expected setup, adopt, add, remove, status, sync, review, or check.`,
`Unknown maintainer action: ${action}. Expected setup, add, remove, status, sync, review, or check.`,
)
if (name !== undefined && action !== 'add' && action !== 'remove')
fail(`maintainer ${action} does not take a skill name.`)
Expand Down Expand Up @@ -290,71 +271,38 @@ 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 <plan.json> 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).replaceAll('\\', '/'),
)
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', 'remove', 'sync'].includes(action)) {
await withMaintainerLock(project.root, () => {
if (action === 'setup') {
const created = setupRecords(project)
configureDistribution(project, options)
const existing = findExistingSkills(project)
const candidates = existing.filter((skill) => !skill.problems.length)
const registered = planAddSkills(
project,
candidates.map((skill) => ({
name: skill.name,
options: {
package: skill.package || undefined,
path: skill.path,
domain: skill.domain,
},
})),
[],
(index, error) => {
const candidate = candidates[index]!
// A blank domain only comes from a domain_map.yaml entry, and
// setup has no --domain flag to point the maintainer at.
candidate.problems.push(
candidate.domain.trim()
? error instanceof Error
? error.message
: String(error)
: `Set a non-empty domain for ${candidate.name} in ${project.artifacts}/domain_map.yaml, then run intent maintainer setup again.`,
)
},
Comment thread
LadyBluenotes marked this conversation as resolved.
)
writeChanges(project.root, registered.changes)
runSetupGithubActions(project.root, getMetaDir())
writeIntentSkillsBlock({
...buildMaintainerGuidanceBlock(
Expand All @@ -370,9 +318,16 @@ export async function runMaintainerCommand(
console.log(
'Next: intent maintainer add <name> --domain <domain> --description <activation> --source <path>. Use --package <directory> 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.',
)
for (const skill of existing)
console.log(
skill.problems.length
? `Skipped ${skill.id}: ${skill.problems.join(' ')}`
: `Registered ${skill.id} (domain ${skill.domain}).`,
)
if (existing.some((skill) => skill.domain === 'uncategorized'))
console.log(
`Set a domain for uncategorized skills in ${project.artifacts}/skill_tree.yaml and domain_map.yaml.`,
)
const distribution = readDistribution(
readRecord(project, 'skill_tree.yaml'),
)
Expand Down
Loading
Loading