clawvault 3.5.0 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +33 -25
  2. package/bin/command-registration.test.js +179 -0
  3. package/bin/command-runtime.test.js +154 -0
  4. package/bin/help-contract.test.js +55 -0
  5. package/bin/register-config-route-commands.test.js +121 -0
  6. package/bin/register-core-commands.test.js +80 -0
  7. package/bin/register-kanban-commands.test.js +83 -0
  8. package/bin/register-project-commands.test.js +206 -0
  9. package/bin/register-query-commands.test.js +80 -0
  10. package/bin/register-resilience-commands.test.js +81 -0
  11. package/bin/register-task-commands.test.js +69 -0
  12. package/bin/register-template-commands.test.js +87 -0
  13. package/bin/test-helpers/cli-command-fixtures.js +120 -0
  14. package/dashboard/lib/graph-diff.test.js +75 -0
  15. package/dashboard/lib/vault-parser.test.js +254 -0
  16. package/dist/{chunk-DCF4KMFD.js → chunk-DPK6P6BO.js} +3 -3
  17. package/dist/{chunk-BLQXXX7Q.js → chunk-FNFP7N6A.js} +2 -2
  18. package/dist/{chunk-JI7VUQV7.js → chunk-IFUHSHN3.js} +146 -118
  19. package/dist/{chunk-QFWERBDP.js → chunk-J6DW6HBX.js} +1 -1
  20. package/dist/{chunk-VXAGOLDP.js → chunk-LCBHM3D6.js} +1 -1
  21. package/dist/{chunk-HGDDW24U.js → chunk-NTQD55S3.js} +3 -3
  22. package/dist/{chunk-QUFQBAHP.js → chunk-P35SHNAU.js} +93 -147
  23. package/dist/cli/index.js +8 -8
  24. package/dist/commands/compat.js +1 -1
  25. package/dist/commands/inject.js +2 -2
  26. package/dist/commands/maintain.js +2 -2
  27. package/dist/commands/observe.js +4 -4
  28. package/dist/commands/rebuild.js +3 -3
  29. package/dist/commands/replay.js +4 -4
  30. package/dist/commands/sleep.js +5 -5
  31. package/dist/commands/status.js +3 -3
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +17 -17
  34. package/dist/{openclaw-plugin--gqA2BZw.d.ts → openclaw-plugin-9M9qCZgl.d.ts} +2 -2
  35. package/dist/openclaw-plugin.d.ts +1 -1
  36. package/dist/openclaw-plugin.js +6 -1
  37. package/package.json +4 -26
  38. package/CHANGELOG.md +0 -543
  39. package/SKILL.md +0 -369
  40. package/docs/clawhub-security-release-playbook.md +0 -75
  41. package/docs/getting-started/installation.md +0 -99
  42. package/docs/openclaw-plugin-usage.md +0 -152
  43. package/dist/{chunk-7SWP5FKU.js → chunk-FSYISBTU.js} +4 -4
  44. package/dist/{chunk-D5U3Q4N5.js → chunk-IOKLQR4W.js} +4 -4
  45. package/dist/{chunk-OFOCU2V4.js → chunk-QL34TMGN.js} +3 -3
package/README.md CHANGED
@@ -267,24 +267,29 @@ clawvault setup --theme neural --canvas --bases
267
267
 
268
268
  ## OpenClaw Integration
269
269
 
270
- ClawVault integrates with OpenClaw as a plugin package (not the deprecated `openclaw hooks install/enable` flow):
270
+ ClawVault integrates with OpenClaw as a plugin. Install the npm package, then configure it in your OpenClaw config:
271
271
 
272
272
  ```bash
273
- # Install ClawVault
274
- npm install -g clawvault
273
+ # 1. Install the ClawVault package
274
+ npm install clawvault
275
275
 
276
- # Add ClawVault package path to plugins.load.paths in openclaw.json
277
- # (You can use `npm root -g` to locate node_modules)
276
+ # 2. Register the plugin and memory slot in openclaw.json
277
+ openclaw config set plugins.entries.clawvault.package clawvault
278
+ openclaw config set plugins.slots.memory clawvault
278
279
 
279
- # Enable plugin + memory slot in openclaw.json:
280
- # plugins.slots.memory = "clawvault"
281
- # plugins.entries.clawvault.enabled = true
280
+ # 3. Configure your vault path
281
+ openclaw config set plugins.entries.clawvault.config.vaultPath ~/my-vault
282
282
 
283
- # Verify runtime assumptions
283
+ # 4. Verify
284
284
  clawvault compat
285
285
  ```
286
286
 
287
- The plugin provides lifecycle hooks, memory tools, and protocol-safe messaging behavior for OpenClaw sessions.
287
+ The plugin automatically:
288
+ - Detects context death and injects recovery alerts
289
+ - Auto-checkpoints before session resets
290
+ - Provides `--profile auto` for context queries
291
+
292
+ > **Legacy note:** The older `openclaw hooks install` / `openclaw hooks enable` flow is no longer the recommended path. Use the plugin model above.
288
293
 
289
294
  ### MEMORY.md vs Vault
290
295
 
@@ -352,26 +357,29 @@ clawvault compat
352
357
 
353
358
  ## OpenClaw Setup (Canonical)
354
359
 
355
- Use this sequence for plugin-based OpenClaw integration:
360
+ ClawVault integrates with OpenClaw as a plugin. Use this sequence:
356
361
 
357
362
  ```bash
358
- # Install CLI
359
- npm install -g clawvault
363
+ # Install ClawVault
364
+ npm install clawvault
365
+
366
+ # Register plugin and memory slot
367
+ openclaw config set plugins.entries.clawvault.package clawvault
368
+ openclaw config set plugins.slots.memory clawvault
360
369
 
361
- # Locate global node_modules
362
- npm root -g
370
+ # Configure vault path and desired features
371
+ openclaw config set plugins.entries.clawvault.config.vaultPath ~/my-vault
372
+ openclaw config set plugins.entries.clawvault.config.enableStartupRecovery true
373
+ openclaw config set plugins.entries.clawvault.config.enableSessionContextInjection true
363
374
 
364
- # In openclaw.json:
365
- # - add <npm-root>/clawvault to plugins.load.paths
366
- # - set plugins.slots.memory = "clawvault"
367
- # - set plugins.entries.clawvault.enabled = true
368
- # - set plugins.entries.clawvault.config.vaultPath as needed
375
+ # Verify
376
+ clawvault compat
369
377
  ```
370
378
 
371
379
  Important:
372
380
 
373
- - `clawhub install clawvault` installs skill guidance, but does not configure OpenClaw plugin loading.
374
- - After changing plugin config, restart the OpenClaw gateway process.
381
+ - `clawhub install clawvault` installs skill guidance, but does not replace plugin configuration.
382
+ - After changing plugin config, restart the OpenClaw gateway process so plugin registration reloads.
375
383
 
376
384
  ## Minimal AGENTS.md Additions
377
385
 
@@ -489,9 +497,9 @@ vault/
489
497
  - `qmd` is optional; in-process BM25 search is available without it
490
498
  - if you want fallback compatibility, ensure `qmd --version` works in the same shell
491
499
  - Plugin not active in OpenClaw:
492
- - verify `plugins.load.paths` includes the ClawVault package path
493
- - verify `plugins.slots.memory` is `clawvault`
494
- - verify `plugins.entries.clawvault.enabled` is `true`
500
+ - run `openclaw config set plugins.entries.clawvault.package clawvault`
501
+ - run `openclaw config set plugins.slots.memory clawvault`
502
+ - restart the OpenClaw gateway process
495
503
  - OpenClaw integration drift:
496
504
  - run `clawvault compat`
497
505
  - Session transcript corruption:
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { registerCoreCommands } from './register-core-commands.js';
6
+ import { registerMaintenanceCommands } from './register-maintenance-commands.js';
7
+ import { registerQueryCommands } from './register-query-commands.js';
8
+ import { registerResilienceCommands } from './register-resilience-commands.js';
9
+ import { registerSessionLifecycleCommands } from './register-session-lifecycle-commands.js';
10
+ import { registerTemplateCommands } from './register-template-commands.js';
11
+ import { registerVaultOperationsCommands } from './register-vault-operations-commands.js';
12
+ import { registerConfigCommands } from './register-config-commands.js';
13
+ import { registerRouteCommands } from './register-route-commands.js';
14
+ import {
15
+ chalkStub,
16
+ createGetVaultStub,
17
+ registerAllCommandModules,
18
+ stubResolveVaultPath
19
+ } from './test-helpers/cli-command-fixtures.js';
20
+
21
+ function listCommandNames(program) {
22
+ return program.commands.map((command) => command.name()).sort((a, b) => a.localeCompare(b));
23
+ }
24
+
25
+ describe('CLI command registration modules', () => {
26
+ it('registers core lifecycle commands', () => {
27
+ const program = new Command();
28
+ registerCoreCommands(program, {
29
+ chalk: chalkStub,
30
+ path,
31
+ fs,
32
+ createVault: async () => ({ getCategories: () => [], getQmdRoot: () => '', getQmdCollection: () => '' }),
33
+ getVault: createGetVaultStub({ store: async () => ({}), capture: async () => ({}) }),
34
+ runQmd: async () => {}
35
+ });
36
+
37
+ const names = listCommandNames(program);
38
+ expect(names).toEqual(expect.arrayContaining(['init', 'setup', 'store', 'patch', 'capture', 'inbox']));
39
+ });
40
+
41
+ it('registers query commands with profile option', () => {
42
+ const program = new Command();
43
+ registerQueryCommands(program, {
44
+ chalk: chalkStub,
45
+ getVault: createGetVaultStub({ find: async () => [], vsearch: async () => [] }),
46
+ resolveVaultPath: stubResolveVaultPath,
47
+ QmdUnavailableError: class extends Error {},
48
+ printQmdMissing: () => {}
49
+ });
50
+
51
+ const names = listCommandNames(program);
52
+ expect(names).toEqual(expect.arrayContaining(['search', 'vsearch', 'context', 'recall', 'inject', 'observe', 'reflect', 'session-recap']));
53
+
54
+ const contextCommand = program.commands.find((command) => command.name() === 'context');
55
+ const profileOption = contextCommand?.options.find((option) => option.flags.includes('--profile <profile>'));
56
+ expect(profileOption?.description).toContain('auto');
57
+ });
58
+
59
+ it('registers vault operation commands', () => {
60
+ const program = new Command();
61
+ registerVaultOperationsCommands(program, {
62
+ chalk: chalkStub,
63
+ fs,
64
+ getVault: createGetVaultStub({
65
+ list: async () => [],
66
+ get: async () => null,
67
+ stats: async () => ({ tags: [], categories: {} }),
68
+ sync: async () => ({ copied: [], deleted: [], unchanged: [], errors: [] }),
69
+ reindex: async () => 0,
70
+ remember: async () => ({ id: '' }),
71
+ getQmdCollection: () => ''
72
+ }),
73
+ runQmd: async () => {},
74
+ resolveVaultPath: stubResolveVaultPath,
75
+ path
76
+ });
77
+
78
+ const names = listCommandNames(program);
79
+ expect(names).toEqual(expect.arrayContaining([
80
+ 'list',
81
+ 'get',
82
+ 'stats',
83
+ 'sync',
84
+ 'reindex',
85
+ 'remember',
86
+ 'shell-init',
87
+ 'dashboard'
88
+ ]));
89
+ });
90
+
91
+ it('registers maintenance, resilience, session-lifecycle and template commands', () => {
92
+ const program = new Command();
93
+ registerMaintenanceCommands(program, { chalk: chalkStub });
94
+ registerResilienceCommands(program, {
95
+ chalk: chalkStub,
96
+ resolveVaultPath: stubResolveVaultPath
97
+ });
98
+ registerSessionLifecycleCommands(program, {
99
+ chalk: chalkStub,
100
+ resolveVaultPath: stubResolveVaultPath,
101
+ QmdUnavailableError: class extends Error {},
102
+ printQmdMissing: () => {},
103
+ getVault: createGetVaultStub({
104
+ createHandoff: async () => ({ id: '', path: '' }),
105
+ getQmdCollection: () => '',
106
+ generateRecap: async () => ({}),
107
+ formatRecap: () => ''
108
+ }),
109
+ runQmd: async () => {}
110
+ });
111
+ registerTemplateCommands(program, { chalk: chalkStub });
112
+ registerConfigCommands(program, {
113
+ chalk: chalkStub,
114
+ resolveVaultPath: stubResolveVaultPath
115
+ });
116
+ registerRouteCommands(program, {
117
+ chalk: chalkStub,
118
+ resolveVaultPath: stubResolveVaultPath
119
+ });
120
+
121
+ const names = listCommandNames(program);
122
+ expect(names).toEqual(expect.arrayContaining([
123
+ 'doctor',
124
+ 'benchmark',
125
+ 'maintain',
126
+ 'embed',
127
+ 'compat',
128
+ 'graph',
129
+ 'entities',
130
+ 'entity',
131
+ 'link',
132
+ 'rebuild',
133
+ 'archive',
134
+ 'migrate-observations',
135
+ 'replay',
136
+ 'sync-bd',
137
+ 'checkpoint',
138
+ 'recover',
139
+ 'status',
140
+ 'clean-exit',
141
+ 'repair-session',
142
+ 'wake',
143
+ 'sleep',
144
+ 'handoff',
145
+ 'recap',
146
+ 'template',
147
+ 'config',
148
+ 'route'
149
+ ]));
150
+
151
+ const templateCommand = program.commands.find((command) => command.name() === 'template');
152
+ const templateSubcommands = templateCommand?.commands.map((command) => command.name()) ?? [];
153
+ expect(templateSubcommands).toEqual(expect.arrayContaining(['list', 'create', 'add']));
154
+
155
+ const compatCommand = program.commands.find((command) => command.name() === 'compat');
156
+ const strictOption = compatCommand?.options.find((option) => option.flags.includes('--strict'));
157
+ const baseDirOption = compatCommand?.options.find((option) => option.flags.includes('--base-dir <path>'));
158
+ expect(strictOption).toBeTruthy();
159
+ expect(baseDirOption).toBeTruthy();
160
+ });
161
+
162
+ it('keeps top-level command names unique when modules are combined', () => {
163
+ const program = registerAllCommandModules(new Command());
164
+
165
+ const names = program.commands.map((command) => command.name());
166
+ const unique = new Set(names);
167
+ expect(unique.size).toBe(names.length);
168
+ });
169
+
170
+ it('does not register removed workgraph commands', () => {
171
+ const program = registerAllCommandModules(new Command());
172
+ const names = listCommandNames(program);
173
+
174
+ expect(names).not.toContain('wg');
175
+ expect(names).not.toContain('thread');
176
+ expect(names).not.toContain('primitive');
177
+ expect(names).not.toContain('ledger');
178
+ });
179
+ });
@@ -0,0 +1,154 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import path from 'path';
3
+
4
+ const {
5
+ spawnMock,
6
+ resolveConfiguredVaultPathMock,
7
+ clawvaultCtorMock,
8
+ loadMock
9
+ } = vi.hoisted(() => ({
10
+ spawnMock: vi.fn(),
11
+ resolveConfiguredVaultPathMock: vi.fn(),
12
+ clawvaultCtorMock: vi.fn(),
13
+ loadMock: vi.fn()
14
+ }));
15
+
16
+ vi.mock('child_process', () => ({
17
+ spawn: spawnMock
18
+ }));
19
+
20
+ vi.mock('../dist/index.js', () => ({
21
+ ClawVault: clawvaultCtorMock,
22
+ resolveVaultPath: resolveConfiguredVaultPathMock,
23
+ QmdUnavailableError: class QmdUnavailableError extends Error {},
24
+ QMD_INSTALL_COMMAND: 'install-qmd'
25
+ }));
26
+
27
+ async function loadRuntimeModule() {
28
+ vi.resetModules();
29
+ return await import('./command-runtime.js');
30
+ }
31
+
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ clawvaultCtorMock.mockImplementation(() => ({
35
+ load: loadMock
36
+ }));
37
+ });
38
+
39
+ describe('command runtime helpers', () => {
40
+ it('delegates vault path resolution and loads vaults', async () => {
41
+ resolveConfiguredVaultPathMock.mockReturnValue('/resolved/vault');
42
+ loadMock.mockResolvedValue(undefined);
43
+ const { getVault, resolveVaultPath } = await loadRuntimeModule();
44
+
45
+ const resolved = resolveVaultPath('/explicit');
46
+ expect(resolveConfiguredVaultPathMock).toHaveBeenCalledWith({ explicitPath: '/explicit' });
47
+ expect(resolved).toBe('/resolved/vault');
48
+
49
+ await getVault('/explicit');
50
+ expect(clawvaultCtorMock).toHaveBeenCalledWith('/resolved/vault');
51
+ expect(loadMock).toHaveBeenCalled();
52
+ });
53
+
54
+ it('maps qmd ENOENT failures to QmdUnavailableError', async () => {
55
+ const { runQmd, QmdUnavailableError } = await loadRuntimeModule();
56
+ spawnMock.mockImplementation(() => {
57
+ const handlers = {};
58
+ const proc = {
59
+ on: (event, handler) => {
60
+ handlers[event] = handler;
61
+ }
62
+ };
63
+ queueMicrotask(() => {
64
+ handlers.error?.({ code: 'ENOENT' });
65
+ });
66
+ return proc;
67
+ });
68
+
69
+ await expect(runQmd(['update'])).rejects.toBeInstanceOf(QmdUnavailableError);
70
+ });
71
+
72
+ it('injects qmd index from environment when configured', async () => {
73
+ const previous = process.env.CLAWVAULT_QMD_INDEX;
74
+ process.env.CLAWVAULT_QMD_INDEX = 'clawvault-test';
75
+
76
+ try {
77
+ const { runQmd } = await loadRuntimeModule();
78
+ spawnMock.mockImplementation((_command, _args) => {
79
+ const handlers = {};
80
+ const proc = {
81
+ on: (event, handler) => {
82
+ handlers[event] = handler;
83
+ }
84
+ };
85
+ queueMicrotask(() => {
86
+ handlers.close?.(0);
87
+ });
88
+ return proc;
89
+ });
90
+
91
+ await runQmd(['update']);
92
+ expect(spawnMock).toHaveBeenCalledWith(
93
+ 'qmd',
94
+ ['--index', 'clawvault-test', 'update'],
95
+ { stdio: 'inherit' }
96
+ );
97
+ } finally {
98
+ if (previous === undefined) {
99
+ delete process.env.CLAWVAULT_QMD_INDEX;
100
+ } else {
101
+ process.env.CLAWVAULT_QMD_INDEX = previous;
102
+ }
103
+ }
104
+ });
105
+
106
+ it('surfaces qmd non-zero exit codes as errors', async () => {
107
+ const { runQmd } = await loadRuntimeModule();
108
+ spawnMock.mockImplementation(() => {
109
+ const handlers = {};
110
+ const proc = {
111
+ on: (event, handler) => {
112
+ handlers[event] = handler;
113
+ }
114
+ };
115
+ queueMicrotask(() => {
116
+ handlers.close?.(2);
117
+ });
118
+ return proc;
119
+ });
120
+
121
+ await expect(runQmd(['update'])).rejects.toThrow('qmd exited with code 2');
122
+ });
123
+
124
+ it('exports and enforces argument/path sanitization helpers', async () => {
125
+ const { sanitizeQmdArg, validatePathWithinBase } = await loadRuntimeModule();
126
+ expect(sanitizeQmdArg('update')).toBe('update');
127
+ expect(sanitizeQmdArg(42)).toBe('42');
128
+ expect(() => sanitizeQmdArg('bad\0arg')).toThrow('contains null byte');
129
+
130
+ const safePath = validatePathWithinBase('notes/today.md', '/tmp/vault');
131
+ expect(safePath).toBe(path.resolve('/tmp/vault', 'notes/today.md'));
132
+ expect(() => validatePathWithinBase('../etc/passwd', '/tmp/vault')).toThrow('Path traversal detected');
133
+ });
134
+
135
+ it('rejects qmd args with null-byte injection attempts', async () => {
136
+ const { runQmd } = await loadRuntimeModule();
137
+ await expect(runQmd(['up\0date'])).rejects.toThrow('contains null byte');
138
+ expect(spawnMock).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('prints consistent qmd missing guidance', async () => {
142
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
143
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
144
+ try {
145
+ const { printQmdMissing } = await loadRuntimeModule();
146
+ printQmdMissing();
147
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('ClawVault requires qmd.'));
148
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('install-qmd'));
149
+ } finally {
150
+ errorSpy.mockRestore();
151
+ logSpy.mockRestore();
152
+ }
153
+ });
154
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { registerAllCommandModules } from './test-helpers/cli-command-fixtures.js';
3
+
4
+ describe('CLI help contract', () => {
5
+ it('includes expected high-level command surface', () => {
6
+ const help = registerAllCommandModules().helpInformation();
7
+ expect(help).toContain('init');
8
+ expect(help).toContain('context');
9
+ expect(help).toContain('recall');
10
+ expect(help).toContain('inject');
11
+ expect(help).toContain('doctor');
12
+ expect(help).toContain('benchmark');
13
+ expect(help).toContain('maintain');
14
+ expect(help).toContain('embed');
15
+ expect(help).toContain('inbox');
16
+ expect(help).toContain('patch');
17
+ expect(help).toContain('compat');
18
+ expect(help).toContain('graph');
19
+ expect(help).toContain('reflect');
20
+ expect(help).toContain('replay');
21
+ expect(help).toContain('repair-session');
22
+ expect(help).toContain('project');
23
+ expect(help).toContain('template');
24
+ expect(help).toContain('config');
25
+ expect(help).toContain('route');
26
+ expect(help).toContain('entity');
27
+ });
28
+
29
+ it('documents context/compat/inject/project help details', () => {
30
+ const program = registerAllCommandModules();
31
+ const contextHelp = program.commands.find((command) => command.name() === 'context')?.helpInformation() ?? '';
32
+ const compatHelp = program.commands.find((command) => command.name() === 'compat')?.helpInformation() ?? '';
33
+ const injectHelp = program.commands.find((command) => command.name() === 'inject')?.helpInformation() ?? '';
34
+ const projectCommand = program.commands.find((command) => command.name() === 'project');
35
+ const projectListHelp = projectCommand?.commands.find((command) => command.name() === 'list')?.helpInformation() ?? '';
36
+ const projectBoardHelp = projectCommand?.commands.find((command) => command.name() === 'board')?.helpInformation() ?? '';
37
+ expect(contextHelp).toContain('--profile <profile>');
38
+ expect(contextHelp).toContain('auto');
39
+ expect(compatHelp).toContain('--strict');
40
+ expect(injectHelp).toContain('inject.maxResults');
41
+ expect(injectHelp).toContain('inject.scope');
42
+ expect(projectListHelp).toContain('archived projects are hidden');
43
+ expect(projectBoardHelp).toContain('default: status');
44
+ });
45
+
46
+ it('does not advertise removed workgraph commands', () => {
47
+ const program = registerAllCommandModules();
48
+ const names = program.commands.map((command) => command.name());
49
+
50
+ expect(names).not.toContain('wg');
51
+ expect(names).not.toContain('thread');
52
+ expect(names).not.toContain('primitive');
53
+ expect(names).not.toContain('ledger');
54
+ });
55
+ });
@@ -0,0 +1,121 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { registerConfigCommands } from './register-config-commands.js';
4
+ import { registerRouteCommands } from './register-route-commands.js';
5
+ import { chalkStub } from './test-helpers/cli-command-fixtures.js';
6
+
7
+ const {
8
+ getConfigValueMock,
9
+ setConfigValueMock,
10
+ listConfigMock,
11
+ resetConfigMock,
12
+ addRouteRuleMock,
13
+ listRouteRulesMock,
14
+ removeRouteRuleMock,
15
+ testRouteRuleMock
16
+ } = vi.hoisted(() => ({
17
+ getConfigValueMock: vi.fn(),
18
+ setConfigValueMock: vi.fn(),
19
+ listConfigMock: vi.fn(),
20
+ resetConfigMock: vi.fn(),
21
+ addRouteRuleMock: vi.fn(),
22
+ listRouteRulesMock: vi.fn(),
23
+ removeRouteRuleMock: vi.fn(),
24
+ testRouteRuleMock: vi.fn()
25
+ }));
26
+
27
+ vi.mock('../dist/index.js', () => ({
28
+ SUPPORTED_CONFIG_KEYS: [
29
+ 'name',
30
+ 'categories',
31
+ 'theme',
32
+ 'observe.model',
33
+ 'observe.provider',
34
+ 'observer.compression.provider',
35
+ 'observer.compression.model',
36
+ 'observer.compression.baseUrl',
37
+ 'observer.compression.apiKey',
38
+ 'context.maxResults',
39
+ 'context.defaultProfile',
40
+ 'graph.maxHops',
41
+ 'inject.maxResults',
42
+ 'inject.useLlm',
43
+ 'inject.scope'
44
+ ],
45
+ getConfigValue: getConfigValueMock,
46
+ setConfigValue: setConfigValueMock,
47
+ listConfig: listConfigMock,
48
+ resetConfig: resetConfigMock,
49
+ addRouteRule: addRouteRuleMock,
50
+ listRouteRules: listRouteRulesMock,
51
+ removeRouteRule: removeRouteRuleMock,
52
+ testRouteRule: testRouteRuleMock
53
+ }));
54
+
55
+ function buildProgram() {
56
+ const program = new Command();
57
+ const resolveVaultPath = (value) => value ?? '/vault';
58
+ registerConfigCommands(program, { chalk: chalkStub, resolveVaultPath });
59
+ registerRouteCommands(program, { chalk: chalkStub, resolveVaultPath });
60
+ return program;
61
+ }
62
+
63
+ async function runCommand(args) {
64
+ const program = buildProgram();
65
+ await program.parseAsync(args, { from: 'user' });
66
+ }
67
+
68
+ beforeEach(() => {
69
+ vi.clearAllMocks();
70
+ getConfigValueMock.mockReturnValue('demo-vault');
71
+ setConfigValueMock.mockReturnValue({ value: 'demo-vault' });
72
+ listConfigMock.mockReturnValue({ name: 'demo-vault', context: { maxResults: 5 } });
73
+ resetConfigMock.mockReturnValue(undefined);
74
+ addRouteRuleMock.mockReturnValue({ pattern: 'Pedro', target: 'people/pedro', priority: 1 });
75
+ listRouteRulesMock.mockReturnValue([{ pattern: 'Pedro', target: 'people/pedro', priority: 1 }]);
76
+ removeRouteRuleMock.mockReturnValue(true);
77
+ testRouteRuleMock.mockReturnValue({ pattern: 'Pedro', target: 'people/pedro', priority: 1 });
78
+ });
79
+
80
+ describe('config and route command registrations', () => {
81
+ it('executes config get/set/list subcommands', async () => {
82
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
83
+ try {
84
+ await runCommand(['config', 'get', 'name']);
85
+ expect(getConfigValueMock).toHaveBeenCalledWith('/vault', 'name');
86
+
87
+ await runCommand(['config', 'set', 'categories', 'people,projects']);
88
+ expect(setConfigValueMock).toHaveBeenCalledWith('/vault', 'categories', 'people,projects');
89
+
90
+ await runCommand(['config', 'list']);
91
+ expect(listConfigMock).toHaveBeenCalledWith('/vault');
92
+
93
+ const output = logSpy.mock.calls.map((call) => call.join(' ')).join('\n');
94
+ expect(output).toContain('context.maxResults');
95
+ } finally {
96
+ logSpy.mockRestore();
97
+ }
98
+ });
99
+
100
+ it('executes route add/remove/test subcommands', async () => {
101
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
102
+ try {
103
+ await runCommand(['route', 'add', 'Pedro', 'people/pedro']);
104
+ expect(addRouteRuleMock).toHaveBeenCalledWith('/vault', 'Pedro', 'people/pedro');
105
+
106
+ await runCommand(['route', 'remove', 'Pedro']);
107
+ expect(removeRouteRuleMock).toHaveBeenCalledWith('/vault', 'Pedro');
108
+
109
+ await runCommand(['route', 'test', 'Talked to Pedro']);
110
+ expect(testRouteRuleMock).toHaveBeenCalledWith('/vault', 'Talked to Pedro');
111
+
112
+ await runCommand(['route', 'list']);
113
+ expect(listRouteRulesMock).toHaveBeenCalledWith('/vault');
114
+
115
+ const output = logSpy.mock.calls.map((call) => call.join(' ')).join('\n');
116
+ expect(output).toContain('Route matched');
117
+ } finally {
118
+ logSpy.mockRestore();
119
+ }
120
+ });
121
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { registerCoreCommands } from './register-core-commands.js';
6
+ import { chalkStub } from './test-helpers/cli-command-fixtures.js';
7
+
8
+ function buildProgram(patchImpl) {
9
+ const program = new Command();
10
+ registerCoreCommands(program, {
11
+ chalk: chalkStub,
12
+ path,
13
+ fs,
14
+ createVault: async () => ({ getCategories: () => [], getQmdRoot: () => '', getQmdCollection: () => '' }),
15
+ getVault: async () => ({ patch: patchImpl }),
16
+ runQmd: async () => {}
17
+ });
18
+ return program;
19
+ }
20
+
21
+ describe('register-core-commands patch command', () => {
22
+ it('exposes patch command mode flags', () => {
23
+ const program = buildProgram(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
24
+ const patchCommand = program.commands.find((command) => command.name() === 'patch');
25
+ expect(patchCommand).toBeDefined();
26
+ const optionFlags = patchCommand?.options.map((option) => option.flags) ?? [];
27
+ expect(optionFlags).toEqual(expect.arrayContaining([
28
+ '--append <text>',
29
+ '--replace <text>',
30
+ '--with <text>',
31
+ '--section <heading>',
32
+ '--content <text>',
33
+ '-v, --vault <path>'
34
+ ]));
35
+ });
36
+
37
+ it('forwards append mode payload to vault.patch', async () => {
38
+ const patchMock = vi.fn(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
39
+ const program = buildProgram(patchMock);
40
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
41
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
42
+
43
+ try {
44
+ await program.parseAsync(['patch', 'decisions/example', '--append', 'new line'], { from: 'user' });
45
+ expect(patchMock).toHaveBeenCalledWith({
46
+ idOrPath: 'decisions/example',
47
+ mode: 'append',
48
+ append: 'new line'
49
+ });
50
+ expect(errorSpy).not.toHaveBeenCalled();
51
+ } finally {
52
+ logSpy.mockRestore();
53
+ errorSpy.mockRestore();
54
+ }
55
+ });
56
+
57
+ it('forwards section/content mode payload to vault.patch', async () => {
58
+ const patchMock = vi.fn(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
59
+ const program = buildProgram(patchMock);
60
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
61
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
62
+
63
+ try {
64
+ await program.parseAsync(
65
+ ['patch', 'decisions/example', '--section', 'Notes', '--content', 'updated notes'],
66
+ { from: 'user' }
67
+ );
68
+ expect(patchMock).toHaveBeenCalledWith({
69
+ idOrPath: 'decisions/example',
70
+ mode: 'content',
71
+ content: 'updated notes',
72
+ section: 'Notes'
73
+ });
74
+ expect(errorSpy).not.toHaveBeenCalled();
75
+ } finally {
76
+ logSpy.mockRestore();
77
+ errorSpy.mockRestore();
78
+ }
79
+ });
80
+ });