ktx/packages/cli/src/setup-agents.test.ts
Andrey Avtomonov 2366b00301
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm

* refactor(workspace): rewrite @ktx/llm imports to relative paths

* refactor(workspace): fold internal packages into cli

* chore(workspace): gate dead-code with knip production mode

Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.

* refactor(cli): delete internal barrel index.ts files

The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).

This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
  (the published package entry).
- Rewrites ~270 source/test files to import each name directly from
  the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
  `create-warehouse-verification-tools.ts` (the function it defined
  locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
  the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
  live-database/extracted-schema, live-database/structural-sync,
  relationship-* feedback/review chain) plus their tests and a
  cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
  (notion-client, connector barrels in scan/local-scan-connectors
  tests) to mock the source files instead.
- Points the maintainer benchmark script
  (`scripts/relationship-benchmark-report.mjs`) at source files
  instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
  production entries only for the benchmark code reached via dist by
  the maintainer script.

Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.

`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.

* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly

Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.

Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.

* docs: align "agent clients" and "data agents" terminology

Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.

Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.

* refactor(release): single source of truth for package version

Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.

Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.

- update-public-release-version.mjs rewrites both Python pyproject.toml
  files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
  normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
  @semantic-release/git assets so the release commit back to main
  carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
  replaced with "?? getKtxCliPackageInfo().version", and
  createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
  always reflects the most recent release; no sentinel pin to
  maintain.

Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.

* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime

Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.

* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal

Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.

* fix(cli): use real package metadata in print-command-tree

The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.

* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts

Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.

Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00

1284 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readKtxSetupState } from './context/project/setup-config.js';
import { strFromU8, unzipSync } from 'fflate';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createAgentNextActionsLineFormatter,
formatInstallSummaryLines,
plannedKtxAgentFiles,
readKtxAgentInstallManifest,
removeKtxAgentInstall,
runKtxSetupAgentsStep,
} from './setup-agents.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: { write: (chunk: string) => (stdout += chunk) },
stderr: { write: (chunk: string) => (stderr += chunk) },
},
stdout: () => stdout,
stderr: () => stderr,
};
}
async function readZipText(path: string, entry: string): Promise<string> {
const archive = unzipSync(new Uint8Array(await readFile(path)));
const content = archive[entry];
if (!content) throw new Error(`Missing zip entry: ${entry}`);
return strFromU8(content);
}
function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) snapshot[key] = env[key];
return snapshot;
}
function clearEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): void {
for (const key of keys) delete env[key];
}
function captureKtxEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) snapshot[key] = env[key];
}
return snapshot;
}
function clearKtxEnv(env: NodeJS.ProcessEnv): void {
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) delete env[key];
}
}
function restoreEnvKeys(env: NodeJS.ProcessEnv, snapshot: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) delete env[key];
else env[key] = value;
}
}
describe('setup agents', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-'));
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('plans project-scoped MCP analytics files for every target', () => {
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
]);
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-analytics.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' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
]);
});
it('plans project-scoped admin CLI files for every target when requested', () => {
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp-cli' })).toEqual([
{ 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' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp-cli' })).toEqual([
{ 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' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'cursor', scope: 'project', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx-analytics.mdc'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.cursor/rules/ktx.mdc') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'opencode', scope: 'project', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx-analytics.md'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.opencode/commands/ktx.md') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'universal', scope: 'project', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
]);
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-analytics.zip'),
role: 'claude-desktop-skill-bundle',
},
{
kind: 'file',
path: join(tempDir, '.ktx/agents/claude/ktx.zip'),
role: 'claude-desktop-skill-bundle',
},
]);
});
it('installs target files, writes a manifest, and marks agents complete', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'universal',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
projectDir: tempDir,
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
});
await expect(stat(join(tempDir, '.agents/skills/ktx/SKILL.md'))).resolves.toBeDefined();
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
expect(skill).toContain(`--project-dir ${tempDir}`);
expect(skill).toContain('must not print secrets');
expect(skill).toContain('status --json');
expect(skill).toContain('sl --json');
expect(skill).toContain('sl query');
expect(skill).toContain('--format json');
expect(skill).not.toContain('sl query --json');
expect(skill).not.toContain('agent ');
expect(skill).not.toContain('sql execute');
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
version: 1,
projectDir: tempDir,
installs: [{ target: 'universal', scope: 'project', mode: 'mcp-cli' }],
});
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
expect(io.stderr()).toBe('');
});
it('installs a specified target in non-interactive mode without --yes', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: false,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
projectDir: tempDir,
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
});
await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
const mcpConfig = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
mcpServers?: Record<string, unknown>;
};
expect(mcpConfig.mcpServers).toHaveProperty('ktx');
expect(io.stderr()).toBe('');
});
it('prints concrete target guidance when non-interactive agent setup has no target', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toEqual({ status: 'missing-input', projectDir: tempDir });
expect(io.stderr()).toBe('Run in a TTY, or pass --target <target>.\n');
});
it('prints standalone agent next actions after successful installation', async () => {
const io = makeIo();
const result = await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
expect(result).toMatchObject({
status: 'ready',
nextActions: expect.stringContaining('Run this command before using Claude Code:'),
});
expect(io.stdout()).toContain('Required before using agents');
expect(io.stdout()).toContain('Run this command before using Claude Code:');
expect(io.stdout()).toContain('RUN:');
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');
});
it('can return agent next actions without printing them', async () => {
const io = makeIo();
const result = await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
showNextActions: false,
},
io.io,
);
expect(result).toMatchObject({
status: 'ready',
nextActions: expect.stringContaining(`ktx mcp start --project-dir ${tempDir}`),
});
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 () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'universal',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({ status: 'ready' });
const analyticsSkill = await readFile(join(tempDir, '.agents/skills/ktx-analytics/SKILL.md'), 'utf-8');
expect(analyticsSkill).toContain('name: ktx-analytics');
expect(analyticsSkill).toContain('Always run `discover_data` before writing SQL.');
expect(analyticsSkill).toContain('Treat a `dictionary_search` miss as non-authoritative.');
expect(analyticsSkill).toContain('memory_ingest');
expect(analyticsSkill).toContain('ARR is reported in cents');
expect(analyticsSkill).not.toContain(`memory_${'capture'}`);
});
it('writes PATH-independent launcher commands for skills', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'universal',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({ status: 'ready' });
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
expect(skill).not.toContain('`ktx agent');
expect(skill).toContain('status --json');
expect(skill).toContain('sl query');
expect(skill).toContain('--format json');
expect(skill).not.toContain('sl query --json');
expect(skill).not.toContain('sql execute');
});
it('writes Claude Code project MCP config and tracks the json key', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({ status: 'ready' });
const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
mcpServers: { ktx: { type: string; url: string; headers?: Record<string, string> } };
};
expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
entries: expect.arrayContaining([{ kind: 'json-key', path: join(tempDir, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }]),
});
});
it('prompts for MCP-first agent client connection mode in interactive setup', async () => {
const io = makeIo();
const prompts = {
select: vi.fn(async ({ message }: { message: string }) => (message.startsWith('Where') ? 'project' : 'mcp')),
multiselect: vi.fn(async () => ['claude-code']),
cancel: vi.fn(),
};
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'auto',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
{ prompts },
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
});
expect(prompts.select).toHaveBeenCalledWith({
message: 'What should agents be allowed to do with this KTX project?',
options: [
{
value: 'mcp',
label: 'Ask data questions with KTX MCP',
hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.',
},
{
value: 'mcp-cli',
label: 'Ask data questions + manage KTX with CLI commands',
hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.',
},
],
});
expect(prompts.multiselect).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([{ value: 'claude-desktop', label: 'Claude Desktop' }]),
}),
);
});
it('prompts for global scope when every selected target supports it', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
const prompts = {
select: vi.fn(async ({ message }: { message: string }) =>
message.startsWith('Where should') ? 'global' : 'mcp',
),
multiselect: vi.fn(async () => ['claude-code']),
cancel: vi.fn(),
};
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'auto',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
{ prompts },
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-code', scope: 'global', mode: 'mcp' }],
});
expect(prompts.select).toHaveBeenCalledWith({
message: `Where should KTX install supported agent config?\n\nKTX project: ${tempDir}`,
options: [
{
value: 'project',
label: 'Project scope (KTX project directory)',
hint: 'Only agents opened from this KTX project path load the project-scoped config.',
},
{
value: 'global',
label: 'Global scope (user config)',
hint: 'Agents can load this KTX project from any working directory.',
},
],
});
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
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']);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
clearKtxEnv(process.env);
try {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp' }],
});
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(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');
expect(launcher).toContain('KTX_CLI_BIN=');
expect(launcher).toContain('.nvm/versions/node');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
};
expect(config.mcpServers.ktx).toEqual({
command: launcherPath,
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
});
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(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');
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;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
it('captures KTX_*, OPENAI_API_KEY, and ANTHROPIC_API_KEY into the Claude Desktop MCP env block', 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',
'KTX_LOG_LEVEL',
]);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearKtxEnv(process.env);
process.env.OPENAI_API_KEY = 'sk-test-openai'; // pragma: allowlist secret
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; // pragma: allowlist secret
process.env.KTX_LOG_LEVEL = 'debug';
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
);
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { env?: Record<string, string> } };
};
expect(config.mcpServers.ktx.env).toEqual({
OPENAI_API_KEY: 'sk-test-openai', // pragma: allowlist secret
ANTHROPIC_API_KEY: 'sk-ant-test', // pragma: allowlist secret
KTX_LOG_LEVEL: 'debug',
});
} finally {
process.env.HOME = previousHome;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
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;
try {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
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(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 });
}
});
it('installs MCP client config and analytics skill without admin CLI files', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
});
const mcpJson = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
mcpServers: { ktx: { type: string; url: string } };
};
expect(mcpJson.mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
await expect(stat(join(tempDir, '.claude/skills/ktx/SKILL.md'))).rejects.toThrow();
await expect(stat(join(tempDir, '.claude/rules/ktx.md'))).rejects.toThrow();
});
it('writes Cursor project MCP config', async () => {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'cursor',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const cursorJson = JSON.parse(await readFile(join(tempDir, '.cursor/mcp.json'), 'utf-8')) as {
mcpServers: { ktx: { url: string; headers?: Record<string, string> } };
};
expect(cursorJson.mcpServers.ktx).toEqual({ url: 'http://localhost:7878/mcp' });
});
it('prints Codex, opencode, and universal snippets without mutating printed-only config files', async () => {
const codexIo = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'codex',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
codexIo.io,
);
expect(codexIo.stdout()).toContain('[mcp_servers.ktx]');
expect(codexIo.stdout()).toContain('url = "http://localhost:7878/mcp"');
expect(codexIo.stdout()).toContain('1. Configure Codex');
expect(codexIo.stdout()).toContain('Open ~/.codex/config.toml, then paste this block:');
expect(codexIo.stdout()).toContain('PASTE:');
const opencodeIo = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'opencode',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
opencodeIo.io,
);
expect(opencodeIo.stdout()).toContain('"mcp"');
expect(opencodeIo.stdout()).toContain('"type": "remote"');
expect(opencodeIo.stdout()).toContain('1. Configure OpenCode');
expect(opencodeIo.stdout()).toContain('Open opencode.json, then paste this block:');
await expect(readFile(join(tempDir, 'opencode.json'), 'utf-8')).rejects.toThrow();
const universalIo = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'universal',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
universalIo.io,
);
expect(universalIo.stdout()).toContain('Universal MCP endpoint:');
expect(universalIo.stdout()).toContain('http://localhost:7878/mcp');
expect(universalIo.stdout()).toContain('1. Configure unsupported MCP clients');
expect(universalIo.stdout()).toContain('Use this endpoint when setting up unsupported MCP clients:');
});
it('uses MCP daemon state for port and token metadata without rendering literal tokens', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await writeFile(
join(tempDir, '.ktx/mcp.json'),
`${JSON.stringify(
{
schemaVersion: 1,
pid: 999999,
host: '127.0.0.1',
port: 8787,
tokenAuth: true,
projectDir: tempDir,
startedAt: '2026-05-14T00:00:00.000Z',
logPath: join(tempDir, '.ktx/logs/mcp.log'),
},
null,
2,
)}\n`,
'utf-8',
);
const io = makeIo();
const previousToken = process.env.KTX_MCP_TOKEN;
process.env.KTX_MCP_TOKEN = 'secret-token';
try {
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const rendered = JSON.stringify(JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')));
expect(rendered).toContain('http://127.0.0.1:8787/mcp');
expect(rendered).toContain('Bearer ${KTX_MCP_TOKEN}');
expect(rendered).not.toContain('secret-token');
expect(io.stdout()).toContain('Run this command before using Claude Code:');
expect(io.stdout()).toContain('RUN:');
expect(io.stdout()).toContain(`ktx mcp start --project-dir ${tempDir}`);
} finally {
if (previousToken === undefined) {
delete process.env.KTX_MCP_TOKEN;
} else {
process.env.KTX_MCP_TOKEN = previousToken;
}
}
});
it('writes Claude Code local MCP config under the project key in ~/.claude.json', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'local',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const config = JSON.parse(await readFile(join(home, '.claude.json'), 'utf-8')) as {
projects: Record<string, { mcpServers: { ktx: { type: string; url: string } } }>;
};
expect(config.projects[tempDir].mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' });
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('removes only manifest-listed files', async () => {
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,
);
await writeFile(join(tempDir, '.claude/skills/ktx/keep.txt'), 'user file', 'utf-8');
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(stat(join(tempDir, '.claude/skills/ktx/SKILL.md'))).rejects.toThrow();
await expect(stat(join(tempDir, '.claude/rules/ktx.md'))).rejects.toThrow();
await expect(stat(join(tempDir, '.claude/skills/ktx/keep.txt'))).resolves.toBeDefined();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
});
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;
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
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(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<string, unknown>;
};
expect(beforeConfig.mcpServers.ktx).toBeDefined();
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
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<string, unknown>;
};
expect(afterConfig.mcpServers.ktx).toBeUndefined();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('treats cancel as skip in interactive mode', async () => {
const io = makeIo();
const prompts = {
select: vi.fn(async () => 'back'),
multiselect: vi.fn(async () => ['codex']),
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: 'skipped', projectDir: tempDir });
});
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?',
}),
);
});
it('prints per-agent install summary after successful installation', async () => {
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');
});
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.',
],
},
]);
});
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.',
],
},
]);
});
it('prints one target-aware next actions block for mixed agent targets', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
const prompts = {
select: vi.fn(async ({ message }: { message: string }) =>
message.startsWith('Where should') ? 'project' : 'mcp',
),
multiselect: vi.fn(async () => ['claude-code', 'claude-desktop']),
cancel: vi.fn(),
};
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'auto',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
{ prompts },
),
).resolves.toMatchObject({
status: 'ready',
installs: [
{ target: 'claude-code', scope: 'project', mode: 'mcp' },
{ target: 'claude-desktop', scope: 'global', mode: 'mcp' },
],
});
const output = io.stdout();
expect(output).toContain('Required before using agents');
expect(output).not.toContain('Next actions');
expect(output).toContain('1. Start MCP');
expect(output).toContain('Run this command before using Claude Code:');
expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
expect(output).toContain(`ktx mcp stop --project-dir ${tempDir}\n\n2. Open Claude Code`);
expect(output).toContain('Open Claude Code from the KTX project directory');
expect(output).toContain('RUN:');
expect(output).toContain(`cd '${tempDir}'`);
expect(output).toContain('3. Restart Claude Desktop');
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-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 {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('does not tell global Claude Code installs to open from the project directory', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-code',
scope: 'global',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-code', scope: 'global', mode: 'mcp' }],
});
const output = io.stdout();
expect(output).toContain('2. Open Claude Code');
expect(output).toContain('RUN:');
expect(output).toContain('claude');
expect(output).not.toContain('Open Claude Code from the KTX project directory');
expect(output).not.toContain(`cd '${tempDir}'`);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('explains next actions for Codex, Cursor, OpenCode, and universal MCP targets', async () => {
const io = makeIo();
const prompts = {
select: vi.fn(async () => 'mcp-cli'),
multiselect: vi.fn(async () => ['codex', 'cursor', 'opencode', 'universal']),
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.toMatchObject({
status: 'ready',
installs: [
{ target: 'codex', scope: 'project', mode: 'mcp-cli' },
{ target: 'cursor', scope: 'project', mode: 'mcp-cli' },
{ target: 'opencode', scope: 'project', mode: 'mcp-cli' },
{ target: 'universal', scope: 'project', mode: 'mcp-cli' },
],
});
const output = io.stdout();
expect(output).toContain('Required before using agents');
expect(output).toContain('1. Configure Codex');
expect(output).toContain('2. Configure OpenCode');
expect(output).toContain('3. Configure unsupported MCP clients');
expect(output).toContain('4. Start MCP');
expect(output).toContain('Run this command before using Codex, Cursor, OpenCode, and Universal .agents:');
expect(output).toContain('Open Cursor from the KTX project directory');
expect(output).toContain('Open ~/.codex/config.toml, then paste this block:\n\n PASTE:\n [mcp_servers.ktx]');
expect(output).toContain('Open opencode.json, then paste this block:');
expect(output).toContain('Use this endpoint when setting up unsupported MCP clients:');
expect(output).toContain('Codex guidance installed');
expect(output).toContain('Cursor rules installed');
expect(output).toContain('OpenCode commands installed');
expect(output).toContain('.agents guidance installed');
});
describe('createAgentNextActionsLineFormatter', () => {
function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
return { write: () => true, hasColors: () => true };
}
function makePlainStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
return { write: () => true, hasColors: () => false };
}
const ESC = String.fromCharCode(27);
it('returns the line untouched when the stream cannot render colors', () => {
const format = createAgentNextActionsLineFormatter(makePlainStdout());
expect(format('2. Upload Claude Desktop skills')).toBe('2. Upload Claude Desktop skills');
expect(format(' /tmp/ktx/.ktx/agents/claude/ktx.zip')).toBe(' /tmp/ktx/.ktx/agents/claude/ktx.zip');
});
it('styles step headings and aligns sub-prose under the title', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const heading = format('2. Upload Claude Desktop skills');
expect(heading).toContain(ESC);
expect(heading).toContain('2');
expect(heading).toContain('Upload Claude Desktop skills');
expect(heading).not.toMatch(/^2\. /);
const sub = format(' Toggle the uploaded KTX skills on.');
expect(sub).toMatch(/^ {3}/);
expect(sub).toContain('Toggle the uploaded KTX skills on.');
});
it('renders skill bundle .zip paths as bullets and shortens HOME to ~', () => {
const previousHome = process.env.HOME;
process.env.HOME = '/tmp/test-home';
try {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const line = format(' /tmp/test-home/.ktx/agents/claude/ktx-analytics.zip');
expect(line).toContain('•');
expect(line).toContain('~/.ktx/agents/claude/ktx-analytics.zip');
expect(line).not.toContain('/tmp/test-home/');
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
}
});
it('replaces breadcrumb separators with a typographic chevron', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const line = format(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.');
expect(line).toContain('');
expect(line).not.toContain(' > ');
});
it('leaves already-styled lines untouched to avoid double-wrapping', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const preStyled = `${ESC}[1m2. Already styled${ESC}[22m`;
expect(format(preStyled)).toBe(preStyled);
});
});
});