diff --git a/.changeset/selected-skill-distribution.md b/.changeset/selected-skill-distribution.md new file mode 100644 index 0000000..7750b49 --- /dev/null +++ b/.changeset/selected-skill-distribution.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add explicit repository skill distribution to the maintainer command workflow. Keep skills in their owning packages, record the selection or opt-out in `skill_tree.yaml`, and generate Claude/Cursor plugin metadata and consumer install commands for `npx skills add` and `gh skill add`. Preserve unrelated plugin fields, require explicit prerequisite selection, and check generated files for drift without publishing or installing anything on the maintainer's behalf. diff --git a/packages/intent/meta/generate-skill/references/distribution.md b/packages/intent/meta/generate-skill/references/distribution.md new file mode 100644 index 0000000..579c502 --- /dev/null +++ b/packages/intent/meta/generate-skill/references/distribution.md @@ -0,0 +1,42 @@ +# Offer skills from the repository + +Read this when setting up maintainer workflow or preparing skills for consumers who use GitHub skill installers or plugins. Keep the authoritative skills beside their owning package's code. Repository distribution generates metadata pointing to those directories; it does not copy the skill text into a second tree. + +## Record the maintainer's choice + +`intent maintainer setup` explains repository distribution until a choice is saved. For selected repository skills, run: + +```sh +intent maintainer setup --distribution repo --skill discover-library --skill query +``` + +Use the actual registered skill names. The repository is read from package metadata; supply `--repository owner/repo` when it cannot be established. `--plugin-name` chooses an initial plugin name when the default is unsuitable. To keep only the existing package distribution workflow, run: + +```sh +intent maintainer setup --distribution none +``` + +The choice lives under `distribution` in `skill_tree.yaml`. Repeated setup preserves it. Adding a skill never adds it to the public selection. If an exported skill requires another local skill, include that prerequisite explicitly; the generator does not silently expand the selection. Preserve prior decisions in the spec. The domain map continues to describe tasks and knowledge, not installer configuration. + +## Generate and check + +After authoring, run `intent maintainer sync`. It updates `skills` paths in `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json`, and the matching root-source entry in each `marketplace.json`. It preserves unrelated plugin fields and other marketplace entries, and rejects conflicting plugin identities or source roots. It also writes `.intent/skill-distribution.json` with the selected source paths and install arguments, and prints copyable consumer commands. Commit the generated metadata alongside the tree and skills through the repository's normal review process. + +`intent maintainer status` reports stale generated files; `--json` also includes the saved distribution choice and consumer commands. `intent maintainer check` requires a recorded choice and synchronized exports alongside the existing authoring and source-review checks. Rerun source review after synchronization so the report covers the final files. Opting out after generating exports clears Intent's selected paths and its marketplace entry on the next sync, retaining unrelated plugin features. It does not revoke already installed copies or make public GitHub files private. + +## Explain the consumer options + +- **Before package installation:** a discovery skill can explain the developer tasks a library supports and help decide whether it fits the project. Respect the chosen stack and existing dependencies. Make any package installation a deliberate project change, then hand off API implementation to the installed version's skills and source. Avoid embedding a second set of version-sensitive API instructions in the discovery skill. +- **Installed package guidance:** consumers can keep using Intent's `list`, `install`, and `load` workflow for skills shipped with the package version they installed. +- **GitHub skill installers:** use the generated `npx skills add owner/repo --skill ` or `gh skill add owner/repo ` commands. Their default installation scope is the project. User scope is a separate consumer choice (`--global` for skills, `--scope user` for gh). Repository source location and installation scope are separate concepts. +- **Plugins:** use the generated marketplace metadata with Claude Code or Cursor's native plugin installation. The plugin references the selected package directories; it does not require Intent to invoke those skills. + +The generated selection controls Intent's exported metadata and suggested install commands. Third-party installers retain their own discovery rules; a full repository scan or explicit `--all` can expose other public skills. Use the named selection or exact paths when distributing a curated subset. Check references from the installed skill directory and test the actual consumer task separately: copying a skill does not prove that all its links or recommendations are portable. + +Publish through the library's normal npm/GitHub release process. `gh skill publish --dry-run` can validate a GitHub skill release without publishing it; actual releases remain a maintainer action. skills.sh indexes public GitHub skills through real installation usage; there is no submission API used by Intent. Do not simulate installations to create a listing. + +## Verify installer compatibility + +The repository's optional `tests/integration/distribution-installers.test.ts` exercises generated metadata with real CLIs. Build Intent, then set `INTENT_GH_SKILL_BIN` and `INTENT_SKILLS_BIN` to installed executables and run that test. It installs only a selected nested package skill into temporary consumer projects, checks its bundled reference, and runs `gh skill publish --dry-run`. Telemetry is disabled. It does not install user-level skills, publish a release, or prove native plugin activation or agent task quality. + +Primary format references: [skills CLI](https://github.com/vercel-labs/skills), [GitHub skill install](https://cli.github.com/manual/gh_skill_install), [Claude plugin paths](https://code.claude.com/docs/en/plugins-reference#path-behavior-rules), and [Cursor plugins](https://cursor.com/docs/reference/plugins). diff --git a/packages/intent/meta/generate-skill/references/maintainer-commands.md b/packages/intent/meta/generate-skill/references/maintainer-commands.md index 0fe148b..d07418a 100644 --- a/packages/intent/meta/generate-skill/references/maintainer-commands.md +++ b/packages/intent/meta/generate-skill/references/maintainer-commands.md @@ -2,7 +2,7 @@ Use the repository's Intent command for all six actions. These commands perform bookkeeping; the maintainer or coding agent still supplies task knowledge, source-backed guidance, and review conclusions. -1. Run `intent maintainer setup` once. It installs repository guidance and creates missing planning records, preserving existing documents. A monorepo uses one shared record and package-owned skill directories. If several record locations exist, select the established one with `--artifacts `; do not merge them by guessing. +1. Run `intent maintainer setup` once. It installs repository guidance and creates missing planning records, preserving existing documents. A monorepo uses one shared record and package-owned skill directories. If several record locations exist, select the established one with `--artifacts `; do not merge them by guessing. Read [repository distribution](distribution.md) and explain the option to the maintainer during setup. Save the selected skills or opt-out with the setup command; do not infer a public selection from directory placement or repeat a recorded decision. 2. For a new task, run `intent maintainer add --domain --description --source `. In a monorepo, pass `--package packages/`. Repeat `--source` or `--requires` for multiple entries. Source paths are relative to the owning package; `owner/repo:path` is relative to the repository. Use `--path /SKILL.md` for an established custom layout. To register an existing skill, supply its name, domain, package, and path; its frontmatter supplies the other fields. 3. Author the skill and reconcile all three records using the procedures in this skill. The command creates a skeleton and a domain-map entry with unassessed task coverage. Write that coverage and the spec's decisions/history. Remove `` only after authoring the corresponding document. Do not remove it simply to make a check pass. 4. Run `intent maintainer status` to see missing work, stale metadata, and pending reviews. `--json` includes the full source-review report. For a supplied PR base, use `--base `. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 94f2488..803d425 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -202,6 +202,19 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { ) .option('--path ', 'SKILL.md path, relative to the owning package') .option('--domain ', 'Domain for a new skill') + .option( + '--distribution ', + 'Repository distribution: repo for selected public skills, none to opt out', + ) + .option( + '--repository ', + 'GitHub repository for skill distribution', + ) + .option('--plugin-name ', 'Name for the generated skill plugin') + .option( + '--skill ', + 'Skill to distribute from the repository; repeat to select more', + ) .option('--description ', 'Activation description for a new skill') .option( '--source ', diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index f01f701..530a93b 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -8,6 +8,11 @@ import { addSkill } from '../maintainer/add.js' import { planMaintainerSync } from '../maintainer/sync.js' import { withMaintainerLock, writeChanges } from '../maintainer/files.js' import { createReview } from '../review/review.js' +import { + configureDistribution, + distributionChoice, + readDistribution, +} from '../maintainer/distribution.js' import { detectIntentCommandPackageManager } from '../shared/command-runner.js' import { buildMaintainerGuidanceBlock, @@ -15,8 +20,9 @@ import { } from './install/guidance.js' import { runReviewCommand } from './review.js' import { runValidateCommand } from './validate.js' +import type { DistributionOptions } from '../maintainer/distribution.js' -export interface MaintainerCommandOptions { +export interface MaintainerCommandOptions extends DistributionOptions { artifacts?: string package?: string path?: string @@ -35,7 +41,7 @@ export async function runMaintainerCommand( options: MaintainerCommandOptions, ): Promise { const allowed: Record> = { - setup: ['artifacts'], + setup: ['artifacts', 'distribution', 'repository', 'pluginName', 'skill'], add: [ 'artifacts', 'package', @@ -69,6 +75,7 @@ export async function runMaintainerCommand( await withMaintainerLock(project.root, () => { if (action === 'setup') { const created = setupRecords(project) + configureDistribution(project, options) writeIntentSkillsBlock({ ...buildMaintainerGuidanceBlock( detectIntentCommandPackageManager(project.root), @@ -83,6 +90,11 @@ 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.', ) + const distribution = readDistribution(project) + if (!distribution) console.log(distributionChoice) + console.log( + `Repository distribution: ${distribution?.mode ?? 'unconfigured'}. Run maintainer sync after authoring to update export metadata.`, + ) } else if (action === 'add') { console.log(`Registered ${addSkill(project, name, options)}.`) console.log( @@ -94,6 +106,7 @@ export async function runMaintainerCommand( console.log(`Synchronized ${plan.changes.length} file(s).`) for (const problem of plan.problems) console.log(`Remaining: ${problem}`) + for (const command of plan.distribution.commands) console.log(command) } }) return @@ -109,6 +122,10 @@ export async function runMaintainerCommand( relative(project.root, change.path), ), problems: plan.problems, + distribution: { + mode: plan.distribution.mode, + commands: plan.distribution.commands, + }, review, } if (options.json) console.log(JSON.stringify(status, null, 2)) diff --git a/packages/intent/src/maintainer/distribution.ts b/packages/intent/src/maintainer/distribution.ts new file mode 100644 index 0000000..2d36a0d --- /dev/null +++ b/packages/intent/src/maintainer/distribution.ts @@ -0,0 +1,368 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, relative } from 'node:path' +import { applyEdits, modify } from 'jsonc-parser' +import { parseFrontmatter } from '../shared/utils.js' +import { stringList } from './add.js' +import { + authoringMarker, + isObject, + projectPath, + readRecord, + skillEntries, + skillPath, +} from './project.js' +import { writeChanges } from './files.js' +import type { MaintainerProject } from './project.js' +import type { FileChange } from './files.js' + +export interface DistributionOptions { + distribution?: string + repository?: string + pluginName?: string + skill?: string | Array +} + +interface Distribution { + mode: 'repo' | 'none' + repository?: string + name?: string + skills?: Array +} + +const repositoryPattern = + /^[a-zA-Z0-9][a-zA-Z0-9-]*\/[a-zA-Z0-9][a-zA-Z0-9_.-]*$/ +const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +export function readDistribution( + project: MaintainerProject, +): Distribution | undefined { + const value: unknown = readRecord(project, 'skill_tree.yaml').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.') + if (value.mode === 'repo' || value.name !== undefined) { + if ( + typeof value.repository !== 'string' || + !repositoryPattern.test(value.repository) || + typeof value.name !== 'string' || + !namePattern.test(value.name) + ) + throw new Error( + 'Repository distribution requires an owner/repo repository and a kebab-case plugin name.', + ) + if (!stringList(value.skills, 'distribution.skills').length) + throw new Error('Choose at least one distribution skill.') + } + return value as unknown as Distribution +} + +export const distributionChoice = + 'Choose repository skill distribution with maintainer setup --distribution repo --skill , or record an opt-out with --distribution none.' + +function readJson(path: string): Record { + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!isObject(value)) throw new Error(`Expected a JSON object: ${path}`) + return value +} + +export function configureDistribution( + project: MaintainerProject, + options: DistributionOptions, +): void { + if (!options.distribution) { + if (options.repository || options.pluginName || options.skill) + throw new Error( + 'Use --distribution repo when choosing repository skills.', + ) + return + } + if (!['repo', 'none'].includes(options.distribution)) + throw new Error('--distribution must be repo or none.') + const previous = readDistribution(project) + let distribution: Distribution + if (options.distribution === 'none') { + if (options.repository || options.pluginName || options.skill) + throw new Error( + '--distribution none does not take repository, plugin-name, or skill options.', + ) + 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$/, '') + : '') + if (!repositoryPattern.test(repository)) + throw new Error( + 'Choose a GitHub repository with --repository .', + ) + const existingManifest = projectPath( + project.root, + '.claude-plugin/plugin.json', + ) + const existingName = existsSync(existingManifest) + ? readJson(existingManifest).name + : undefined + const name = + options.pluginName ?? + previous?.name ?? + (typeof existingName === 'string' + ? existingName + : repository + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-$/, '')) + if (!namePattern.test(name)) + throw new Error('Choose a kebab-case name with --plugin-name .') + if (previous?.name && previous.name !== name) + throw new Error( + `Keep the existing plugin name ${previous.name}; renaming a published plugin requires a separate migration.`, + ) + const skills = options.skill + ? stringList([options.skill].flat(), '--skill') + : (previous?.skills ?? []) + if (!skills.length) + throw new Error( + 'Select public skills explicitly with --skill (repeat for multiple skills).', + ) + const entries = skillEntries(project) + for (const selected of skills) { + const entry = entries.find( + (skill) => (skill.slug ?? skill.name) === selected, + ) + if (!entry || ['planned', 'retired'].includes(String(entry.status))) + throw new Error( + `Distribution skill is not an implemented tree entry: ${selected}`, + ) + if (!existsSync(skillPath(project, entry))) + throw new Error(`Distribution skill is missing: ${selected}`) + } + distribution = { mode: 'repo', repository, name, skills } + } + const tree = readRecord(project, 'skill_tree.yaml') + 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() }, + ]) +} + +function jsonChange( + project: MaintainerProject, + path: string, + update: (current: Record) => Record, +): FileChange | undefined { + const absolute = projectPath(project.root, path) + const source = existsSync(absolute) ? readFileSync(absolute, 'utf8') : null + const current = source === null ? {} : readJson(absolute) + const fields = update(current) + let content = source ?? '{}\n' + const indent = content.match(/\n([\t ]+)"/)?.[1] ?? ' ' + const options = { + formattingOptions: { + insertSpaces: !indent.includes('\t'), + tabSize: indent.length, + eol: content.includes('\r\n') ? '\r\n' : '\n', + }, + } + for (const [field, value] of Object.entries(fields)) { + if (JSON.stringify(current[field]) !== JSON.stringify(value)) + content = applyEdits(content, modify(content, [field], value, options)) + } + return content === source ? undefined : { path: absolute, source, content } +} + +function shellCommand(args: Array): string { + return args + .map((arg) => + /^[a-zA-Z0-9_./@#-]+$/.test(arg) + ? arg + : `'${arg.replaceAll("'", "'\\''")}'`, + ) + .join(' ') +} + +export function planDistribution(project: MaintainerProject) { + const config = readDistribution(project) + const changes: Array = [] + const problems: Array = [] + const commands: Array = [] + if (!config) + return { + changes, + problems: [distributionChoice], + commands, + mode: 'unconfigured', + } + if (config.mode === 'none' && !config.name) + return { changes, problems, commands, mode: config.mode } + const { name, repository } = config + const entries = skillEntries(project) + const selected = config.mode === 'repo' ? config.skills! : [] + const exported: Array<{ name: string; path: string }> = [] + for (const selectedName of selected) { + const entry = entries.find( + (skill) => (skill.slug ?? skill.name) === selectedName, + ) + if (!entry || ['planned', 'retired'].includes(String(entry.status))) + throw new Error(`Distribution skill is not implemented: ${selectedName}`) + const path = skillPath(project, entry) + const fm = parseFrontmatter(path) + if ( + !isObject(fm) || + fm.name !== selectedName || + !namePattern.test(selectedName) + ) + throw new Error(`Invalid distribution skill identity: ${selectedName}`) + if (readFileSync(path, 'utf8').includes(authoringMarker)) + throw new Error( + `Finish authoring ${selectedName} before generating distribution files.`, + ) + for (const dependency of stringList( + fm.requires ?? [], + `${selectedName} requires`, + )) { + if ( + entries.some((skill) => (skill.slug ?? skill.name) === dependency) && + !selected.includes(dependency) + ) + throw new Error( + `Select prerequisite ${dependency} explicitly alongside ${selectedName}.`, + ) + } + exported.push({ + name: selectedName, + path: relative(project.root, path).replaceAll('\\', '/'), + }) + } + const paths = exported.map( + (skill) => `./${dirname(skill.path).replaceAll('\\', '/')}`, + ) + for (const directory of ['.claude-plugin', '.cursor-plugin']) { + const pluginPath = `${directory}/plugin.json` + if ( + config.mode === 'repo' || + existsSync(projectPath(project.root, pluginPath)) + ) { + const change = jsonChange(project, pluginPath, (current) => { + if (current.name !== undefined && current.name !== name) + throw new Error( + `${pluginPath} belongs to plugin ${String(current.name)}, not ${name}.`, + ) + return { name, skills: paths } + }) + if (change) changes.push(change) + } + const marketplacePath = `${directory}/marketplace.json` + if ( + config.mode === 'repo' || + existsSync(projectPath(project.root, marketplacePath)) + ) { + const change = jsonChange(project, marketplacePath, (current) => { + if ( + current.metadata !== undefined && + (!isObject(current.metadata) || + (current.metadata.pluginRoot !== undefined && + !['', '.', './'].includes(String(current.metadata.pluginRoot)))) + ) + throw new Error( + `${marketplacePath} metadata.pluginRoot must use the repository root for selected skill distribution.`, + ) + if ( + current.name !== undefined && + (typeof current.name !== 'string' || !namePattern.test(current.name)) + ) + throw new Error(`Invalid marketplace name in ${marketplacePath}.`) + if ( + current.owner !== undefined && + (!isObject(current.owner) || + typeof current.owner.name !== 'string' || + !current.owner.name.trim()) + ) + throw new Error(`Invalid marketplace owner in ${marketplacePath}.`) + if ( + current.plugins !== undefined && + (!Array.isArray(current.plugins) || + current.plugins.some((plugin) => !isObject(plugin))) + ) + throw new Error(`Invalid plugins array in ${marketplacePath}.`) + const plugins = (current.plugins ?? []) as Array< + Record + > + const existing = plugins.filter((plugin) => plugin.name === name) + if (existing.length > 1) + throw new Error(`Duplicate plugin ${name} in ${marketplacePath}.`) + if ( + existing[0]?.source !== undefined && + !['.', './'].includes(String(existing[0].source)) + ) + throw new Error( + `Plugin ${name} in ${marketplacePath} uses a different source.`, + ) + const next = plugins.filter((plugin) => plugin.name !== name) + if (config.mode === 'repo') + next.push({ ...existing[0], name, source: './', skills: paths }) + return { + name: current.name ?? name, + owner: current.owner ?? { name: repository!.split('/')[0] }, + plugins: next, + } + }) + if (change) changes.push(change) + } + } + const install = + config.mode === 'repo' + ? { + skills: ['npx', 'skills', 'add', repository!, '--skill', ...selected], + github: exported.map((skill) => [ + 'gh', + 'skill', + 'add', + repository!, + skill.path, + ]), + } + : { skills: [], github: [] } + const inventory = jsonChange( + project, + '.intent/skill-distribution.json', + () => ({ + schemaVersion: 1, + mode: config.mode, + repository, + skills: exported, + install, + }), + ) + if (inventory) changes.push(inventory) + if (config.mode === 'repo') { + commands.push( + shellCommand(install.skills), + ...install.github.map(shellCommand), + ) + const marketplace = projectPath( + project.root, + '.claude-plugin/marketplace.json', + ) + const marketplaceName = existsSync(marketplace) + ? (readJson(marketplace).name ?? name) + : name + commands.push( + `/plugin marketplace add ${repository}`, + `/plugin install ${name}@${String(marketplaceName)}`, + ) + } + return { changes, problems, commands, mode: config.mode } +} diff --git a/packages/intent/src/maintainer/sync.ts b/packages/intent/src/maintainer/sync.ts index 3c76720..d9a65b7 100644 --- a/packages/intent/src/maintainer/sync.ts +++ b/packages/intent/src/maintainer/sync.ts @@ -4,6 +4,7 @@ import { applyEdits, modify, parse } from 'jsonc-parser' import { resolveProjectContext } from '../core/project-context.js' import { parseFrontmatter } from '../shared/utils.js' import { stringList } from './add.js' +import { planDistribution } from './distribution.js' import { authoringMarker, isObject, @@ -202,5 +203,8 @@ export function planMaintainerSync(project: MaintainerProject) { const spec = readFileSync(recordPath(project, 'skill_spec.md'), 'utf8') if (!spec.trim() || spec.includes(authoringMarker)) problems.push('skill_spec.md still needs authored coverage and decisions.') - return { changes, problems, skills } + const distribution = planDistribution(project) + changes.push(...distribution.changes) + problems.push(...distribution.problems) + return { changes, problems, skills, distribution } } diff --git a/packages/intent/tests/distribution.test.ts b/packages/intent/tests/distribution.test.ts new file mode 100644 index 0000000..1b349c8 --- /dev/null +++ b/packages/intent/tests/distribution.test.ts @@ -0,0 +1,305 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { parse, stringify } from 'yaml' +import { main } from '../src/cli.js' + +let root: string +let previousCwd: string +function write(path: string, content: string) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), content) +} +function read(path: string) { + return readFileSync(join(root, path), 'utf8') +} +function readJson(path: string) { + return JSON.parse(read(path)) +} + +beforeEach(async () => { + previousCwd = process.cwd() + root = mkdtempSync(join(tmpdir(), 'intent-distribution-')) + process.chdir(root) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], { + cwd: root, + }) + write( + 'package.json', + '{"name":"library","repository":"https://github.com/acme/library"}\n', + ) + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + write( + 'packages/client/package.json', + '{"name":"@acme/client","files":["dist"]}\n', + ) + execFileSync('git', ['-c', 'core.fsmonitor=false', 'add', '.'], { cwd: root }) + execFileSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '-qm', + 'fixture', + ], + { cwd: root }, + ) + expect(await main(['maintainer', 'setup'])).toBe(0) + for (const name of ['query', 'internal']) { + write( + `packages/client/skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for the ${name} task.\nsources: [package.json]\n---\nRun the ${name} task.\n`, + ) + expect( + await main([ + 'maintainer', + 'add', + name, + '--package', + 'packages/client', + '--domain', + 'queries', + ]), + ).toBe(0) + } +}) +afterEach(() => { + process.chdir(previousCwd) + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('explains the missing choice, remembers an opt-out, and does not ask again', async () => { + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + '--distribution', + ) + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + expect(parse(read('_artifacts/skill_tree.yaml')).distribution).toEqual({ + mode: 'none', + }) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'setup'])).toBe(0) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).not.toContain( + 'Choose', + ) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(existsSync(join(root, '.claude-plugin'))).toBe(false) +}) + +it('generates selected package paths, preserves plugin fields, and leaves skill contents in place', async () => { + const original = read('packages/client/skills/query/SKILL.md') + write( + '.claude-plugin/plugin.json', + '{\n "name": "acme-library",\n "description": "Our existing plugin",\n "commands": ["./commands/custom.md"]\n}\n', + ) + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + ]), + ).toBe(0) + expect(await main(['maintainer', 'sync'])).toBe(0) + const plugin = readJson('.claude-plugin/plugin.json') + expect(plugin.skills).toEqual(['./packages/client/skills/query']) + expect(plugin.commands).toEqual(['./commands/custom.md']) + expect(plugin.description).toBe('Our existing plugin') + expect(readJson('.cursor-plugin/plugin.json').skills).toEqual(plugin.skills) + expect(readJson('.claude-plugin/marketplace.json').plugins[0].skills).toEqual( + plugin.skills, + ) + const instructions = readJson('.intent/skill-distribution.json') + expect(instructions.install.skills).toEqual([ + 'npx', + 'skills', + 'add', + 'acme/library', + '--skill', + 'query', + ]) + expect(instructions.install.github).toEqual([ + [ + 'gh', + 'skill', + 'add', + 'acme/library', + 'packages/client/skills/query/SKILL.md', + ], + ]) + expect(read('packages/client/skills/query/SKILL.md')).toBe(original) + expect(existsSync(join(root, 'skills/query/SKILL.md'))).toBe(false) + const files = [ + '.claude-plugin/plugin.json', + '.claude-plugin/marketplace.json', + '.cursor-plugin/plugin.json', + '.cursor-plugin/marketplace.json', + '.intent/skill-distribution.json', + '_artifacts/skill_tree.yaml', + ] + const before = files.map(read) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(files.map(read)).toEqual(before) + const tree = parse(read('_artifacts/skill_tree.yaml')) + tree.skills.push({ + name: 'future', + slug: 'future', + path: 'skills/future/SKILL.md', + status: 'planned', + }) + write('_artifacts/skill_tree.yaml', stringify(tree)) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(readJson('.claude-plugin/plugin.json').skills).toEqual(plugin.skills) + write( + 'packages/client/skills/more/SKILL.md', + '---\nname: more\ndescription: Another task\nsources: [package.json]\n---\nAnother task.\n', + ) + expect( + await main([ + 'maintainer', + 'add', + 'more', + '--package', + 'packages/client', + '--domain', + 'queries', + ]), + ).toBe(0) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(readJson('.claude-plugin/plugin.json').skills).toEqual(plugin.skills) +}, 30_000) + +it('reports stale exports and removes its selected paths on opt-out while retaining other plugin features', async () => { + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + ]), + ).toBe(0) + expect(await main(['maintainer', 'sync'])).toBe(0) + const plugin = readJson('.claude-plugin/plugin.json') + plugin.skills = ['./packages/client/skills/internal'] + plugin.commands = ['./commands/retained.md'] + write('.claude-plugin/plugin.json', JSON.stringify(plugin)) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'status', '--json'])).toBe(0) + const status = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(status.staleFiles).toContain('.claude-plugin/plugin.json') + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(readJson('.claude-plugin/plugin.json').skills).toEqual([]) + expect(readJson('.claude-plugin/plugin.json').commands).toEqual( + plugin.commands, + ) + expect(readJson('.claude-plugin/marketplace.json').plugins).toEqual([]) + expect(readJson('.intent/skill-distribution.json').install.skills).toEqual([]) + expect(read('packages/client/skills/query/SKILL.md')).toContain( + 'Run the query task.', + ) +}, 30_000) + +it('requires prerequisites to be selected and refuses foreign plugin ownership before any sync writes', async () => { + write( + 'packages/client/skills/query/SKILL.md', + '---\nname: query\ndescription: Query\nsources: [package.json]\nrequires: [internal]\n---\nRead internal before querying.\n', + ) + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + ]), + ).toBe(0) + const manifest = read('packages/client/package.json') + expect(await main(['maintainer', 'sync'])).toBe(1) + expect(read('packages/client/package.json')).toBe(manifest) + expect(existsSync(join(root, '.claude-plugin'))).toBe(false) + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + '--skill', + 'internal', + ]), + ).toBe(0) + write('.cursor-plugin/plugin.json', '{"name":"another-plugin","skills":[]}\n') + expect(await main(['maintainer', 'sync'])).toBe(1) + expect(read('packages/client/package.json')).toBe(manifest) + expect(existsSync(join(root, '.claude-plugin'))).toBe(false) +}) + +it.each(['.claude-plugin', '.cursor-plugin'])( + 'refuses a prefixed %s marketplace source before any sync writes', + async (directory) => { + expect( + await main([ + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + ]), + ).toBe(0) + const marketplace = { + name: 'acme-marketplace', + owner: { name: 'acme' }, + metadata: { pluginRoot: './plugins', description: 'Keep this metadata' }, + plugins: [{ name: 'other', source: './other' }], + } + const path = `${directory}/marketplace.json` + write(path, JSON.stringify(marketplace)) + const manifest = read('packages/client/package.json') + expect(await main(['maintainer', 'sync'])).toBe(1) + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + 'pluginRoot', + ) + expect(read('packages/client/package.json')).toBe(manifest) + expect(readJson(path)).toEqual(marketplace) + expect(existsSync(join(root, '.claude-plugin/plugin.json'))).toBe(false) + expect(existsSync(join(root, '.cursor-plugin/plugin.json'))).toBe(false) + + marketplace.metadata.pluginRoot = './' + write(path, JSON.stringify(marketplace)) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect(readJson(path).metadata).toEqual(marketplace.metadata) + expect(readJson(path).plugins).toEqual([ + ...marketplace.plugins, + { + name: 'acme-library', + source: './', + skills: ['./packages/client/skills/query'], + }, + ]) + }, + 30_000, +) diff --git a/packages/intent/tests/integration/distribution-installers.test.ts b/packages/intent/tests/integration/distribution-installers.test.ts new file mode 100644 index 0000000..4a141b2 --- /dev/null +++ b/packages/intent/tests/integration/distribution-installers.test.ts @@ -0,0 +1,158 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const gh = process.env.INTENT_GH_SKILL_BIN +const skills = process.env.INTENT_SKILLS_BIN +const cli = join(dirname(fileURLToPath(import.meta.url)), '../../dist/cli.mjs') + +it.skipIf(!gh || !skills)( + 'installs only the selected package skill with real external installers', + () => { + const root = mkdtempSync(join(tmpdir(), 'intent-distribution-installers-')) + const source = join(root, 'source') + function write(path: string, content: string) { + mkdirSync(dirname(join(source, path)), { recursive: true }) + writeFileSync(join(source, path), content) + } + function run(command: string, args: Array, cwd: string) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + DISABLE_TELEMETRY: '1', + DO_NOT_TRACK: '1', + GH_PROMPT_DISABLED: '1', + }, + }) + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) + return result.stdout + } + try { + write( + 'package.json', + '{"name":"library","repository":"https://github.com/acme/library"}\n', + ) + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + write('packages/client/package.json', '{"name":"@acme/client"}\n') + run('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], source) + run(process.execPath, [cli, 'maintainer', 'setup'], source) + for (const name of ['query', 'internal']) { + write( + `packages/client/skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for ${name} tasks.\nsources: [package.json]\n---\n\n# ${name}\n\nRead [the example](references/example.md) for the ${name} task.\n`, + ) + write( + `packages/client/skills/${name}/references/example.md`, + `${name} reference content\n`, + ) + run( + process.execPath, + [ + cli, + 'maintainer', + 'add', + name, + '--package', + 'packages/client', + '--domain', + 'queries', + ], + source, + ) + } + run( + process.execPath, + [ + cli, + 'maintainer', + 'setup', + '--distribution', + 'repo', + '--skill', + 'query', + ], + source, + ) + run(process.execPath, [cli, 'maintainer', 'sync'], source) + const before = readFileSync( + join(source, 'packages/client/skills/query/SKILL.md'), + 'utf8', + ) + const instructions = JSON.parse( + readFileSync(join(source, '.intent/skill-distribution.json'), 'utf8'), + ) + for (const installer of ['skills', 'gh'] as const) { + const consumer = join(root, installer) + mkdirSync(consumer) + run('git', ['-c', 'core.fsmonitor=false', 'init', '-q'], consumer) + if (installer === 'skills') { + run( + skills!, + [ + 'add', + source, + ...instructions.install.skills.slice(4), + '--agent', + 'codex', + '--copy', + '--yes', + ], + consumer, + ) + } else { + // gh's local mode selects by name; the generated exact path is for GitHub repositories. + run( + gh!, + [ + 'skill', + 'add', + source, + 'query', + '--from-local', + '--agent', + 'codex', + '--scope', + 'project', + ], + consumer, + ) + } + const installed = join(consumer, '.agents/skills') + expect(readdirSync(installed)).toEqual(['query']) + expect( + readFileSync(join(installed, 'query/SKILL.md'), 'utf8'), + ).toContain('references/example.md') + expect( + readFileSync(join(installed, 'query/references/example.md'), 'utf8'), + ).toBe('query reference content\n') + } + expect( + readFileSync( + join(source, 'packages/client/skills/query/SKILL.md'), + 'utf8', + ), + ).toBe(before) + execFileSync(gh!, ['skill', 'publish', source, '--dry-run'], { + cwd: source, + encoding: 'utf8', + timeout: 30_000, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 60_000, +) diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index 86a722f..f5da52d 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -57,7 +57,7 @@ beforeEach(() => { it('checks the authored workflow, rejects stale outcomes, and reopens after source edits', async () => { write('src/query.ts', 'export const query = () => 1\n') - expect(await main(['maintainer', 'setup'])).toBe(0) + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) expect( await main([ 'maintainer',