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

@ -119,8 +119,8 @@ After setup, KTX prints **Required before using agents**. Complete those steps
before opening the configured agent. If it shows `ktx mcp start --project-dir ...`,
run that command before using Claude Code, Codex, Cursor, OpenCode, or generic
MCP clients. The same output also prints the matching `ktx mcp stop` command
for when you want to stop MCP later. Claude Desktop uses its own launcher and
only needs a restart.
for when you want to stop MCP later. Claude Desktop uses its own launcher for
MCP and prints separate skill upload steps.
The analytics skill teaches client agents the MCP workflow: discover data,
prefer semantic-layer measures, inspect entity details before raw SQL, and
@ -136,9 +136,10 @@ ktx sl validate orders
Supported client agents: Claude Code, Claude Desktop, Codex, Cursor, OpenCode,
and clients that can use the printed MCP endpoint or `.agents` admin skills.
Claude Desktop setup registers a local `ktx mcp stdio` server in Claude
Desktop's config and generates `.ktx/agents/claude/ktx-plugin.zip` with the
analytics skill. Restart Claude Desktop after setup; no manual plugin install
step is required.
Desktop's config and generates `.ktx/agents/claude/ktx-skills.zip` with one or
two skills, depending on the setup mode. Restart Claude Desktop after setup,
then upload that ZIP from **Customize** > **Skills** > **+** > **Create
skill** > **Upload a skill**.
The release artifact manifest contains the public npm tarball and the bundled
`kaelio-ktx` runtime wheel. The `python/ktx-sl` and `python/ktx-daemon`

View file

@ -32,7 +32,7 @@ ktx setup --agents --target codex
```
Use `--global` only with `claude-code` or `codex`. Claude Desktop always writes
global Claude Desktop config and generates a project-local plugin package:
global Claude Desktop config and generates a project-local skill ZIP:
```bash
ktx setup --agents --target claude-code --global
@ -85,12 +85,12 @@ before opening the configured agent. If it shows `ktx mcp start --project-dir ..
run that command before using Claude Code, Codex, Cursor, OpenCode, or generic
MCP clients. The same output also prints the matching `ktx mcp stop` command
for when you want to stop MCP later. Claude Desktop uses its own launcher and
only needs a restart.
prints separate skill upload steps.
| Target | Ask data questions with KTX MCP | Adds when agents can manage KTX with CLI |
|--------|------------------------------|---------------------------|
| Claude Code | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md` | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` |
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-plugin.zip` with analytics skill | Adds `skills/ktx/SKILL.md` inside the plugin ZIP |
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-skills.zip` upload | Adds `ktx/SKILL.md` inside the same ZIP |
| Codex | Printed snippet for `~/.codex/config.toml`, `.agents/skills/ktx-analytics/SKILL.md` | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` |
| Cursor | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` | `.cursor/rules/ktx.mdc` |
| OpenCode | Printed snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` | `.opencode/commands/ktx.md` |
@ -178,8 +178,8 @@ same markdown command definitions.
## Claude Desktop
During setup, select **Claude Desktop** from the agent targets. KTX writes the
MCP server entry directly into Claude Desktop's config and prepares the
Claude Desktop skill package for the analytics workflow:
MCP server entry directly into Claude Desktop's config and prepares uploadable
Claude Desktop skill packages for the KTX workflows:
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
@ -187,14 +187,20 @@ Claude Desktop skill package for the analytics workflow:
launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
the server without needing `node` in PATH.
- `.ktx/agents/claude/ktx-plugin.zip` contains the `ktx-analytics` skill (and
the admin `ktx` skill if you choose **Ask data questions + manage KTX with
CLI commands**). This package is generated by KTX setup; no manual plugin
install step is required.
- `.ktx/agents/claude/ktx-skills.zip` contains the `ktx-analytics` skill. If
you choose **Ask data questions + manage KTX with CLI commands**, the same
ZIP also contains the admin `ktx` skill.
After `ktx setup`, restart Claude Desktop so it picks up the new MCP server
entry and bundled KTX skills. No daemon needs to be running — Claude Desktop
spawns the MCP server itself per session.
entry. No daemon needs to be running -- Claude Desktop spawns the MCP server
itself per session.
Upload the generated skill ZIP from Claude Desktop:
1. Open **Customize** > **Skills**.
2. Click **+** > **Create skill** > **Upload a skill**.
3. Upload `.ktx/agents/claude/ktx-skills.zip`.
4. Toggle the uploaded KTX skills on.
Claude Desktop does not introspect local stdio MCP servers, so the per-tool
"Connector"-style UI is not rendered for KTX. The tools are still callable
@ -202,7 +208,8 @@ from any Claude Desktop chat.
If you move the KTX checkout or project directory, rerun `ktx setup --agents`
to refresh the absolute paths in `claude_desktop_config.json` and the launcher
shim, then restart Claude Desktop.
shim, regenerate the skill ZIP, then restart Claude Desktop and upload the new
ZIP.
---
@ -290,8 +297,8 @@ Admin CLI skills call the same KTX CLI commands:
| | Claude Code | Claude Desktop | Cursor | Codex | OpenCode |
|---|---|---|---|---|---|
| MCP tools | Yes | Local stdio via `claude_desktop_config.json` | Yes | Snippet | Snippet |
| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Included in plugin ZIP | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` |
| Admin CLI skills | Optional | Optional in plugin ZIP | Optional (.mdc) | Optional | Optional |
| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Upload `.ktx/agents/claude/ktx-skills.zip` | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` |
| Admin CLI skills | Optional | Optional inside the same ZIP | Optional (.mdc) | Optional | Optional |
| Global install | Yes | Claude Desktop config | No | Yes | No |
| Rule or instruction file | `.claude/rules/ktx.md` | Plugin `SETUP.md` | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` |
| Skill file | `.claude/skills/ktx/SKILL.md` | `skills/ktx/SKILL.md` in plugin ZIP | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate |
| Rule or instruction file | `.claude/rules/ktx.md` | Not separate | `.cursor/rules/ktx.mdc` | `.codex/instructions/ktx.md` | `.opencode/commands/ktx.md` |
| Skill file | `.claude/skills/ktx/SKILL.md` | `ktx/SKILL.md` inside `ktx-skills.zip` | Not separate | `.agents/skills/ktx/SKILL.md` | Not separate |

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,