From 0cbf8121d9f373e047c43883e8f7623f22cddef7 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 19 May 2026 14:12:18 +0200 Subject: [PATCH] Polish ktx setup-agents output: hint, summaries, outro --- ...26-05-19-ktx-setup-agents-output-polish.md | 728 ++++++++++++++++++ ...9-ktx-setup-agents-output-polish-design.md | 220 ++++++ packages/cli/src/setup-agents.test.ts | 133 ++-- packages/cli/src/setup-agents.ts | 157 +++- 4 files changed, 1151 insertions(+), 87 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-19-ktx-setup-agents-output-polish.md create mode 100644 docs/superpowers/specs/2026-05-19-ktx-setup-agents-output-polish-design.md diff --git a/docs/superpowers/plans/2026-05-19-ktx-setup-agents-output-polish.md b/docs/superpowers/plans/2026-05-19-ktx-setup-agents-output-polish.md new file mode 100644 index 00000000..a0c90d3c --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-ktx-setup-agents-output-polish.md @@ -0,0 +1,728 @@ +# KTX Setup Agents Output Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `ktx setup --agents` print the polished interactive hint, +per-install inline summaries, the required next-actions note, and the final +`All set.` outro described by the May 19 output-polish spec. + +**Architecture:** Keep the change centered in +`packages/cli/src/setup-agents.ts` and preserve the existing +`withMultiselectNavigation` helper for other setup flows. Replace the old +boxed install-summary string with structured summary entries, then render those +entries with Clack `log.step` for real TTY output and plain `io.stdout` text in +tests or scripted output. The current uncommitted Claude Desktop split-ZIP +changes are treated as the baseline and must not be reverted. + +**Tech Stack:** TypeScript, Vitest, Node stream types, `@clack/prompts`, +pnpm workspace commands. + +--- + +## Current audit + +Original spec: +`docs/superpowers/specs/2026-05-19-ktx-setup-agents-output-polish-design.md` + +Current uncommitted files: + +- `README.md` +- `docs-site/content/docs/integrations/agent-clients.mdx` +- `packages/cli/src/setup-agents.test.ts` +- `packages/cli/src/setup-agents.ts` + +Observed implementation status: + +- The branch currently contains unrelated Claude Desktop split-ZIP work: + `ktx-analytics.zip` and optional `ktx.zip` replace the old combined + `ktx-skills.zip`. Keep that work intact. +- `packages/cli/src/setup-agents.ts` still imports and uses + `withMultiselectNavigation` for the agents multiselect prompt. +- `runKtxSetupAgentsStep` does not emit + `Space to select, Enter to confirm, Esc to go back.` before interactive + prompts. +- `formatInstallSummary` still returns one boxed string containing + `KTX project`, `Installed agents`, and nested indentation. +- `runKtxSetupAgentsStep` still prints the install summary with + `setupUi.note(..., 'Agent integration complete', io)`. +- `runKtxSetupAgentsStep` does not emit `outro('All set.')` after the + "Required before using agents" note. +- Tests still assert the old long multiselect message and old boxed install + summary. + +V1-blocking gaps: + +- Move the multiselect navigation hint out of the question text and print it + once before interactive prompts. +- Render install summaries as inline `log.step` blocks, one block per install. +- Keep the next-actions note boxed and emit `All set.` only when next-actions + are shown. +- Update setup-agents tests to cover the new output contract. + +Non-blocking gaps: + +- The docs updates for Claude Desktop split ZIPs are outside this output-polish + spec and are already present in the current working tree. +- `setup --sources` and `setup --databases` still use + `withMultiselectNavigation`; the spec explicitly defers those flows. +- There is no docs-site update for the output shape because no public docs page + currently promises the old boxed install-summary layout. + +## File structure + +Modify: + +- `packages/cli/src/setup-agents.ts` + - Remove the agents-flow dependency on `withMultiselectNavigation`. + - Add tiny output helpers that use Clack `log.info`, `log.step`, and `outro` + when `io.stdout` is a writable TTY and fall back to plain writes otherwise. + - Rename `formatInstallSummary` to `formatInstallSummaryLines` and return + structured entries. + - Render each summary entry with `log.step`. + - Emit the final outro only when next-actions are printed. +- `packages/cli/src/setup-agents.test.ts` + - Import `formatInstallSummaryLines`. + - Update the four output-polish tests named in the spec. + - Add or update assertions for the single hint line and final outro. + +Do not modify: + +- `packages/cli/src/prompt-navigation.ts` +- `packages/cli/src/prompt-navigation.test.ts` +- `README.md` +- `docs-site/content/docs/integrations/agent-clients.mdx` + +## Task 1: Move the interactive multiselect hint out of the prompt + +**Files:** + +- Modify: `packages/cli/src/setup-agents.test.ts` +- Modify: `packages/cli/src/setup-agents.ts` + +- [ ] **Step 1: Write the failing prompt-output test** + +In `packages/cli/src/setup-agents.test.ts`, replace the existing test named +`explains how to select multiple agent targets in interactive mode` with: + +```typescript + it('prints one navigation hint before interactive agent target prompts', async () => { + const io = makeIo(); + const prompts = { + select: vi.fn(async () => 'mcp-cli'), + multiselect: vi.fn(async () => ['back']), + cancel: vi.fn(), + }; + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp-cli', + skipAgents: false, + }, + io.io, + { prompts }, + ), + ).resolves.toEqual({ status: 'back', projectDir: tempDir }); + + expect(io.stdout()).toContain('Space to select, Enter to confirm, Esc to go back.'); + expect(io.stdout().match(/Space to select/g)).toHaveLength(1); + expect(prompts.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which agent targets should KTX install?', + }), + ); + }); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "prints one navigation hint before interactive agent target prompts" +``` + +Expected: FAIL because the current implementation embeds the long navigation +hint in the multiselect message and does not write the short hint to +`io.stdout`. + +- [ ] **Step 3: Add Clack output helpers for setup-agents** + +In `packages/cli/src/setup-agents.ts`, replace the current top imports: + +```typescript +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +``` + +with: + +```typescript +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { log, outro } from '@clack/prompts'; +``` + +Remove this import: + +```typescript +import { withMultiselectNavigation } from './prompt-navigation.js'; +``` + +Add these helpers after the `KtxCliLauncher` interface: + +```typescript +function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable { + return ( + output.isTTY === true && + typeof (output as { on?: unknown }).on === 'function' && + typeof (output as { columns?: unknown }).columns !== 'undefined' + ); +} + +function writeSetupInfo(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.info(message, { output: io.stdout }); + return; + } + io.stdout.write(`${message}\n`); +} + +function writeSetupStep(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.step(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} + +function writeSetupOutro(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + outro(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} +``` + +- [ ] **Step 4: Print the short hint only before interactive prompts** + +In `runKtxSetupAgentsStep`, immediately after: + +```typescript + const prompts = deps.prompts ?? createPromptAdapter(); +``` + +add: + +```typescript + if (args.inputMode === 'auto' && args.target === undefined) { + writeSetupInfo(io, 'Space to select, Enter to confirm, Esc to go back.'); + } +``` + +In the multiselect call, replace: + +```typescript + message: withMultiselectNavigation('Which agent targets should KTX install?'), +``` + +with: + +```typescript + message: 'Which agent targets should KTX install?', +``` + +- [ ] **Step 5: Run the focused test and verify it passes** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "prints one navigation hint before interactive agent target prompts" +``` + +Expected: PASS. + +## Task 2: Return structured install-summary entries + +**Files:** + +- Modify: `packages/cli/src/setup-agents.test.ts` +- Modify: `packages/cli/src/setup-agents.ts` + +- [ ] **Step 1: Update the summary formatter import in tests** + +In `packages/cli/src/setup-agents.test.ts`, replace: + +```typescript + formatInstallSummary, +``` + +with: + +```typescript + formatInstallSummaryLines, +``` + +- [ ] **Step 2: Write failing structured formatter tests** + +In `packages/cli/src/setup-agents.test.ts`, replace the test named +`formats summary with explicit project-scoped config paths` with: + +```typescript + it('formats summary with explicit project-scoped config paths', () => { + const summary = formatInstallSummaryLines( + [{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }], + [ + { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, + { kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') }, + { kind: 'json-key', path: join(tempDir, '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, + ], + tempDir, + ); + + expect(summary).toEqual([ + { + title: 'Cursor · Project scope', + lines: [ + join(tempDir, '.cursor/mcp.json'), + 'Requires MCP to be started.', + 'Cursor rules installed.', + ], + }, + ]); + }); +``` + +Replace the test named `formats summary with multiple agent targets` with: + +```typescript + it('formats summary with multiple agent targets', () => { + const summary = formatInstallSummaryLines( + [ + { target: 'claude-code', scope: 'project', mode: 'mcp-cli' }, + { target: 'codex', scope: 'project', mode: 'mcp-cli' }, + ], + [ + { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + { kind: 'file', path: join(tempDir, '.claude/skills/ktx/SKILL.md'), role: 'skill' }, + { kind: 'file', path: join(tempDir, '.claude/rules/ktx.md'), role: 'rule' }, + { kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, + { kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + { kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md'), role: 'skill' }, + { kind: 'file', path: join(tempDir, '.codex/instructions/ktx.md'), role: 'rule' }, + ], + tempDir, + ); + + expect(summary).toEqual([ + { + title: 'Claude Code · Project scope', + lines: [ + join(tempDir, '.mcp.json'), + 'Requires MCP to be started.', + 'Analytics skill installed.', + 'Admin CLI skill installed.', + ], + }, + { + title: 'Codex · Project scope', + lines: [ + 'Add the snippet shown below to ~/.codex/config.toml.', + 'Requires MCP to be started.', + 'Codex guidance installed.', + ], + }, + ]); + }); +``` + +- [ ] **Step 3: Run the focused formatter tests and verify they fail** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "formats summary" +``` + +Expected: FAIL because `formatInstallSummaryLines` is not exported and the +current formatter returns one string. + +- [ ] **Step 4: Replace the string summary formatter with structured entries** + +In `packages/cli/src/setup-agents.ts`, add this exported interface immediately +before the current `formatInstallSummary` function: + +```typescript +export interface InstallSummaryEntry { + title: string; + lines: string[]; +} +``` + +Replace the full `export function formatInstallSummary(...)` implementation +with: + +```typescript +function formatInlinePath(path: string): string { + const home = process.env.HOME; + if (!home) return path; + const resolvedHome = resolve(home); + if (path === resolvedHome) return '~'; + if (path.startsWith(`${resolvedHome}/`)) { + return `~/${relative(resolvedHome, path)}`; + } + return path; +} + +export function formatInstallSummaryLines( + installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, + entries: InstallEntry[], + projectDir: string, +): InstallSummaryEntry[] { + const entriesByTarget = new Map(); + for (const install of installs) { + const plannedFilePaths = new Set( + plannedKtxAgentFiles({ projectDir, ...install }) + .filter((entry) => entry.kind === 'file') + .map((entry) => entry.path), + ); + entriesByTarget.set( + install.target, + entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)), + ); + } + + const mcpEntriesByTarget = new Map(); + for (const install of installs) { + const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey)); + mcpEntriesByTarget.set( + install.target, + entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))), + ); + } + + return installs.map((install) => { + const targetEntries = entriesByTarget.get(install.target) ?? []; + const mcpEntry = mcpEntriesByTarget + .get(install.target) + ?.find((entry): entry is Extract => entry.kind === 'json-key'); + const lines: string[] = []; + + if (mcpEntry) { + lines.push(formatInlinePath(mcpEntry.path)); + } else if (install.target !== 'claude-desktop') { + lines.push(manualMcpConfigInstruction(install.target, install.scope)); + } + + if (targetUsesHttpMcpDaemon(install.target)) { + lines.push('Requires MCP to be started.'); + } + + const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill'); + const hasAdmin = hasAdminCliEntries(targetEntries); + const claudeDesktopSkillBundles = targetEntries.filter( + (entry): entry is Extract => + entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle', + ); + + if (install.target === 'claude-code') { + if (hasAnalytics) { + lines.push('Analytics skill installed.'); + } + if (hasAdmin) { + lines.push('Admin CLI skill installed.'); + } + } else if (install.target === 'claude-desktop') { + if (claudeDesktopSkillBundles.length > 0) { + lines.push('Skill bundles:'); + for (const bundle of claudeDesktopSkillBundles) { + lines.push(` ${bundle.path}`); + } + } + } else if (hasAnalytics || hasAdmin) { + lines.push(`${guidanceInstallLine(install.target)}.`); + } + + if (hasEntryRole(targetEntries, 'launcher')) { + lines.push('Starts KTX over stdio from Claude Desktop.'); + } + + return { + title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, + lines, + }; + }); +} +``` + +- [ ] **Step 5: Run the focused formatter tests and verify they pass** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "formats summary" +``` + +Expected: PASS. + +## Task 3: Render inline install summaries with `log.step` + +**Files:** + +- Modify: `packages/cli/src/setup-agents.test.ts` +- Modify: `packages/cli/src/setup-agents.ts` + +- [ ] **Step 1: Write failing install-summary output assertions** + +In `packages/cli/src/setup-agents.test.ts`, replace the body of the test named +`prints per-agent install summary after successful installation` with: + +```typescript + const io = makeIo(); + + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'mcp-cli', + skipAgents: false, + }, + io.io, + ); + + const output = io.stdout(); + expect(output).toContain('Claude Code · Project scope'); + expect(output).toContain(join(tempDir, '.mcp.json')); + expect(output).toContain('Requires MCP to be started.'); + expect(output).toContain('Analytics skill installed.'); + expect(output).toContain('Admin CLI skill installed.'); + expect(output).not.toContain('Agent integration complete'); + expect(output).not.toContain(`KTX project\n ${tempDir}`); + expect(output).not.toContain('Installed agents'); + expect(output).not.toContain('.claude/skills/ktx-analytics/SKILL.md'); + expect(output).not.toContain('.claude/skills/ktx/SKILL.md'); + expect(output).not.toContain('.claude/rules/ktx.md'); +``` + +In the test named `can return agent next actions without printing them`, replace: + +```typescript + expect(io.stdout()).toContain('Agent integration complete'); + expect(io.stdout()).not.toContain('Required before using agents'); +``` + +with: + +```typescript + expect(io.stdout()).toContain('Claude Code · Project scope'); + expect(io.stdout()).not.toContain('Agent integration complete'); + expect(io.stdout()).not.toContain('Required before using agents'); + expect(io.stdout()).not.toContain('All set.'); +``` + +- [ ] **Step 2: Run the focused output tests and verify they fail** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "install summary|without printing" +``` + +Expected: FAIL because `Agent integration complete` is still printed and the +new inline summary heading is absent. + +- [ ] **Step 3: Render each structured summary as an inline step** + +In `runKtxSetupAgentsStep`, replace: + +```typescript + setupUi.note( + formatInstallSummary(installs, entries, args.projectDir), + 'Agent integration complete', + io, + ); +``` + +with: + +```typescript + for (const summary of formatInstallSummaryLines(installs, entries, args.projectDir)) { + writeSetupStep( + io, + summary.lines.length > 0 ? `${summary.title}\n${summary.lines.join('\n')}` : summary.title, + ); + } +``` + +- [ ] **Step 4: Run the focused output tests and verify they pass** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "install summary|without printing" +``` + +Expected: PASS. + +## Task 4: Emit the final outro after next actions + +**Files:** + +- Modify: `packages/cli/src/setup-agents.test.ts` +- Modify: `packages/cli/src/setup-agents.ts` + +- [ ] **Step 1: Add failing outro assertions** + +In `packages/cli/src/setup-agents.test.ts`, in the test named +`prints standalone agent next actions after successful installation`, add this +assertion after the existing next-actions stdout assertions: + +```typescript + expect(io.stdout()).toContain('All set.'); +``` + +In the test named `prints one target-aware next actions block for mixed agent +targets`, add this assertion near the other stdout assertions: + +```typescript + expect(output).toContain('All set.'); +``` + +- [ ] **Step 2: Run the focused next-actions tests and verify they fail** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "next actions|All set|without printing" +``` + +Expected: FAIL because no outro is emitted. + +- [ ] **Step 3: Emit `All set.` only when next-actions are shown** + +In `runKtxSetupAgentsStep`, replace: + +```typescript + if (args.showNextActions !== false) { + setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line }); + } +``` + +with: + +```typescript + if (args.showNextActions !== false) { + setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line }); + writeSetupOutro(io, 'All set.'); + } +``` + +- [ ] **Step 4: Run the focused next-actions tests and verify they pass** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts -t "next actions|All set|without printing" +``` + +Expected: PASS. + +## Task 5: Verify the full CLI package surface + +**Files:** + +- Verify: `packages/cli/src/setup-agents.ts` +- Verify: `packages/cli/src/setup-agents.test.ts` + +- [ ] **Step 1: Run the setup-agents test file** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-agents.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run the CLI type-check** + +Run: + +```bash +pnpm --filter @ktx/cli run type-check +``` + +Expected: PASS. + +- [ ] **Step 3: Run the CLI test script** + +Run: + +```bash +pnpm --filter @ktx/cli run test +``` + +Expected: PASS. + +- [ ] **Step 4: Run dead-code checks for TypeScript changes** + +Run: + +```bash +pnpm run dead-code +``` + +Expected: PASS. If Knip reports unrelated existing findings, inspect them and +record the exact unrelated findings before finishing. + +- [ ] **Step 5: Run pre-commit for modified TypeScript files** + +Run: + +```bash +uv run pre-commit run --files packages/cli/src/setup-agents.ts packages/cli/src/setup-agents.test.ts +``` + +Expected: PASS. If the repository has no usable pre-commit environment or the +configured tool versions are unavailable, state the exact failure and keep the +passing pnpm checks as the closest available verification. + +- [ ] **Step 6: Inspect the final diff** + +Run: + +```bash +git diff -- packages/cli/src/setup-agents.ts packages/cli/src/setup-agents.test.ts +``` + +Expected: The diff only contains output-polish changes on top of the existing +Claude Desktop split-ZIP baseline. It must not revert the current +`ktx-analytics.zip` and `ktx.zip` behavior. + +## Self-review checklist + +- The agents multiselect message is exactly + `Which agent targets should KTX install?`. +- The short navigation hint appears once for interactive no-target runs and not + for scripted target runs. +- The shared `withMultiselectNavigation` helper and its tests are unchanged. +- `Agent integration complete`, `KTX project`, and `Installed agents` no longer + appear in setup-agents install-summary output. +- `Required before using agents` remains a `note()` box. +- `All set.` appears only when the next-actions note is printed. +- Current uncommitted README and docs-site split-ZIP edits are untouched. diff --git a/docs/superpowers/specs/2026-05-19-ktx-setup-agents-output-polish-design.md b/docs/superpowers/specs/2026-05-19-ktx-setup-agents-output-polish-design.md new file mode 100644 index 00000000..82fb8be8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-ktx-setup-agents-output-polish-design.md @@ -0,0 +1,220 @@ +# `ktx setup --agents` output polish + +## Problem + +The current `ktx setup --agents` flow renders four visual issues: + +1. The multiselect prompt embeds a long navigation hint + (`Use Up/Down to move, Space to select or unselect, Enter to confirm, + Escape to go back, or Ctrl+C to exit.`) directly into the question text. + Clack uses the same message verbatim for the confirmed-state echo, so the + hint stays on screen after the user has already answered. +2. After install, two heavy `note()` boxes appear back-to-back ("Agent + integration complete" and "Required before using agents") with the same + visual weight, making the post-install output feel boxed in. +3. The install-complete box has 3–4 levels of nested indentation, which is + hard to scan. +4. The flow ends after the second box; there is no `outro` marker. + +## Scope + +Narrow: changes apply only to `runKtxSetupAgentsStep` in +`packages/cli/src/setup-agents.ts`. The shared `withMultiselectNavigation` +helper is left as-is so `setup --sources` and `setup --databases` keep their +current prompt rendering until a follow-up. + +## Goals + +- Replace the in-question navigation hint for the agents multiselect with a + one-time `log.info(...)` line emitted before the first interactive prompt. +- Render the per-install summary inline using `log.step` (one block per + install) instead of a `note()` box. +- Keep the "Required before using agents" `note()` box (the file paths users + copy/paste benefit from visual framing). +- End the flow with `outro('All set.')` when next-actions are shown. + +Non-goals: + +- No change to the actual install logic, manifest, or generated config files. +- No change to `setup --sources` or `setup --databases` (deferred). +- No change to `formatAgentNextActions` content (only the surrounding `note` + call site is touched; body stays). +- No path shortening to project-relative in next-actions output — users may + copy/paste from terminals outside the project, so absolute paths stay. + +## Design + +### Prompt polish + +In `runKtxSetupAgentsStep`, emit the navigation hint **once, only when +interactive prompts will actually run** (`args.inputMode === 'auto'` and +`args.target === undefined`), immediately before the first prompt: + +```ts +log.info('Space to select, Enter to confirm, Esc to go back.'); +``` + +In scripted mode (`inputMode === 'disabled'` or `--target` supplied), the +hint is skipped — no prompts run, so the help would be noise. + +Replace `withMultiselectNavigation('Which agent targets should KTX install?')` +with the plain string `'Which agent targets should KTX install?'`. + +The shared `withMultiselectNavigation` helper, its export, and its +`prompt-navigation.test.ts` test remain untouched — they still apply to the +other two setup flows. + +### Install summary + +Today: `formatInstallSummary(...)` returns a multi-line string that +`runKtxSetupAgentsStep` passes to `setupUi.note(..., 'Agent integration +complete', io)`. + +New: rename `formatInstallSummary` to `formatInstallSummaryLines` and have it +return a structured array — one entry per install — with: + +```ts +type InstallSummaryEntry = { + title: string; // e.g. "Claude Desktop · Global scope" + lines: string[]; // body lines, no indentation prefix +}; +``` + +`runKtxSetupAgentsStep` then iterates and emits one `log.step(title + +'\n' + body)` per install. The "KTX project / " header line is dropped +(the user already sees the project path in the existing `intro` block, e.g. +`Project: /tmp/ktx7`). + +Body lines for a Claude Desktop install: + +``` +~/Library/Application Support/Claude/claude_desktop_config.json +Skill bundles: + /tmp/ktx7/.ktx/agents/claude/ktx-analytics.zip + /tmp/ktx7/.ktx/agents/claude/ktx.zip +Starts KTX over stdio from Claude Desktop. +``` + +Body lines for a Claude Code project install (HTTP MCP target): + +``` +Project scope: /tmp/ktx7/.mcp.json +Requires MCP to be started. +Analytics skill installed. +Admin CLI skill installed. +``` + +Path rendering: replace a leading `$HOME` with `~` for absolute paths shown +inline in the body, leaving non-home paths untouched. Skill-bundle paths +inside `projectDir` stay absolute (users copy these into the Claude Desktop +file picker). + +### Next actions + +`formatAgentNextActions(...)` is unchanged. The `setupUi.note(..., +'Required before using agents', io, { format: (line) => line })` call site +stays. This is the one remaining box and acts as the visual payoff with +copy-pasteable instructions. + +### Outro + +After the next-actions note (when `args.showNextActions !== false`), emit: + +```ts +outro('All set.'); +``` + +When next actions are suppressed (`showNextActions === false`), no `outro` is +emitted — the caller is composing a larger flow. + +## Final layout (Claude Desktop, global) + +``` +┌ KTX setup +│ Project: /tmp/ktx7 +│ +● Space to select, Enter to confirm, Esc to go back. +│ +◇ What should agents be allowed to do with this KTX project? +│ Ask data questions + manage KTX with CLI commands +│ +◇ Which agent targets should KTX install? +│ Claude Desktop +│ +◆ Claude Desktop · Global scope +│ ~/Library/Application Support/Claude/claude_desktop_config.json +│ Skill bundles: +│ /tmp/ktx7/.ktx/agents/claude/ktx-analytics.zip +│ /tmp/ktx7/.ktx/agents/claude/ktx.zip +│ Starts KTX over stdio from Claude Desktop. +│ +◇ Required before using agents ───────────────────────────────────╮ +│ │ +│ 1. Restart Claude Desktop │ +│ Claude Desktop loads KTX MCP after restart. │ +│ │ +│ 2. Upload Claude Desktop skills │ +│ Open Claude Desktop: Customize > Skills > + > Create skill. │ +│ Upload these files: │ +│ /tmp/ktx7/.ktx/agents/claude/ktx-analytics.zip │ +│ /tmp/ktx7/.ktx/agents/claude/ktx.zip │ +│ Toggle the uploaded KTX skills on. │ +│ │ +├──────────────────────────────────────────────────────────────────╯ +│ +└ All set. +``` + +## Test impact + +`packages/cli/src/setup-agents.test.ts` — four tests update: + +- **`explains how to select multiple agent targets in interactive mode`** + (L879) — currently asserts the multiselect `message` ends with the long + navigation hint string. Update to assert the plain message string. The test + name and intent shift: it now verifies the multiselect message no longer + embeds the hint and that the navigation hint was emitted once via stdout + before the prompts (`Space to select, Enter to confirm, Esc to go back.`). +- **`prints per-agent install summary after successful installation`** + (L911) — currently asserts `Agent integration complete` appears in stdout + plus the multi-level indented summary structure (`KTX project\n + ${tempDir}`, `Installed agents`, `Project scope\n ${path}`). + Update to assert the new inline `log.step` lines: + - `Claude Code · Project scope` heading + - path on its own line (e.g., `${join(tempDir, '.mcp.json')}`) + - `Requires MCP to be started.` + - `Analytics skill installed.` + - `Admin CLI skill installed.` + - Stops asserting `Agent integration complete` and `KTX project\n …`. +- **`formats summary with explicit project-scoped config paths`** (L942) and + **`formats summary with multiple agent targets`** (L961) — call + `formatInstallSummary` directly. Update to call + `formatInstallSummaryLines` and assert on the returned structured array + shape and content per install. +- **`can return agent next actions without printing them`** (L212) — asserts + `'Agent integration complete'` appears in stdout when `showNextActions: + false`. Update to assert the new heading `Claude Code · Project scope` + appears instead. + +The next-actions assertions (L181, L1028, L1089) keep passing — that code +path is unchanged. + +## Verification + +- `pnpm --filter @ktx/cli run type-check` +- `pnpm --filter @ktx/cli run test` +- Manual run: `ktx setup --agents` against `/tmp/ktx7` (or any fresh project) + with `--target claude-desktop --scope global --mode mcp-cli` to compare + against the layout above. + +## Risks + +- The `formatInstallSummary` rename is a breaking change for any external + importer. Grep confirms it is only imported from + `packages/cli/src/setup-agents.test.ts`; no production consumer outside + `runKtxSetupAgentsStep`. KTX has no public users (`feedback_ktx_no_backward_compat`), + so a clean rename is acceptable. +- `log.info` for the navigation hint runs in TTY mode only via Clack. In + non-TTY mode (`setupUi.note` falls back to plain stdout) the hint is not + emitted. Acceptable because non-TTY runs are scripted (`--target`/`--yes`) + and don't need keyboard help. diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 341b1240..4975e487 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -5,7 +5,7 @@ import { readKtxSetupState } from '@ktx/context/project'; import { strFromU8, unzipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - formatInstallSummary, + formatInstallSummaryLines, plannedKtxAgentFiles, readKtxAgentInstallManifest, removeKtxAgentInstall, @@ -84,7 +84,7 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', - path: join(tempDir, '.ktx/agents/claude/ktx-skills.zip'), + path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), role: 'claude-desktop-skill-bundle', }, ]); @@ -129,7 +129,12 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', - path: join(tempDir, '.ktx/agents/claude/ktx-skills.zip'), + path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), + role: 'claude-desktop-skill-bundle', + }, + { + kind: 'file', + path: join(tempDir, '.ktx/agents/claude/ktx.zip'), role: 'claude-desktop-skill-bundle', }, ]); @@ -205,6 +210,7 @@ describe('setup agents', () => { expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`); expect(io.stdout()).toContain('If you need to stop MCP later:'); expect(io.stdout()).toContain(`ktx mcp stop --project-dir ${tempDir}`); + expect(io.stdout()).toContain('All set.'); expect(io.stdout()).not.toContain('Finish agent setup'); expect(io.stdout()).not.toContain('Next actions'); }); @@ -231,8 +237,10 @@ describe('setup agents', () => { status: 'ready', nextActions: expect.stringContaining(`ktx mcp start --project-dir ${tempDir}`), }); - expect(io.stdout()).toContain('Agent integration complete'); + expect(io.stdout()).toContain('Claude Code · Project scope'); + expect(io.stdout()).not.toContain('Agent integration complete'); expect(io.stdout()).not.toContain('Required before using agents'); + expect(io.stdout()).not.toContain('All set.'); }); it('installs the analytics skill from the runtime asset', async () => { @@ -452,9 +460,11 @@ describe('setup agents', () => { installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }], }); - const skillBundlePath = join(tempDir, '.ktx/agents/claude/ktx-skills.zip'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip'); const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); - await expect(stat(skillBundlePath)).resolves.toBeDefined(); + await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); + await expect(stat(adminSkillPath)).rejects.toThrow(); const launcherStat = await stat(launcherPath); expect(launcherStat.mode & 0o111).not.toBe(0); const launcher = await readFile(launcherPath, 'utf-8'); @@ -470,15 +480,16 @@ describe('setup agents', () => { args: ['--project-dir', tempDir, 'mcp', 'stdio'], }); - 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( + expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); + await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(analyticsSkillPath, '.claude-plugin/plugin.json')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(analyticsSkillPath, 'skills/ktx-analytics/SKILL.md')).rejects.toThrow( 'Missing zip entry', ); expect(io.stdout()).toContain('Claude Desktop'); - expect(io.stdout()).toContain(skillBundlePath); + expect(io.stdout()).toContain(analyticsSkillPath); + expect(io.stdout()).not.toContain(adminSkillPath); expect(io.stdout()).toContain('claude_desktop_config.json'); expect(io.stdout()).toContain('Required before using agents'); expect(io.stdout()).toContain('1. Restart Claude Desktop'); @@ -569,13 +580,18 @@ describe('setup agents', () => { installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }], }); - 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'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.zip'); + expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); + await expect(readZipText(analyticsSkillPath, 'ktx/SKILL.md')).rejects.toThrow('Missing zip entry'); + const adminSkill = await readZipText(adminSkillPath, 'ktx/SKILL.md'); expect(adminSkill).toContain(`--project-dir ${tempDir}`); expect(adminSkill).toContain('status --json'); - await expect(readZipText(skillBundlePath, '.mcp.json')).rejects.toThrow('Missing zip entry'); - expect(io.stdout()).toContain(skillBundlePath); + await expect(readZipText(adminSkillPath, '.mcp.json')).rejects.toThrow('Missing zip entry'); + await expect(readZipText(adminSkillPath, 'ktx-analytics/SKILL.md')).rejects.toThrow('Missing zip entry'); + expect(io.stdout()).toContain(analyticsSkillPath); + expect(io.stdout()).toContain(adminSkillPath); + expect(io.stdout()).toContain('Upload each file separately:'); } finally { process.env.HOME = previousHome; await rm(home, { recursive: true, force: true }); @@ -826,10 +842,12 @@ describe('setup agents', () => { }, io.io, ); - const skillBundlePath = join(tempDir, '.ktx/agents/claude/ktx-skills.zip'); + const analyticsSkillPath = join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'); + const adminSkillPath = join(tempDir, '.ktx/agents/claude/ktx.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(skillBundlePath)).resolves.toBeDefined(); + await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); + await expect(stat(adminSkillPath)).resolves.toBeDefined(); await expect(stat(launcherPath)).resolves.toBeDefined(); const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; @@ -838,7 +856,8 @@ describe('setup agents', () => { await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0); - await expect(stat(skillBundlePath)).rejects.toThrow(); + await expect(stat(analyticsSkillPath)).rejects.toThrow(); + await expect(stat(adminSkillPath)).rejects.toThrow(); await expect(stat(launcherPath)).rejects.toThrow(); const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; @@ -876,7 +895,7 @@ describe('setup agents', () => { ).resolves.toEqual({ status: 'skipped', projectDir: tempDir }); }); - it('explains how to select multiple agent targets in interactive mode', async () => { + it('prints one navigation hint before interactive agent target prompts', async () => { const io = makeIo(); const prompts = { select: vi.fn(async () => 'mcp-cli'), @@ -900,10 +919,11 @@ describe('setup agents', () => { ), ).resolves.toEqual({ status: 'back', projectDir: tempDir }); + expect(io.stdout()).toContain('Space to select, Enter to confirm, Esc to go back.'); + expect(io.stdout().match(/Space to select/g)).toHaveLength(1); expect(prompts.multiselect).toHaveBeenCalledWith( expect.objectContaining({ - message: - 'Which agent targets should KTX install?\nUse Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + message: 'Which agent targets should KTX install?', }), ); }); @@ -926,21 +946,21 @@ describe('setup agents', () => { ); const output = io.stdout(); - expect(output).toContain('Agent integration complete'); - expect(output).toContain(`KTX project\n ${tempDir}`); - expect(output).toContain('Installed agents'); - expect(output).toContain('Claude Code'); - expect(output).toContain(`Project scope\n ${join(tempDir, '.mcp.json')}`); - expect(output).toContain('Requires MCP to be started'); - expect(output).toContain('Analytics skill installed'); - expect(output).toContain('Admin CLI skill installed'); + expect(output).toContain('Claude Code · Project scope'); + expect(output).toContain(join(tempDir, '.mcp.json')); + expect(output).toContain('Requires MCP to be started.'); + expect(output).toContain('Analytics skill installed.'); + expect(output).toContain('Admin CLI skill installed.'); + expect(output).not.toContain('Agent integration complete'); + expect(output).not.toContain(`KTX project\n ${tempDir}`); + expect(output).not.toContain('Installed agents'); expect(output).not.toContain('.claude/skills/ktx-analytics/SKILL.md'); expect(output).not.toContain('.claude/skills/ktx/SKILL.md'); expect(output).not.toContain('.claude/rules/ktx.md'); }); it('formats summary with explicit project-scoped config paths', () => { - const summary = formatInstallSummary( + const summary = formatInstallSummaryLines( [{ target: 'cursor', scope: 'project', mode: 'mcp-cli' }], [ { kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' }, @@ -950,16 +970,20 @@ describe('setup agents', () => { tempDir, ); - expect(summary).toContain('Cursor'); - expect(summary).toContain(`Project scope\n ${join(tempDir, '.cursor/mcp.json')}`); - expect(summary).toContain('Requires MCP to be started'); - expect(summary).toContain('Cursor rules installed'); - expect(summary).not.toContain('.cursor/rules/ktx-analytics.mdc'); - expect(summary).not.toContain('.cursor/rules/ktx.mdc'); + expect(summary).toEqual([ + { + title: 'Cursor · Project scope', + lines: [ + join(tempDir, '.cursor/mcp.json'), + 'Requires MCP to be started.', + 'Cursor rules installed.', + ], + }, + ]); }); it('formats summary with multiple agent targets', () => { - const summary = formatInstallSummary( + const summary = formatInstallSummaryLines( [ { target: 'claude-code', scope: 'project', mode: 'mcp-cli' }, { target: 'codex', scope: 'project', mode: 'mcp-cli' }, @@ -976,16 +1000,25 @@ describe('setup agents', () => { tempDir, ); - expect(summary).toContain('Claude Code'); - expect(summary).toContain('Project scope\n '); - expect(summary).toContain('Analytics skill installed'); - expect(summary).toContain('Admin CLI skill installed'); - expect(summary).toContain('\n\n Codex\n'); - expect(summary).toContain('MCP config\n Add the snippet shown below to ~/.codex/config.toml.'); - expect(summary).toContain('Codex'); - expect(summary).toContain('Codex guidance installed'); - expect(summary).not.toContain('.agents/skills/ktx-analytics/SKILL.md'); - expect(summary).not.toContain('.agents/skills/ktx/SKILL.md'); + expect(summary).toEqual([ + { + title: 'Claude Code · Project scope', + lines: [ + join(tempDir, '.mcp.json'), + 'Requires MCP to be started.', + 'Analytics skill installed.', + 'Admin CLI skill installed.', + ], + }, + { + title: 'Codex · Project scope', + lines: [ + 'Add the snippet shown below to ~/.codex/config.toml.', + 'Requires MCP to be started.', + 'Codex guidance installed.', + ], + }, + ]); }); it('prints one target-aware next actions block for mixed agent targets', async () => { @@ -1038,8 +1071,10 @@ describe('setup agents', () => { 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(join(tempDir, '.ktx/agents/claude/ktx-analytics.zip')); + expect(output).not.toContain(join(tempDir, '.ktx/agents/claude/ktx.zip')); expect(output).toContain('Upload this file:'); + expect(output).toContain('All set.'); expect(output).not.toContain('Finish Claude Desktop setup'); expect(output).not.toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); } finally { diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 88319808..b7389211 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,7 +1,9 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { log, outro } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStateStepComplete, @@ -9,7 +11,6 @@ import { } from '@ktx/context/project'; import { strToU8, zipSync } from 'fflate'; import type { KtxCliIo } from './cli-runtime.js'; -import { withMultiselectNavigation } from './prompt-navigation.js'; import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, @@ -81,6 +82,38 @@ interface KtxCliLauncher { args: string[]; } +function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable { + return ( + output.isTTY === true && + typeof (output as { on?: unknown }).on === 'function' && + typeof (output as { columns?: unknown }).columns !== 'undefined' + ); +} + +function writeSetupInfo(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.info(message, { output: io.stdout }); + return; + } + io.stdout.write(`${message}\n`); +} + +function writeSetupStep(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + log.step(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} + +function writeSetupOutro(io: KtxCliIo, message: string): void { + if (isWritableTtyOutput(io.stdout)) { + outro(message, { output: io.stdout }); + return; + } + io.stdout.write(`\n${message}\n`); +} + async function readJsonObject(path: string): Promise> { if (!existsSync(path)) return {}; const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown; @@ -314,8 +347,12 @@ export function agentInstallManifestPath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/install-manifest.json'); } -function claudeDesktopSkillBundlePath(projectDir: string): string { - return join(resolve(projectDir), '.ktx/agents/claude/ktx-skills.zip'); +function claudeDesktopAnalyticsSkillBundlePath(projectDir: string): string { + return join(resolve(projectDir), '.ktx/agents/claude/ktx-analytics.zip'); +} + +function claudeDesktopAdminSkillBundlePath(projectDir: string): string { + return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip'); } function claudeDesktopLauncherPath(projectDir: string): string { @@ -363,9 +400,18 @@ export function plannedKtxAgentFiles(input: { { kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const }, { kind: 'file', - path: claudeDesktopSkillBundlePath(input.projectDir), + path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir), role: 'claude-desktop-skill-bundle' as const, }, + ...(withAdminCli + ? [ + { + kind: 'file' as const, + path: claudeDesktopAdminSkillBundlePath(input.projectDir), + role: 'claude-desktop-skill-bundle' as const, + }, + ] + : []), ]; } throw new Error(`Global ${input.target} installation is not supported; omit --global.`); @@ -553,21 +599,30 @@ function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): stri async function writeClaudeDesktopSkillBundle(input: { projectDir: string; path: string; - mode: KtxAgentInstallMode; + skillName: 'ktx-analytics' | 'ktx'; launcher: KtxCliLauncher; }): Promise { + const content = + input.skillName === 'ktx-analytics' + ? await readAnalyticsSkillContent() + : cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }); const files: Record = { - 'ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()), + [`${input.skillName}/SKILL.md`]: strToU8(content), }; - if (input.mode === 'mcp-cli') { - files['ktx/SKILL.md'] = strToU8( - cliInstructionContent({ projectDir: input.projectDir, launcher: input.launcher }), - ); - } await mkdir(dirname(input.path), { recursive: true }); await writeFile(input.path, Buffer.from(zipSync(files))); } +function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx' { + if (path.endsWith('/ktx-analytics.zip')) { + return 'ktx-analytics'; + } + if (path.endsWith('/ktx.zip')) { + return 'ktx'; + } + throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`); +} + async function writeClaudeDesktopLauncher(input: { path: string; launcher: KtxCliLauncher; @@ -747,12 +802,27 @@ function hasAdminCliEntries(entries: InstallEntry[]): boolean { ); } -export function formatInstallSummary( +export interface InstallSummaryEntry { + title: string; + lines: string[]; +} + +function formatInlinePath(path: string): string { + const home = process.env.HOME; + if (!home) return path; + const resolvedHome = resolve(home); + if (path === resolvedHome) return '~'; + if (path.startsWith(`${resolvedHome}/`)) { + return `~/${relative(resolvedHome, path)}`; + } + return path; +} + +export function formatInstallSummaryLines( installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, entries: InstallEntry[], projectDir: string, -): string { - const resolvedProjectDir = resolve(projectDir); +): InstallSummaryEntry[] { const entriesByTarget = new Map(); for (const install of installs) { const plannedFilePaths = new Set( @@ -774,51 +844,57 @@ export function formatInstallSummary( ); } - const lines: string[] = ['KTX project', ` ${resolvedProjectDir}`, '', 'Installed agents']; - for (const install of installs) { + return installs.map((install) => { const targetEntries = entriesByTarget.get(install.target) ?? []; const mcpEntry = mcpEntriesByTarget .get(install.target) ?.find((entry): entry is Extract => entry.kind === 'json-key'); - lines.push('', ` ${targetDisplayName(install.target)}`); + const lines: string[] = []; + if (mcpEntry) { - lines.push(` ${scopeDisplayName(install.scope)}`); - lines.push(` ${mcpEntry.path}`); + lines.push(formatInlinePath(mcpEntry.path)); } else if (install.target !== 'claude-desktop') { - lines.push(' MCP config'); - lines.push(` ${manualMcpConfigInstruction(install.target, install.scope)}`); + lines.push(manualMcpConfigInstruction(install.target, install.scope)); } + if (targetUsesHttpMcpDaemon(install.target)) { - lines.push(' Requires MCP to be started'); + lines.push('Requires MCP to be started.'); } + const hasAnalytics = hasEntryRole(targetEntries, 'analytics-skill'); const hasAdmin = hasAdminCliEntries(targetEntries); const claudeDesktopSkillBundles = targetEntries.filter( (entry): entry is Extract => entry.kind === 'file' && entry.role === 'claude-desktop-skill-bundle', ); + if (install.target === 'claude-code') { if (hasAnalytics) { - lines.push(' Analytics skill installed'); + lines.push('Analytics skill installed.'); } if (hasAdmin) { - lines.push(' Admin CLI skill installed'); + lines.push('Admin CLI skill installed.'); } } else if (install.target === 'claude-desktop') { if (claudeDesktopSkillBundles.length > 0) { - lines.push(' Claude Desktop skill uploads'); + lines.push('Skill bundles:'); for (const bundle of claudeDesktopSkillBundles) { - lines.push(` ${bundle.path}`); + lines.push(` ${bundle.path}`); } } } else if (hasAnalytics || hasAdmin) { - lines.push(` ${guidanceInstallLine(install.target)}`); + lines.push(`${guidanceInstallLine(install.target)}.`); } + if (hasEntryRole(targetEntries, 'launcher')) { - lines.push(' Starts KTX over stdio from Claude Desktop'); + lines.push('Starts KTX over stdio from Claude Desktop.'); } - } - return lines.join('\n'); + + return { + title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, + lines, + }; + }); } function claudeDesktopSkillBundlePathsForInstalls( @@ -980,7 +1056,7 @@ function formatAgentNextActions(input: { 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:'); + lines.push(skillBundlePaths.length === 1 ? ' Upload this file:' : ' Upload each file separately:'); for (const path of skillBundlePaths) { lines.push(` ${path}`); } @@ -1016,7 +1092,7 @@ async function installTarget(input: { await writeClaudeDesktopSkillBundle({ projectDir: input.projectDir, path: entry.path, - mode: input.mode, + skillName: claudeDesktopSkillNameForBundle(entry.path), launcher, }); continue; @@ -1053,6 +1129,9 @@ export async function runKtxSetupAgentsStep( } const prompts = deps.prompts ?? createPromptAdapter(); + if (args.inputMode === 'auto' && args.target === undefined) { + writeSetupInfo(io, 'Space to select, Enter to confirm, Esc to go back.'); + } const mode = args.inputMode === 'disabled' ? args.mode @@ -1079,7 +1158,7 @@ export async function runKtxSetupAgentsStep( : args.inputMode === 'disabled' ? [] : ((await prompts.multiselect({ - message: withMultiselectNavigation('Which agent targets should KTX install?'), + message: 'Which agent targets should KTX install?', options: [ { value: 'claude-code', label: 'Claude Code' }, { value: 'claude-desktop', label: 'Claude Desktop' }, @@ -1143,11 +1222,12 @@ export async function runKtxSetupAgentsStep( ); await markAgentsComplete(args.projectDir); const setupUi = createKtxSetupUiAdapter(); - setupUi.note( - formatInstallSummary(installs, entries, args.projectDir), - 'Agent integration complete', - io, - ); + for (const summary of formatInstallSummaryLines(installs, entries, args.projectDir)) { + writeSetupStep( + io, + summary.lines.length > 0 ? `${summary.title}\n${summary.lines.join('\n')}` : summary.title, + ); + } const nextActions = formatAgentNextActions({ projectDir: args.projectDir, installs, @@ -1156,6 +1236,7 @@ export async function runKtxSetupAgentsStep( }); if (args.showNextActions !== false) { setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line }); + writeSetupOutro(io, 'All set.'); } return { status: 'ready', projectDir: args.projectDir, installs, nextActions }; } catch (error) {