diff --git a/.changeset/curated-skills-only.md b/.changeset/curated-skills-only.md new file mode 100644 index 0000000..9bac1e9 --- /dev/null +++ b/.changeset/curated-skills-only.md @@ -0,0 +1,5 @@ +--- +'@stripe/link-cli': patch +--- + +Install only Link CLI's authored agent skills when running `link-cli skills add`. diff --git a/packages/cli/postinstall.mjs b/packages/cli/postinstall.mjs index 45ae2ef..31cca79 100644 --- a/packages/cli/postinstall.mjs +++ b/packages/cli/postinstall.mjs @@ -29,6 +29,7 @@ function run() { 'npx', ['--yes', 'skills', 'add', REPO, '-g', '-y'], { + env: { ...process.env, GH_HOST: 'github.com' }, stdio: 'inherit', timeout: 60_000, }, diff --git a/packages/cli/skill-bundle.ts b/packages/cli/skill-bundle.ts new file mode 100644 index 0000000..dcc4b7d --- /dev/null +++ b/packages/cli/skill-bundle.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const authoredSkillFiles = [ + 'create-payment-credential/SKILL.md', + 'financial-insights/SKILL.md', + 'link-cli/SKILL.md', +]; + +export function authoredSkillsDefine(packageDirectory: string): string { + const skillsDirectory = join(packageDirectory, '..', '..', 'skills'); + return JSON.stringify( + Object.fromEntries( + authoredSkillFiles.map((relativePath) => [ + relativePath, + readFileSync(join(skillsDirectory, relativePath), 'utf8'), + ]), + ), + ); +} diff --git a/packages/cli/src/__tests__/skills-install.test.ts b/packages/cli/src/__tests__/skills-install.test.ts new file mode 100644 index 0000000..bae7310 --- /dev/null +++ b/packages/cli/src/__tests__/skills-install.test.ts @@ -0,0 +1,178 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + installAuthoredSkills, + installAuthoredSkillsNatively, + isSkillsAddInvocation, + removeLegacyIncurSkills, +} from '../skills-install'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe('authored skill installation', () => { + it('recognizes Incur skill-add invocations only', () => { + expect(isSkillsAddInvocation(['skills', 'add'])).toBe(true); + expect(isSkillsAddInvocation(['skill', 'add', '--no-global'])).toBe(true); + expect( + isSkillsAddInvocation([ + '--format', + 'json', + 'skills', + 'add', + '--no-global', + ]), + ).toBe(true); + expect( + isSkillsAddInvocation(['skills', '--token-limit', '100', 'add']), + ).toBe(true); + expect(isSkillsAddInvocation(['skills', 'add', '--help'])).toBe(false); + expect(isSkillsAddInvocation(['skill', 'add', '-h'])).toBe(false); + expect(isSkillsAddInvocation(['skills', 'list'])).toBe(false); + expect(isSkillsAddInvocation(['auth', 'login'])).toBe(false); + expect(isSkillsAddInvocation(['--format', 'skills', 'add'])).toBe(false); + }); + + it('delegates global installation to the authored repository skills', () => { + const run = vi.fn(() => 0); + const cleanup = vi.fn(); + + expect(installAuthoredSkills([], run, cleanup)).toBe(0); + expect(run).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'add', 'stripe/link-cli', '-g', '-y'], + expect.objectContaining({ GH_HOST: 'github.com' }), + ); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('preserves project-local installation', () => { + const run = vi.fn(() => 0); + const cleanup = vi.fn(); + + expect(installAuthoredSkills(['--no-global'], run, cleanup)).toBe(0); + expect(run).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'add', 'stripe/link-cli', '-y'], + expect.objectContaining({ GH_HOST: 'github.com' }), + ); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('does not clean up legacy skills when installation fails', () => { + const cleanup = vi.fn(); + + expect(installAuthoredSkills([], () => 1, cleanup)).toBe(1); + expect(cleanup).not.toHaveBeenCalled(); + }); + + it('removes only Incur-recorded generated skills and metadata', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-skills-')); + temporaryDirectories.push(root); + const dataHome = path.join(root, 'data'); + const skillsDirectory = path.join(root, '.agents', 'skills'); + const generatedSkill = path.join(skillsDirectory, 'link-cli-auth'); + const agentSkill = path.join(root, '.claude', 'skills', 'link-cli-auth'); + const authoredSkill = path.join(skillsDirectory, 'link-cli'); + const metadataPath = path.join(dataHome, 'incur', 'link-cli.json'); + fs.mkdirSync(generatedSkill, { recursive: true }); + fs.mkdirSync(agentSkill, { recursive: true }); + fs.mkdirSync(authoredSkill, { recursive: true }); + fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); + fs.writeFileSync( + metadataPath, + JSON.stringify({ + hash: 'old-hash', + skills: ['link-cli-auth', 'link-cli'], + paths: [generatedSkill, agentSkill, authoredSkill], + }), + ); + + removeLegacyIncurSkills({ dataHome, homeDir: root }); + + expect(fs.existsSync(generatedSkill)).toBe(false); + expect(fs.existsSync(agentSkill)).toBe(false); + expect(fs.existsSync(authoredSkill)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(false); + }); + + it('installs embedded authored skills natively without generated skills', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-native-')); + temporaryDirectories.push(root); + const syncSkills = vi.fn(async (name, commands, options) => { + expect(name).toBe('link-cli-authored'); + expect(commands.size).toBe(0); + expect(options.global).toBe(false); + expect(options.cwd).toBe(root); + const includeRoot = options.include?.[0]?.replace(/\/\*$/, '') ?? ''; + expect( + fs.readFileSync( + path.join(root, includeRoot, 'link-cli', 'SKILL.md'), + 'utf8', + ), + ).toBe('link skill'); + return { agents: [], paths: [], skills: [] }; + }); + const removeMetadata = vi.fn(); + + await expect( + installAuthoredSkillsNatively(['skills', 'add', '--no-global'], { + authoredSkills: { + 'create-payment-credential/SKILL.md': 'payment skill', + 'financial-insights/SKILL.md': 'insights skill', + 'link-cli/SKILL.md': 'link skill', + }, + cwd: root, + removeMetadata, + syncSkills, + }), + ).resolves.toBe(0); + + expect(syncSkills).toHaveBeenCalledOnce(); + expect(removeMetadata).toHaveBeenCalledOnce(); + expect( + fs + .readdirSync(root) + .some((entry) => entry.startsWith('link-cli-skills-')), + ).toBe(false); + }); + + it('installs only embedded skills through Incur native sync', async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'link-cli-native-sync-'), + ); + temporaryDirectories.push(root); + const dataHome = path.join(root, 'data'); + vi.stubEnv('XDG_DATA_HOME', dataHome); + const skill = (name: string) => + `---\nname: ${name}\ndescription: Test skill.\n---\n`; + + await expect( + installAuthoredSkillsNatively(['skills', 'add', '--no-global'], { + authoredSkills: { + 'create-payment-credential/SKILL.md': skill( + 'create-payment-credential', + ), + 'financial-insights/SKILL.md': skill('financial-insights'), + 'link-cli/SKILL.md': skill('link-cli'), + }, + cwd: root, + }), + ).resolves.toBe(0); + + expect(fs.readdirSync(path.join(root, '.agents', 'skills')).sort()).toEqual( + ['create-payment-credential', 'financial-insights', 'link-cli'], + ); + expect(fs.existsSync(path.join(dataHome, 'incur', 'link-cli.json'))).toBe( + false, + ); + }); +}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ecdc577..1c5981a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -14,6 +14,11 @@ import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; +import { + installAuthoredSkills, + installAuthoredSkillsNatively, + isSkillsAddInvocation, +} from './skills-install'; import { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { @@ -24,6 +29,7 @@ import { declare const __CLI_VERSION__: string; declare const __CLI_NAME__: string; +declare const __CLI_STANDALONE__: boolean; const cliVersion = __CLI_VERSION__; const cliName = __CLI_NAME__; @@ -176,6 +182,14 @@ cli.command( ); cli.command(createServeCli(cli)); -cli.serve(); +const argv = process.argv.slice(2); +if (isSkillsAddInvocation(argv)) { + process.exitCode = + typeof __CLI_STANDALONE__ !== 'undefined' && __CLI_STANDALONE__ + ? await installAuthoredSkillsNatively(argv) + : installAuthoredSkills(argv); +} else { + await cli.serve(); +} export default cli; diff --git a/packages/cli/src/skills-install.ts b/packages/cli/src/skills-install.ts new file mode 100644 index 0000000..877fc59 --- /dev/null +++ b/packages/cli/src/skills-install.ts @@ -0,0 +1,229 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { SyncSkills } from 'incur'; + +declare const __AUTHORED_SKILLS__: Readonly>; + +const SKILLS_REPOSITORY = 'stripe/link-cli'; + +// Prior art: Shopify's ucp-cli uses the same pre-dispatch interception point to +// replace Incur's generated skill sync with curated behavior: +// https://github.com/Shopify/ucp-cli/blob/main/src/cli/skills-sync.ts +export function isSkillsAddInvocation(argv: readonly string[]): boolean { + const filtered = withoutIncurGlobalFlags(argv); + return ( + (filtered[0] === 'skills' || filtered[0] === 'skill') && + filtered[1] === 'add' && + !argv.includes('--help') && + !argv.includes('-h') + ); +} + +function withoutIncurGlobalFlags(argv: readonly string[]): string[] { + const filtered: string[] = []; + const flagsWithValues = new Set([ + '--filter-output', + '--format', + '--token-limit', + '--token-offset', + ]); + const flagsWithoutValues = new Set([ + '--full-output', + '--incur-update-check', + '--json', + '--llms', + '--llms-full', + '--mcp', + '--schema', + '--token-count', + '--update', + ]); + + for (let index = 0; index < argv.length; index++) { + const token = argv[index]; + if (token === undefined) continue; + if (flagsWithValues.has(token) && argv[index + 1] !== undefined) { + index++; + continue; + } + if (flagsWithoutValues.has(token)) continue; + if ( + token === '--version' && + (argv[index + 1] === undefined || argv[index + 1]?.startsWith('-')) + ) { + continue; + } + filtered.push(token); + } + return filtered; +} + +type InstallerRunner = ( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +) => number; +type LegacySkillsCleanup = () => void; + +export function installAuthoredSkills( + argv: readonly string[], + run: InstallerRunner = runInstaller, + cleanupLegacySkills: LegacySkillsCleanup = removeLegacyIncurSkills, +): number { + const args = ['--yes', 'skills', 'add', SKILLS_REPOSITORY]; + if (!argv.includes('--no-global')) args.push('-g'); + args.push('-y'); + const status = run('npx', args, { ...process.env, GH_HOST: 'github.com' }); + if (status === 0) cleanupLegacySkills(); + return status; +} + +/** Installs the embedded authored skills without requiring Node or npm. */ +export async function installAuthoredSkillsNatively( + argv: readonly string[], + options: { + authoredSkills?: Readonly>; + cwd?: string; + removeMetadata?: () => void; + syncSkills?: typeof SyncSkills.sync; + } = {}, +): Promise { + const global = !argv.includes('--no-global'); + const cwd = options.cwd ?? process.cwd(); + const stagingParent = global ? os.tmpdir() : cwd; + const stagingDirectory = fs.mkdtempSync( + path.join(stagingParent, 'link-cli-skills-'), + ); + const authoredSkills = options.authoredSkills ?? __AUTHORED_SKILLS__; + + try { + for (const [relativePath, content] of Object.entries(authoredSkills)) { + const target = path.resolve(stagingDirectory, relativePath); + if (!target.startsWith(`${stagingDirectory}${path.sep}`)) { + throw new Error(`Invalid embedded skill path: ${relativePath}`); + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + + const syncCwd = global ? stagingDirectory : cwd; + const includeRoot = global ? '.' : path.relative(cwd, stagingDirectory); + await (options.syncSkills ?? SyncSkills.sync)( + 'link-cli-authored', + new Map(), + { + cwd: syncCwd, + global, + include: [path.join(includeRoot, '*')], + }, + ); + (options.removeMetadata ?? finalizeNativeInstall)(); + return 0; + } catch (error) { + process.stderr.write( + `Failed to install Link CLI skills: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return 1; + } finally { + fs.rmSync(stagingDirectory, { force: true, recursive: true }); + } +} + +/** + * Removes skills generated by Incur's former `skills add` implementation and + * its staleness metadata. Authored skills such as `link-cli` are not matched. + */ +export function removeLegacyIncurSkills( + options: { + cwd?: string; + dataHome?: string; + homeDir?: string; + } = {}, +): void { + const homeDir = options.homeDir ?? os.homedir(); + const dataHome = + options.dataHome ?? + process.env.XDG_DATA_HOME ?? + path.join(homeDir, '.local', 'share'); + const metadataPath = incurMetadataPath(dataHome); + + let metadata: unknown; + try { + metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } catch { + return; + } + + if (!isRecord(metadata) || !Array.isArray(metadata.skills)) return; + const legacySkillNames = new Set( + metadata.skills.filter( + (skill): skill is string => + typeof skill === 'string' && skill.startsWith('link-cli-'), + ), + ); + if (legacySkillNames.size === 0) return; + + const recordedPaths = Array.isArray(metadata.paths) + ? metadata.paths.filter((skillPath): skillPath is string => + isRecordedLegacySkillPath(skillPath, legacySkillNames), + ) + : []; + const legacySkillPaths = + recordedPaths.length > 0 + ? recordedPaths + : [...legacySkillNames].flatMap((skill) => [ + path.join(homeDir, '.agents', 'skills', skill), + path.join(options.cwd ?? process.cwd(), '.agents', 'skills', skill), + ]); + + for (const skillPath of new Set(legacySkillPaths)) { + fs.rmSync(skillPath, { force: true, recursive: true }); + } + fs.rmSync(metadataPath, { force: true }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRecordedLegacySkillPath( + skillPath: string, + legacySkillNames: ReadonlySet, +): boolean { + const resolvedPath = path.resolve(skillPath); + return ( + path.isAbsolute(skillPath) && + legacySkillNames.has(path.basename(resolvedPath)) && + path.basename(path.dirname(resolvedPath)) === 'skills' + ); +} + +function incurMetadataPath(dataHome?: string, name = 'link-cli'): string { + const resolvedDataHome = + dataHome ?? + process.env.XDG_DATA_HOME ?? + path.join(os.homedir(), '.local', 'share'); + return path.join(resolvedDataHome, 'incur', `${name}.json`); +} + +function finalizeNativeInstall(): void { + removeLegacyIncurSkills(); + fs.rmSync(incurMetadataPath(undefined, 'link-cli-authored'), { force: true }); +} + +function runInstaller( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +): number { + const result = spawnSync(command, [...args], { env, stdio: 'inherit' }); + if (result.error) { + process.stderr.write( + `Failed to install Link CLI skills: ${result.error.message}\n`, + ); + return 1; + } + return result.status ?? 1; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 4a960c3..3ca8027 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; +import { authoredSkillsDefine } from './skill-bundle'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); @@ -17,6 +18,8 @@ export default defineConfig({ external: ['update-notifier'], banner: { js: '#!/usr/bin/env node' }, define: { + __AUTHORED_SKILLS__: authoredSkillsDefine(__dirname), + __CLI_STANDALONE__: 'false', __CLI_VERSION__: JSON.stringify(pkg.version), __CLI_NAME__: JSON.stringify(pkg.name), }, diff --git a/packages/cli/tsup.sea.config.ts b/packages/cli/tsup.sea.config.ts index 46c7c97..cb074af 100644 --- a/packages/cli/tsup.sea.config.ts +++ b/packages/cli/tsup.sea.config.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; +import { authoredSkillsDefine } from './skill-bundle'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); @@ -39,6 +40,8 @@ export default defineConfig({ ].join('\n'), }, define: { + __AUTHORED_SKILLS__: authoredSkillsDefine(__dirname), + __CLI_STANDALONE__: 'true', __CLI_VERSION__: JSON.stringify(pkg.version), __CLI_NAME__: JSON.stringify(pkg.name), },