diff --git a/packages/kb/README.md b/packages/kb/README.md index 179cc112..ad8439be 100644 --- a/packages/kb/README.md +++ b/packages/kb/README.md @@ -211,6 +211,8 @@ The schema and config seeds are serialized from the in-package `defaultSchema` a The name defaults to the directory's base name; `--name` overrides it and `--no-register` scaffolds without writing the registry. The registry write preserves any existing comments in `kb.yaml`. `kb create` refuses to clobber: it exits 2 if the directory already contains a `.kb/` store, or if the chosen name is already registered. +`kb create` also keeps a default knowledge base set. When the registry's top-level `default_kb` pointer is unset and the new store is the only registered KB, it becomes the default. When other KBs are already registered with no default, `kb create` prompts you to choose one on an interactive terminal — or, when stdin is not interactive, points you to `kb set-default`. An existing `default_kb` is never overwritten. + ### kb set-default `kb set-default` sets, clears, or interactively chooses the user-global default knowledge base: the top-level `default_kb` pointer in `~/.agents/kb.yaml`. diff --git a/packages/kb/src/cli/__tests__/create.test.ts b/packages/kb/src/cli/__tests__/create.test.ts index 1a18da74..0b8c531d 100644 --- a/packages/kb/src/cli/__tests__/create.test.ts +++ b/packages/kb/src/cli/__tests__/create.test.ts @@ -7,6 +7,7 @@ import { loadKbRegistry } from '../../discovery/load-registry.ts'; import { pathExists } from '../../filesystem/exists.ts'; import { getRegistryPathFor, makeTempDir, seedRegistry } from '../../test-utils/scaffolding.ts'; import { run } from '../run.ts'; +import type { SelectKbChoice, SelectKbPrompt } from '../select-kb-prompt.ts'; describe('kb create', () => { it('scaffolds a store in cwd and registers it under the directory name', async () => { @@ -91,4 +92,103 @@ describe('kb create', () => { expect(result.stdout).toContain('create'); }); + + it('sets the new store as the default when the registry is empty', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + + const result = await run({ argv: ['create'], cwd, home }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Set as the default knowledge base.'); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb?.name).toBe(basename(cwd)); + }); + + it('prompts to choose the default when other KBs exist and none is set', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'kbs:\n existing:\n path: /abs/existing\n'); + + const result = await run({ argv: ['create'], cwd, home, selectKb: stubPrompt({ kind: 'kb', index: 0 }) }); + + expect(result.exitCode).toBe(0); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb?.name).toBe('existing'); + }); + + it('offers the newly-created store among the picker choices', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'kbs:\n existing:\n path: /abs/existing\n'); + + // The new store is appended after the seeded one, so index 1 selects it. + const result = await run({ argv: ['create'], cwd, home, selectKb: stubPrompt({ kind: 'kb', index: 1 }) }); + + expect(result.exitCode).toBe(0); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb?.name).toBe(basename(cwd)); + }); + + it('exits 0 once the store is created even when the delegated selection fails', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'kbs:\n existing:\n path: /abs/existing\n'); + + const result = await run({ argv: ['create'], cwd, home, selectKb: stubPrompt({ kind: 'kb', index: 99 }) }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('invalid selection'); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.entries.some((entry) => entry.name === basename(cwd))).toBe(true); + expect(config.defaultKb).toBeUndefined(); + }); + + it('leaves the default unset and points to set-default when other KBs exist and stdin is non-interactive', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'kbs:\n existing:\n path: /abs/existing\n'); + + const result = await run({ argv: ['create'], cwd, home }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('kb set-default'); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb).toBeUndefined(); + }); + + it('still succeeds with no default when the picker is cancelled', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'kbs:\n existing:\n path: /abs/existing\n'); + + const result = await run({ argv: ['create'], cwd, home, selectKb: stubPrompt({ kind: 'cancel' }) }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No changes made.'); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb).toBeUndefined(); + }); + + it('leaves an existing default unchanged and announces no default', async () => { + const cwd = await makeTempDir('kb-cli-store-'); + const home = await makeTempDir('kb-cli-home-'); + await seedRegistry(getRegistryPathFor(home), 'default_kb: existing\nkbs:\n existing:\n path: /abs/existing\n'); + + const result = await run({ argv: ['create'], cwd, home }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Set as the default knowledge base.'); + const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) }); + expect(config.defaultKb?.name).toBe('existing'); + }); }); + +// region | Helpers + +/** A stub picker that resolves to a fixed choice, standing in for the interactive default-KB prompt. */ +function stubPrompt(choice: SelectKbChoice): SelectKbPrompt { + return () => Promise.resolve(choice); +} + +// endregion | Helpers diff --git a/packages/kb/src/cli/commands/create.ts b/packages/kb/src/cli/commands/create.ts index 48858b90..7516b68d 100644 --- a/packages/kb/src/cli/commands/create.ts +++ b/packages/kb/src/cli/commands/create.ts @@ -2,13 +2,17 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { create, type CreatedStore } from '../../create/create.ts'; +import type { SelectKbPrompt } from '../select-kb-prompt.ts'; import type { CommandOutput } from './check.ts'; +import { runSetDefault } from './set-default.ts'; /** Usage text for `kb create`. */ export const CREATE_HELP = `Usage: kb create [options] -Scaffold a new knowledge base in the current directory and register it in the -user-global kb.yaml registry. +Scaffold a new knowledge base in the current directory and register it in the user-global kb.yaml registry. + +When the registry has no default knowledge base, the new store becomes the default. +If other knowledge bases are already registered, you are prompted to choose one (or set it later with "kb set-default"). Creates: .kb/schema.yaml record-type schema (a copy of the bundled default) @@ -28,13 +32,17 @@ Exit codes: /** * Runs `kb create`: parses options, scaffolds a store in `cwd`, and (unless `--no-register`) registers it in the - * user-global `~/.agents/kb.yaml`. A precondition failure from `create` — an existing `.kb/` or an already-registered - * name — maps to exit 2; a genuine I/O error propagates to the caller. + * user-global `~/.agents/kb.yaml`. After registering, it ensures a default knowledge base: the new store becomes the + * default when none is set and it is the only KB; when other KBs already exist with no default, it delegates to + * `kb set-default`'s picker (or, with no `selectKb` on a non-interactive stdin, points the user there). A precondition + * failure from `create` — an existing `.kb/` or an already-registered name — maps to exit 2; a genuine I/O error + * propagates to the caller. */ export async function runCreate(input: { argv: readonly string[]; cwd: string; home?: string; + selectKb?: SelectKbPrompt; }): Promise { let options: CreateOptions; try { @@ -56,7 +64,23 @@ export async function runCreate(input: { if (!outcome.ok) { return { exitCode: 2, stdout: '', stderr: `kb create: ${outcome.message}\n` }; } - return { exitCode: 0, stdout: formatCreated(outcome.created, registryPath), stderr: '' }; + + const summary = formatCreated(outcome.created, registryPath); + if (outcome.created.defaultKb !== 'needs-selection') { + return { exitCode: 0, stdout: summary, stderr: '' }; + } + + // No default is set and other KBs already exist: let the user pick one, reusing `kb set-default`'s interactive form. + if (input.selectKb === undefined) { + return { exitCode: 0, stdout: summary + UNSET_DEFAULT_HINT, stderr: '' }; + } + const selection = await runSetDefault({ + argv: [], + ...(input.home !== undefined && { home: input.home }), + selectKb: input.selectKb, + }); + // The exit code reflects the store creation that already succeeded, not the follow-on default selection. + return { exitCode: 0, stdout: summary + selection.stdout, stderr: selection.stderr }; } /** Parsed `kb create` options. */ @@ -116,19 +140,26 @@ export function parseCreateArgs(argv: readonly string[]): CreateOptions { // region | Helpers +// Guidance shown when a created store leaves the registry with multiple KBs and no default, with no picker available. +const UNSET_DEFAULT_HINT = + 'Multiple knowledge bases are registered and no default is set. Run `kb set-default` to choose one.\n'; + /** Builds a usage-error `CommandOutput` (exit 2) from a thrown parse error. */ function buildUsageError(error: unknown): CommandOutput { const message = error instanceof Error ? error.message : String(error); return { exitCode: 2, stdout: '', stderr: `kb create: ${message}\n${CREATE_HELP}` }; } -/** Builds the human summary of a created store. */ +/** Builds a human summary of a created store. If the new store became the default, reports that in the message. */ function formatCreated(created: CreatedStore, registryPath: string): string { const lines = [`Created knowledge base "${created.name}" at ${created.storePath}`]; for (const path of created.created) { lines.push(` ${path}`); } lines.push(created.registered ? `Registered in ${registryPath}` : 'Not registered (--no-register).'); + if (created.defaultKb === 'set') { + lines.push('Set as the default knowledge base.'); + } return `${lines.join('\n')}\n`; } diff --git a/packages/kb/src/cli/run.ts b/packages/kb/src/cli/run.ts index bcba9195..58fa61e8 100644 --- a/packages/kb/src/cli/run.ts +++ b/packages/kb/src/cli/run.ts @@ -18,7 +18,8 @@ Run "kb --help" for command options. * Dispatches a `kb` subcommand and returns its {@link CommandOutput} without touching `process`, so tests drive the * command directly. `check`, `create`, and `set-default` are the subcommands; a bare invocation or `--help`/`-h` prints * top-level usage (exit 0), and an unknown command prints usage to stderr (exit 2). The optional `selectKb` picker is - * forwarded to `set-default`'s interactive form; `cli/index.ts` supplies it only when stdin is a TTY. + * forwarded to `set-default`'s interactive form and to `create`'s ambiguous default-KB prompt; `cli/index.ts` supplies + * it only when stdin is a TTY. */ export async function run(input: { argv: readonly string[]; @@ -37,7 +38,12 @@ export async function run(input: { } if (command === 'create') { - return runCreate({ argv: rest, cwd: input.cwd, ...(input.home !== undefined && { home: input.home }) }); + return runCreate({ + argv: rest, + cwd: input.cwd, + ...(input.home !== undefined && { home: input.home }), + ...(input.selectKb !== undefined && { selectKb: input.selectKb }), + }); } if (command === 'set-default') { diff --git a/packages/kb/src/create/__tests__/create.test.ts b/packages/kb/src/create/__tests__/create.test.ts index 477aaf4a..04a191b8 100644 --- a/packages/kb/src/create/__tests__/create.test.ts +++ b/packages/kb/src/create/__tests__/create.test.ts @@ -92,4 +92,51 @@ describe(create, () => { expect(outcome.reason).toBe('kb-exists'); expect(await pathExists(join(targetDir, 'content'))).toBe(false); }); + + it('sets the new store as the default when the registry has no default and no other KBs', async () => { + const targetDir = await makeTempDir('kb-create-store-'); + const registryPath = await makeRegistryPath(); + + const outcome = await create({ targetDir, register: true, registryPath }); + + assert.ok(outcome.ok); + expect(outcome.created.defaultKb).toBe('set'); + const config = await loadKbRegistry({ userConfigPath: registryPath }); + expect(config.defaultKb?.name).toBe(basename(targetDir)); + }); + + it('leaves an existing default_kb unchanged', async () => { + const targetDir = await makeTempDir('kb-create-store-'); + const registryPath = await makeRegistryPath(); + await seedRegistry(registryPath, 'default_kb: existing\nkbs:\n existing:\n path: /abs/existing\n'); + + const outcome = await create({ targetDir, register: true, registryPath }); + + assert.ok(outcome.ok); + expect(outcome.created.defaultKb).toBe('unchanged'); + const config = await loadKbRegistry({ userConfigPath: registryPath }); + expect(config.defaultKb?.name).toBe('existing'); + }); + + it('defers selection without setting a default when other KBs exist and none is set', async () => { + const targetDir = await makeTempDir('kb-create-store-'); + const registryPath = await makeRegistryPath(); + await seedRegistry(registryPath, 'kbs:\n existing:\n path: /abs/existing\n'); + + const outcome = await create({ targetDir, register: true, registryPath }); + + assert.ok(outcome.ok); + expect(outcome.created.defaultKb).toBe('needs-selection'); + const config = await loadKbRegistry({ userConfigPath: registryPath }); + expect(config.defaultKb).toBeUndefined(); + }); + + it('omits the default-KB outcome when not registering', async () => { + const targetDir = await makeTempDir('kb-create-store-'); + + const outcome = await create({ targetDir, register: false }); + + assert.ok(outcome.ok); + expect(outcome.created.defaultKb).toBeUndefined(); + }); }); diff --git a/packages/kb/src/create/create.ts b/packages/kb/src/create/create.ts index e8287d3f..c50e351c 100644 --- a/packages/kb/src/create/create.ts +++ b/packages/kb/src/create/create.ts @@ -3,9 +3,19 @@ import { basename, join, resolve } from 'node:path'; import { loadKbRegistry } from '../discovery/load-registry.ts'; import { registerStore } from '../discovery/register-store.ts'; +import { setDefaultKb } from '../discovery/set-default-kb.ts'; import { pathExists } from '../filesystem/exists.ts'; +import type { KbRegistry } from '../types.ts'; import { renderAliasesSeed, renderConfigSeed, renderSchemaSeed } from './render-seeds.ts'; +/** + * What `create` did about the registry's `default_kb` pointer when registering a store: + * `set` — it was unset and the new store was the only KB, so the store became the default; + * `unchanged` — a default was already set and left untouched; + * `needs-selection` — it was unset but other KBs exist, so the caller should prompt for a choice. + */ +export type DefaultKbOutcome = 'set' | 'unchanged' | 'needs-selection'; + /** A successfully created store and a record of what was written. */ export interface CreatedStore { /** The store's registry name (the directory's base name unless overridden). */ @@ -16,6 +26,8 @@ export interface CreatedStore { registered: boolean; /** Store-relative paths created by the scaffold. */ created: readonly string[]; + /** What happened to the registry's `default_kb` pointer; absent when the store was not registered. */ + defaultKb?: DefaultKbOutcome; } /** Inputs for {@link create}. `registryPath` is required only when registering. */ @@ -43,40 +55,57 @@ export async function create(input: CreateInput): Promise { return { ok: false, reason: 'kb-exists', message: `a ${KB_DIR}/ store already exists at ${storePath}` }; } - if (input.register && (await isNameRegistered(input.registryPath, name))) { - return { - ok: false, - reason: 'name-registered', - message: `a store named "${name}" is already registered in ${input.registryPath}`, - }; + if (!input.register) { + const created = await scaffold(storePath); + return { ok: true, created: { name, storePath, registered: false, created } }; + } + + const { registryPath } = input; + // Capture the pre-register registry: it drives both the name-collision check and the default-KB decision. + const before = await loadKbRegistry({ userConfigPath: registryPath }); + if (before.entries.some((entry) => entry.name === name)) { + return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) }; } const created = await scaffold(storePath); - let registered = false; - if (input.register) { - const result = await registerStore({ registryPath: input.registryPath, name, storePath }); - if (result.status === 'already-present') { - return { - ok: false, - reason: 'name-registered', - message: `a store named "${name}" is already registered in ${input.registryPath}`, - }; - } - registered = true; + const result = await registerStore({ registryPath, name, storePath }); + if (result.status === 'already-present') { + return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) }; } - return { ok: true, created: { name, storePath, registered, created } }; + const defaultKb = await ensureDefaultKb({ registryPath, name, before }); + return { ok: true, created: { name, storePath, registered: true, created, defaultKb } }; } // region | Helpers const KB_DIR = '.kb'; -/** Reports whether a store of the given name already exists in the registry at `registryPath`. */ -async function isNameRegistered(registryPath: string, name: string): Promise { - const { entries } = await loadKbRegistry({ userConfigPath: registryPath }); - return entries.some((entry) => entry.name === name); +/** + * Decides — and, for the sole-KB case, applies — what happens to `default_kb` for a freshly-registered store, given + * the registry state captured before registering. Sets the new store as the default only when no default exists and + * it is the only registered KB; an existing default is never overwritten, and an ambiguous case is deferred to the + * caller for an interactive choice. + */ +async function ensureDefaultKb(input: { + registryPath: string; + name: string; + before: KbRegistry; +}): Promise { + if (input.before.defaultKb !== undefined) { + return 'unchanged'; + } + if (input.before.entries.length > 0) { + return 'needs-selection'; + } + await setDefaultKb({ registryPath: input.registryPath, name: input.name }); + return 'set'; +} + +/** Builds the "name already registered" precondition-failure message. */ +function nameRegisteredMessage(name: string, registryPath: string): string { + return `a store named "${name}" is already registered in ${registryPath}`; } /** Writes the `.kb/` seed files and the content directories, returning the store-relative paths created. */