From 365e8dae06fd5a7aef5e0518a9b092bd3dc198d1 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Tue, 8 Sep 2026 12:57:12 -0400 Subject: [PATCH 1/4] cli: install only authored skills - Intercept Incur skill installation and delegate to the canonical repository installer, following Shopify's ucp-cli interception pattern. - Preserve global and project-local installation while preventing generated command-reference skills. - Add regression coverage for aliases, help handling, and installer delegation. Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex --- .changeset/curated-skills-only.md | 5 +++ .../cli/src/__tests__/skills-install.test.ts | 43 +++++++++++++++++++ packages/cli/src/cli.tsx | 8 +++- packages/cli/src/skills-install.ts | 38 ++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .changeset/curated-skills-only.md create mode 100644 packages/cli/src/__tests__/skills-install.test.ts create mode 100644 packages/cli/src/skills-install.ts 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/src/__tests__/skills-install.test.ts b/packages/cli/src/__tests__/skills-install.test.ts new file mode 100644 index 0000000..5d7ce4a --- /dev/null +++ b/packages/cli/src/__tests__/skills-install.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + installAuthoredSkills, + isSkillsAddInvocation, +} from '../skills-install'; + +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(['skills', 'add', '--help'])).toBe(false); + expect(isSkillsAddInvocation(['skill', 'add', '-h'])).toBe(false); + expect(isSkillsAddInvocation(['skills', 'list'])).toBe(false); + expect(isSkillsAddInvocation(['auth', 'login'])).toBe(false); + }); + + it('delegates global installation to the authored repository skills', () => { + const run = vi.fn(() => 0); + + expect(installAuthoredSkills([], run)).toBe(0); + expect(run).toHaveBeenCalledWith('npx', [ + '--yes', + 'skills', + 'add', + 'stripe/link-cli', + '-g', + '-y', + ]); + }); + + it('preserves project-local installation', () => { + const run = vi.fn(() => 0); + + expect(installAuthoredSkills(['--no-global'], run)).toBe(0); + expect(run).toHaveBeenCalledWith('npx', [ + '--yes', + 'skills', + 'add', + 'stripe/link-cli', + '-y', + ]); + }); +}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ecdc577..f803513 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -15,6 +15,7 @@ import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; import { buildMcpCommand } from './utils/package-runner'; +import { installAuthoredSkills, isSkillsAddInvocation } from './skills-install'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider, @@ -176,6 +177,11 @@ cli.command( ); cli.command(createServeCli(cli)); -cli.serve(); +const argv = process.argv.slice(2); +if (isSkillsAddInvocation(argv)) { + process.exitCode = installAuthoredSkills(argv.slice(2)); +} 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..3711c01 --- /dev/null +++ b/packages/cli/src/skills-install.ts @@ -0,0 +1,38 @@ +import { spawnSync } from 'node:child_process'; + +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 { + return ( + (argv[0] === 'skills' || argv[0] === 'skill') && + argv[1] === 'add' && + !argv.includes('--help') && + !argv.includes('-h') + ); +} + +type InstallerRunner = (command: string, args: readonly string[]) => number; + +export function installAuthoredSkills( + argv: readonly string[], + run: InstallerRunner = runInstaller, +): number { + const args = ['--yes', 'skills', 'add', SKILLS_REPOSITORY]; + if (!argv.includes('--no-global')) args.push('-g'); + args.push('-y'); + return run('npx', args); +} + +function runInstaller(command: string, args: readonly string[]): number { + const result = spawnSync(command, [...args], { stdio: 'inherit' }); + if (result.error) { + process.stderr.write( + `Failed to install Link CLI skills: ${result.error.message}\n`, + ); + return 1; + } + return result.status ?? 1; +} From 9f20e4b8b275d808a90017ab8156e1a5079f407a Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Wed, 9 Sep 2026 12:39:50 -0400 Subject: [PATCH 2/4] cli: migrate legacy Incur skills - Remove only Incur-recorded link-cli generated skills after authored-skill installation succeeds. - Clear stale Incur metadata so unrelated commands stop advertising a recurring sync CTA. - Force the child skills installer to resolve the public GitHub repository under Stripe GH_HOST settings. - Cover successful migration, failed installation, and authored-skill preservation. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/postinstall.mjs | 1 + .../cli/src/__tests__/skills-install.test.ts | 81 ++++++++++++---- packages/cli/src/skills-install.ts | 93 ++++++++++++++++++- 3 files changed, 153 insertions(+), 22 deletions(-) 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/src/__tests__/skills-install.test.ts b/packages/cli/src/__tests__/skills-install.test.ts index 5d7ce4a..28dcf32 100644 --- a/packages/cli/src/__tests__/skills-install.test.ts +++ b/packages/cli/src/__tests__/skills-install.test.ts @@ -1,9 +1,21 @@ -import { describe, expect, it, vi } from 'vitest'; +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, isSkillsAddInvocation, + removeLegacyIncurSkills, } from '../skills-install'; +const temporaryDirectories: string[] = []; + +afterEach(() => { + 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); @@ -16,28 +28,61 @@ describe('authored skill installation', () => { it('delegates global installation to the authored repository skills', () => { const run = vi.fn(() => 0); + const cleanup = vi.fn(); - expect(installAuthoredSkills([], run)).toBe(0); - expect(run).toHaveBeenCalledWith('npx', [ - '--yes', - 'skills', - 'add', - 'stripe/link-cli', - '-g', - '-y', - ]); + 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 authoredSkill = path.join(skillsDirectory, 'link-cli'); + const metadataPath = path.join(dataHome, 'incur', 'link-cli.json'); + fs.mkdirSync(generatedSkill, { 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, authoredSkill], + }), + ); + + removeLegacyIncurSkills({ dataHome, homeDir: root }); - expect(installAuthoredSkills(['--no-global'], run)).toBe(0); - expect(run).toHaveBeenCalledWith('npx', [ - '--yes', - 'skills', - 'add', - 'stripe/link-cli', - '-y', - ]); + expect(fs.existsSync(generatedSkill)).toBe(false); + expect(fs.existsSync(authoredSkill)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(false); }); }); diff --git a/packages/cli/src/skills-install.ts b/packages/cli/src/skills-install.ts index 3711c01..9d30068 100644 --- a/packages/cli/src/skills-install.ts +++ b/packages/cli/src/skills-install.ts @@ -1,4 +1,7 @@ import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; const SKILLS_REPOSITORY = 'stripe/link-cli'; @@ -14,20 +17,102 @@ export function isSkillsAddInvocation(argv: readonly string[]): boolean { ); } -type InstallerRunner = (command: string, args: readonly string[]) => number; +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'); - return run('npx', args); + const status = run('npx', args, { ...process.env, GH_HOST: 'github.com' }); + if (status === 0) cleanupLegacySkills(); + return status; } -function runInstaller(command: string, args: readonly string[]): number { - const result = spawnSync(command, [...args], { stdio: 'inherit' }); +/** + * 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 = path.join(dataHome, 'incur', 'link-cli.json'); + + 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' && + path.basename(path.dirname(path.dirname(resolvedPath))) === '.agents' + ); +} + +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`, From 4ac7ebc8e21ad2e5bc45220b16d7914a66c41c4c Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Wed, 9 Sep 2026 14:03:14 -0400 Subject: [PATCH 3/4] cli: handle authored skill installation edge cases - Normalize Incur global flags before intercepting skills add so generated skills cannot bypass the authored installer. - Remove legacy skills from every agent-specific path recorded by Incur while preserving authored skill directories. - Embed authored skill content and install it natively from standalone executables without requiring Node or npm. - Add regression coverage for flagged invocations, noncanonical cleanup paths, and native authored-only sync. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/skill-bundle.ts | 20 +++ .../cli/src/__tests__/skills-install.test.ts | 92 +++++++++++++- packages/cli/src/cli.tsx | 12 +- packages/cli/src/skills-install.ts | 116 +++++++++++++++++- packages/cli/tsup.config.ts | 3 + packages/cli/tsup.sea.config.ts | 3 + 6 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 packages/cli/skill-bundle.ts 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 index 28dcf32..bae7310 100644 --- a/packages/cli/src/__tests__/skills-install.test.ts +++ b/packages/cli/src/__tests__/skills-install.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { installAuthoredSkills, + installAuthoredSkillsNatively, isSkillsAddInvocation, removeLegacyIncurSkills, } from '../skills-install'; @@ -11,6 +12,7 @@ import { const temporaryDirectories: string[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const directory of temporaryDirectories.splice(0)) { fs.rmSync(directory, { force: true, recursive: true }); } @@ -20,10 +22,23 @@ 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', () => { @@ -65,9 +80,11 @@ describe('authored skill installation', () => { 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( @@ -75,14 +92,87 @@ describe('authored skill installation', () => { JSON.stringify({ hash: 'old-hash', skills: ['link-cli-auth', 'link-cli'], - paths: [generatedSkill, authoredSkill], + 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 f803513..b89b72d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -15,7 +15,11 @@ import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; import { buildMcpCommand } from './utils/package-runner'; -import { installAuthoredSkills, isSkillsAddInvocation } from './skills-install'; +import { + installAuthoredSkills, + installAuthoredSkillsNatively, + isSkillsAddInvocation, +} from './skills-install'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider, @@ -25,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__; @@ -179,7 +184,10 @@ cli.command(createServeCli(cli)); const argv = process.argv.slice(2); if (isSkillsAddInvocation(argv)) { - process.exitCode = installAuthoredSkills(argv.slice(2)); + process.exitCode = + typeof __CLI_STANDALONE__ !== 'undefined' && __CLI_STANDALONE__ + ? await installAuthoredSkillsNatively(argv) + : installAuthoredSkills(argv); } else { await cli.serve(); } diff --git a/packages/cli/src/skills-install.ts b/packages/cli/src/skills-install.ts index 9d30068..877fc59 100644 --- a/packages/cli/src/skills-install.ts +++ b/packages/cli/src/skills-install.ts @@ -2,6 +2,9 @@ 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'; @@ -9,14 +12,54 @@ const SKILLS_REPOSITORY = 'stripe/link-cli'; // 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 ( - (argv[0] === 'skills' || argv[0] === 'skill') && - argv[1] === 'add' && + (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[], @@ -37,6 +80,57 @@ export function installAuthoredSkills( 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. @@ -53,7 +147,7 @@ export function removeLegacyIncurSkills( options.dataHome ?? process.env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share'); - const metadataPath = path.join(dataHome, 'incur', 'link-cli.json'); + const metadataPath = incurMetadataPath(dataHome); let metadata: unknown; try { @@ -102,11 +196,23 @@ function isRecordedLegacySkillPath( return ( path.isAbsolute(skillPath) && legacySkillNames.has(path.basename(resolvedPath)) && - path.basename(path.dirname(resolvedPath)) === 'skills' && - path.basename(path.dirname(path.dirname(resolvedPath))) === '.agents' + 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[], 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), }, From 93acaac9c60cb25b5af4cd9ed191212d1384e3e5 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Fri, 11 Sep 2026 11:52:52 -0400 Subject: [PATCH 4/4] style: resolve rebased import order - Preserve main's MCP package-runner import alongside the authored-skills imports. - Apply Biome's canonical ordering after resolving the rebase conflict. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/cli.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index b89b72d..1c5981a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -14,12 +14,12 @@ 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 { buildMcpCommand } from './utils/package-runner'; import { installAuthoredSkills, installAuthoredSkillsNatively, isSkillsAddInvocation, } from './skills-install'; +import { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider,