Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/kb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
100 changes: 100 additions & 0 deletions packages/kb/src/cli/__tests__/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
43 changes: 37 additions & 6 deletions packages/kb/src/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<CommandOutput> {
let options: CreateOptions;
try {
Expand All @@ -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. */
Expand Down Expand Up @@ -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`;
}

Expand Down
10 changes: 8 additions & 2 deletions packages/kb/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ Run "kb <command> --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[];
Expand All @@ -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') {
Expand Down
47 changes: 47 additions & 0 deletions packages/kb/src/create/__tests__/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
73 changes: 51 additions & 22 deletions packages/kb/src/create/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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. */
Expand Down Expand Up @@ -43,40 +55,57 @@ export async function create(input: CreateInput): Promise<CreateOutcome> {
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<boolean> {
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<DefaultKbOutcome> {
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. */
Expand Down
Loading