mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-13 11:22:11 +02:00
fix(cli): package Claude Desktop skills in one zip
This commit is contained in:
parent
b42f418adc
commit
ddabe517e3
4 changed files with 130 additions and 107 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue