fix(cli): package Claude Desktop skills in one zip

This commit is contained in:
Andrey Avtomonov 2026-05-19 13:55:02 +02:00
parent b42f418adc
commit ddabe517e3
4 changed files with 130 additions and 107 deletions

View file

@ -82,7 +82,11 @@ describe('setup agents', () => {
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
{
kind: 'file',
path: join(tempDir, '.ktx/agents/claude/ktx-skills.zip'),
role: 'claude-desktop-skill-bundle',
},
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
@ -123,7 +127,11 @@ describe('setup agents', () => {
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
{
kind: 'file',
path: join(tempDir, '.ktx/agents/claude/ktx-skills.zip'),
role: 'claude-desktop-skill-bundle',
},
]);
});
@ -414,7 +422,7 @@ describe('setup agents', () => {
}
});
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
it('registers Claude Desktop MCP and ships an uploadable analytics skill zip', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
@ -444,9 +452,9 @@ describe('setup agents', () => {
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }],
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const skillBundlePath = join(tempDir, '.ktx/agents/claude/ktx-skills.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(skillBundlePath)).resolves.toBeDefined();
const launcherStat = await stat(launcherPath);
expect(launcherStat.mode & 0o111).not.toBe(0);
const launcher = await readFile(launcherPath, 'utf-8');
@ -462,23 +470,23 @@ describe('setup agents', () => {
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
});
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
const setupMd = await readZipText(pluginPath, 'SETUP.md');
expect(setupMd).not.toContain('ktx mcp start');
expect(setupMd).toContain('no manual plugin install step is required');
expect(setupMd).toContain('claude_desktop_config.json');
expect(setupMd).not.toContain('Install this plugin ZIP from Claude Desktop');
await expect(readZipText(pluginPath, 'skills/ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
expect(await readZipText(skillBundlePath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
await expect(readZipText(skillBundlePath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
await expect(readZipText(skillBundlePath, '.claude-plugin/plugin.json')).rejects.toThrow('Missing zip entry');
await expect(readZipText(skillBundlePath, 'skills/ktx-analytics/SKILL.md')).rejects.toThrow(
'Missing zip entry',
);
expect(io.stdout()).toContain('Claude Desktop');
expect(io.stdout()).not.toContain('.ktx/agents/claude/ktx-plugin.zip');
expect(io.stdout()).toContain(skillBundlePath);
expect(io.stdout()).toContain('claude_desktop_config.json');
expect(io.stdout()).toContain('Required before using agents');
expect(io.stdout()).toContain('1. Restart Claude Desktop');
expect(io.stdout()).toContain('Claude Desktop loads KTX after restart.');
expect(io.stdout()).not.toContain('install plugin');
expect(io.stdout()).toContain('Claude Desktop loads KTX MCP after restart.');
expect(io.stdout()).toContain('2. Upload Claude Desktop skills');
expect(io.stdout()).toContain('Customize > Skills > + > Create skill > Upload a skill');
expect(io.stdout()).toContain('Upload this file:');
expect(io.stdout()).toContain('Toggle the uploaded KTX skills on.');
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
} finally {
process.env.HOME = previousHome;
@ -535,7 +543,7 @@ describe('setup agents', () => {
}
});
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
it('includes an uploadable admin CLI skill zip for Claude Desktop when requested', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
@ -561,12 +569,13 @@ describe('setup agents', () => {
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
const skillBundlePath = join(tempDir, '.ktx/agents/claude/ktx-skills.zip');
expect(await readZipText(skillBundlePath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
const adminSkill = await readZipText(skillBundlePath, 'ktx/SKILL.md');
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
expect(adminSkill).toContain('status --json');
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
await expect(readZipText(skillBundlePath, '.mcp.json')).rejects.toThrow('Missing zip entry');
expect(io.stdout()).toContain(skillBundlePath);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
@ -798,7 +807,7 @@ describe('setup agents', () => {
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
});
it('removes generated Claude Desktop plugin from the manifest', async () => {
it('removes generated Claude Desktop skill zips from the manifest', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
@ -817,10 +826,10 @@ describe('setup agents', () => {
},
io.io,
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const skillBundlePath = join(tempDir, '.ktx/agents/claude/ktx-skills.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(skillBundlePath)).resolves.toBeDefined();
await expect(stat(launcherPath)).resolves.toBeDefined();
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
@ -829,7 +838,7 @@ describe('setup agents', () => {
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(stat(pluginPath)).rejects.toThrow();
await expect(stat(skillBundlePath)).rejects.toThrow();
await expect(stat(launcherPath)).rejects.toThrow();
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
@ -1026,9 +1035,11 @@ describe('setup agents', () => {
expect(output).toContain('RUN:');
expect(output).toContain(`cd '${tempDir}'`);
expect(output).toContain('3. Restart Claude Desktop');
expect(output).toContain('Claude Desktop loads KTX after restart.');
expect(output).not.toContain('install plugin');
expect(output).not.toContain(join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'));
expect(output).toContain('Claude Desktop loads KTX MCP after restart.');
expect(output).toContain('4. Upload Claude Desktop skills');
expect(output).toContain('Customize > Skills > + > Create skill > Upload a skill');
expect(output).toContain(join(tempDir, '.ktx/agents/claude/ktx-skills.zip'));
expect(output).toContain('Upload this file:');
expect(output).not.toContain('Finish Claude Desktop setup');
expect(output).not.toContain('Run `ktx mcp start` to enable the configured KTX MCP server.');
} finally {

View file

@ -51,7 +51,11 @@ export interface KtxAgentInstallManifest {
installedAt: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
entries: Array<
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
| {
kind: 'file';
path: string;
role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher';
}
| { kind: 'json-key'; path: string; jsonPath: string[] }
>;
}
@ -310,8 +314,8 @@ export function agentInstallManifestPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/install-manifest.json');
}
function claudeDesktopPluginPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
function claudeDesktopSkillBundlePath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-skills.zip');
}
function claudeDesktopLauncherPath(projectDir: string): string {
@ -357,7 +361,11 @@ export function plannedKtxAgentFiles(input: {
if (input.target === 'claude-desktop') {
return [
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
{
kind: 'file',
path: claudeDesktopSkillBundlePath(input.projectDir),
role: 'claude-desktop-skill-bundle' as const,
},
];
}
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
@ -487,43 +495,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
].join('\n');
}
function claudePluginJsonContent(): string {
return `${JSON.stringify(
{
name: 'ktx',
version: '0.0.0-local',
description: 'KTX analytics workflow guidance and local MCP tools.',
},
null,
2,
)}\n`;
}
function claudePluginVersionContent(): string {
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
}
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
return [
'# KTX Claude Plugin',
'',
'This package is generated by KTX setup. Claude Desktop loads KTX through the registered `claude_desktop_config.json` entry after restart; no manual plugin install step is required.',
'',
`KTX project: \`${input.projectDir}\``,
'',
'Included:',
'',
'- `ktx-analytics` skill for the MCP analytics workflow',
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
'',
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
'',
'If this checkout or project directory moves, rerun `ktx setup --agents` and restart Claude Desktop.',
'',
].join('\n');
}
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string {
const binPath = input.launcher.args[0];
if (!binPath) {
throw new Error('Expected KTX CLI launcher to include a bin path.');
@ -572,27 +544,23 @@ function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): strin
' run_with_node "$(command -v node)" "$@"',
'fi',
'',
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2',
'exit 127',
'',
].join('\n');
}
async function writeClaudeDesktopPlugin(input: {
async function writeClaudeDesktopSkillBundle(input: {
projectDir: string;
path: string;
mode: KtxAgentInstallMode;
launcher: KtxCliLauncher;
}): Promise<void> {
const withAdminCli = input.mode === 'mcp-cli';
const files: Record<string, Uint8Array> = {
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
'version.json': strToU8(claudePluginVersionContent()),
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
'ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
};
if (withAdminCli) {
files['skills/ktx/SKILL.md'] = strToU8(
if (input.mode === 'mcp-cli') {
files['ktx/SKILL.md'] = strToU8(
cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }),
);
}
@ -605,7 +573,7 @@ async function writeClaudeDesktopLauncher(input: {
launcher: KtxCliLauncher;
}): Promise<void> {
await mkdir(dirname(input.path), { recursive: true });
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8');
await chmod(input.path, 0o755);
}
@ -764,7 +732,6 @@ function guidanceInstallLine(target: KtxAgentTarget): string {
if (target === 'cursor') return 'Cursor rules installed';
if (target === 'opencode') return 'OpenCode commands installed';
if (target === 'universal') return '.agents guidance installed';
if (target === 'claude-desktop') return 'Claude Desktop skills bundled';
return 'Agent guidance installed';
}
@ -826,7 +793,10 @@ export function formatInstallSummary(
}
const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill');
const hasAdmin = hasAdminCliEntries(targetEntries);
const hasPlugin = hasEntryRole(targetEntries, 'claude-plugin');
const claudeDesktopSkillBundles = targetEntries.filter(
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle',
);
if (install.target === 'claude-code') {
if (hasAnalytics) {
lines.push(' Analytics skill installed');
@ -834,7 +804,14 @@ export function formatInstallSummary(
if (hasAdmin) {
lines.push(' Admin CLI skill installed');
}
} else if (hasAnalytics || hasAdmin || hasPlugin) {
} else if (install.target === 'claude-desktop') {
if (claudeDesktopSkillBundles.length > 0) {
lines.push(' Claude Desktop skill uploads');
for (const bundle of claudeDesktopSkillBundles) {
lines.push(` ${bundle.path}`);
}
}
} else if (hasAnalytics || hasAdmin) {
lines.push(` ${guidanceInstallLine(install.target)}`);
}
if (hasEntryRole(targetEntries, 'launcher')) {
@ -844,6 +821,20 @@ export function formatInstallSummary(
return lines.join('\n');
}
function claudeDesktopSkillBundlePathsForInstalls(
projectDir: string,
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>,
): string[] {
return installs
.filter((install) => install.target === 'claude-desktop')
.flatMap((install) => plannedKtxAgentFiles({ projectDir, ...install }))
.filter(
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle',
)
.map((entry) => entry.path);
}
function humanList(values: string[]): string {
if (values.length <= 2) {
return values.join(' and ');
@ -981,9 +972,22 @@ function formatAgentNextActions(input: {
if (input.installs.some((install) => install.target === 'claude-desktop')) {
lines.push(`${step}. Restart Claude Desktop`);
lines.push(' Claude Desktop loads KTX after restart.');
lines.push(' Claude Desktop loads KTX MCP after restart.');
pushBlankLine(lines);
step += 1;
const skillBundlePaths = claudeDesktopSkillBundlePathsForInstalls(projectDir, input.installs);
if (skillBundlePaths.length > 0) {
lines.push(`${step}. Upload Claude Desktop skills`);
lines.push(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.');
lines.push(' Upload this file:');
for (const path of skillBundlePaths) {
lines.push(` ${path}`);
}
lines.push(' Toggle the uploaded KTX skills on.');
pushBlankLine(lines);
step += 1;
}
}
if (lines.length === 0) {
@ -1008,8 +1012,8 @@ async function installTarget(input: {
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
continue;
}
if (entry.role === 'claude-plugin') {
await writeClaudeDesktopPlugin({
if (entry.role === 'claude-desktop-skill-bundle') {
await writeClaudeDesktopSkillBundle({
projectDir: input.projectDir,
path: entry.path,
mode: input.mode,