Skip to content
16 changes: 14 additions & 2 deletions packages/kb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ kbs:
description: Shared team knowledge base
```

The top-level `default_kb` key names the machine's default knowledge basethe single KB that search and writes fall back on when no store is named or discovered. It must name an entry under `kbs`; a value that matches none fails the load.
The top-level `default_kb` key names the machine's default knowledge base: the single KB that search and writes fall back on when no store is named or discovered. It must name an entry under `kbs`; a value that matches none fails the load. Set, change, or clear it from the command line with `kb set-default`.

Configuration keys, per KB entry under `kbs.<name>`:

Expand Down Expand Up @@ -186,7 +186,7 @@ Matching uses dotfile-insensitive globbing, so dot-directories (`.kb`, `.git`, `

## The `kb` command

The package ships a `kb` bin with two subcommands: `create` and `check`.
The package ships a `kb` bin with three subcommands: `create`, `set-default`, and `check`.

### kb create

Expand All @@ -211,6 +211,18 @@ 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 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`.

```bash
kb set-default coding # set default_kb to the registered KB "coding"
kb set-default --none # clear default_kb
kb set-default # list the registered KBs and choose interactively
```

With a name, it sets `default_kb` to that KB, exiting 2 if the name is not registered. With `--none`, it clears the pointer. With no arguments on an interactive terminal, it lists the registered KBs — marking the current default and offering a `(none)` option — and writes the choice; cancelling with an empty line leaves the registry unchanged. With no arguments on a non-interactive stdin, it exits 2 rather than hanging. Writes resolve against and target the user-global registry only, and preserve existing comments and formatting.

### kb check

`kb check` validates a store's notes and reports the findings. With no path arguments it checks every note; path arguments or `--vs` scope the run to a subset.
Expand Down
54 changes: 54 additions & 0 deletions packages/kb/src/cli/__tests__/select-kb-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import type { KbRegistryEntry } from '../../types.ts';
import { formatKbSelection, parseSelection } from '../select-kb-prompt.ts';

const entries: KbRegistryEntry[] = [
{ name: 'coding', path: '/abs/coding', source: 'user' },
{ name: 'notes', path: '/abs/notes', source: 'user' },
];

describe(formatKbSelection, () => {
it('numbers each KB and appends a (none) option', () => {
const text = formatKbSelection(entries);

expect(text).toContain('1) coding');
expect(text).toContain('2) notes');
expect(text).toContain('3) (none) — no default');
});

it('marks the current default', () => {
const text = formatKbSelection(entries, 'notes');

expect(text).toContain('2) notes (current default)');
expect(text).not.toContain('coding (current default)');
});

it('marks (none) as current when no default is set', () => {
const text = formatKbSelection(entries, undefined);

expect(text).toContain('(none) — no default (current)');
});
});

describe(parseSelection, () => {
it('treats an empty answer as cancel', () => {
expect(parseSelection('', 2)).toEqual({ kind: 'cancel' });
});

it('maps an in-range number to a 0-based KB index', () => {
expect(parseSelection('1', 2)).toEqual({ kind: 'kb', index: 0 });
expect(parseSelection('2', 2)).toEqual({ kind: 'kb', index: 1 });
});

it('maps the trailing number to none', () => {
expect(parseSelection('3', 2)).toEqual({ kind: 'none' });
});

it('returns null for out-of-range or non-numeric input', () => {
expect(parseSelection('0', 2)).toBeNull();
expect(parseSelection('4', 2)).toBeNull();
expect(parseSelection('abc', 2)).toBeNull();
expect(parseSelection('1.5', 2)).toBeNull();
});
});
182 changes: 182 additions & 0 deletions packages/kb/src/cli/__tests__/set-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';

import { loadKbRegistry } from '../../discovery/load-registry.ts';
import { getRegistryPathFor, makeTempDir, seedRegistry } from '../../test-utils/scaffolding.ts';
import { run } from '../run.ts';
import type { SelectKbChoice, SelectKbPrompt } from '../select-kb-prompt.ts';

const TWO_KBS = 'kbs:\n coding:\n path: /abs/coding\n notes:\n path: /abs/notes\n';

describe('kb set-default <name>', () => {
it('sets default_kb to the named KB and confirms', async () => {
const home = await makeSeededHome(TWO_KBS);

const result = await run({ argv: ['set-default', 'notes'], cwd: home, home });

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Default knowledge base has been set to "notes".\n');
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb?.name).toBe('notes');
});

it('exits 2 and leaves the registry unchanged when the name is not registered', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);

const result = await run({ argv: ['set-default', 'missing'], cwd: home, home });

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('not a registered knowledge base');
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb?.name).toBe('coding');
});

it('sets the current default again idempotently', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);

const result = await run({ argv: ['set-default', 'coding'], cwd: home, home });

expect(result.exitCode).toBe(0);
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb?.name).toBe('coding');
});
});

describe('kb set-default --none', () => {
it('clears default_kb and confirms', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);

const result = await run({ argv: ['set-default', '--none'], cwd: home, home });

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('The default knowledge base has been cleared.\n');
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb).toBeUndefined();
});

it('succeeds when no default is set', async () => {
const home = await makeSeededHome(TWO_KBS);

const result = await run({ argv: ['set-default', '--none'], cwd: home, home });

expect(result.exitCode).toBe(0);
});

it('exits 2 when combined with a name', async () => {
const home = await makeSeededHome(TWO_KBS);

const result = await run({ argv: ['set-default', 'coding', '--none'], cwd: home, home });

expect(result.exitCode).toBe(2);
});
});

describe('kb set-default (interactive)', () => {
it('sets the picked KB and reports the current default to the picker', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);
const { prompt, calledWith } = stubPrompt({ kind: 'kb', index: 1 });

const result = await run({ argv: ['set-default'], cwd: home, home, selectKb: prompt });

expect(result.exitCode).toBe(0);
expect(calledWith[0]?.currentDefaultName).toBe('coding');
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb?.name).toBe('notes');
});

it('clears the default when (none) is chosen', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);
const { prompt } = stubPrompt({ kind: 'none' });

const result = await run({ argv: ['set-default'], cwd: home, home, selectKb: prompt });

expect(result.exitCode).toBe(0);
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb).toBeUndefined();
});

it('leaves the registry unchanged when cancelled', async () => {
const home = await makeSeededHome(`default_kb: coding\n${TWO_KBS}`);
const { prompt } = stubPrompt({ kind: 'cancel' });

const result = await run({ argv: ['set-default'], cwd: home, home, selectKb: prompt });

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('No changes made.\n');
const config = await loadKbRegistry({ userConfigPath: getRegistryPathFor(home) });
expect(config.defaultKb?.name).toBe('coding');
});

it('exits 2 when stdin is not interactive (no picker supplied)', async () => {
const home = await makeSeededHome(TWO_KBS);

const result = await run({ argv: ['set-default'], cwd: home, home });

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('not interactive');
});
});

describe('kb set-default error and help paths', () => {
it('exits 2 directing to kb create when no KBs are registered (name form)', async () => {
const home = await makeTempDir('kb-setdefault-home-');

const result = await run({ argv: ['set-default', 'coding'], cwd: home, home });

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('kb create');
});

it('exits 2 directing to kb create when no KBs are registered (interactive form)', async () => {
const home = await makeTempDir('kb-setdefault-home-');
const { prompt } = stubPrompt({ kind: 'cancel' });

const result = await run({ argv: ['set-default'], cwd: home, home, selectKb: prompt });

expect(result.exitCode).toBe(2);
expect(result.stderr).toContain('kb create');
});

it('exits 2 on an unknown flag', async () => {
const home = await makeSeededHome(TWO_KBS);

const result = await run({ argv: ['set-default', '--bogus'], cwd: home, home });

expect(result.exitCode).toBe(2);
});

it('prints command help with --help', async () => {
const result = await run({ argv: ['set-default', '--help'], cwd: '/tmp', home: '/tmp' });

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('kb set-default');
});

it('lists set-default in the top-level help', async () => {
const result = await run({ argv: [], cwd: '/tmp', home: '/tmp' });

expect(result.stdout).toContain('set-default');
});
});

// region | Helpers

/** Creates a temp home directory whose `kb.yaml` registry is seeded with `content`, and returns its path. */
async function makeSeededHome(content: string): Promise<string> {
const home = await makeTempDir('kb-setdefault-home-');
await seedRegistry(getRegistryPathFor(home), content);
return home;
}

/** A stub picker that returns a fixed choice and records the input it was called with. */
function stubPrompt(choice: SelectKbChoice): { prompt: SelectKbPrompt; calledWith: { currentDefaultName?: string }[] } {
const calledWith: { currentDefaultName?: string }[] = [];
const prompt: SelectKbPrompt = (input) => {
calledWith.push({
...(input.currentDefaultName !== undefined && { currentDefaultName: input.currentDefaultName }),
});
return Promise.resolve(choice);
};
return { prompt, calledWith };
}

// endregion | Helpers
Loading
Loading