clawvault 3.5.0 → 3.5.1

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 (46) hide show
  1. package/README.md +26 -26
  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-QFWERBDP.js → chunk-J6DW6HBX.js} +1 -1
  19. package/dist/{chunk-VXAGOLDP.js → chunk-LCBHM3D6.js} +1 -1
  20. package/dist/{chunk-HGDDW24U.js → chunk-NTQD55S3.js} +3 -3
  21. package/dist/{chunk-QUFQBAHP.js → chunk-P35SHNAU.js} +93 -147
  22. package/dist/{chunk-JI7VUQV7.js → chunk-X3SPPUFG.js} +132 -118
  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/LICENSE +0 -21
  40. package/SKILL.md +0 -369
  41. package/docs/clawhub-security-release-playbook.md +0 -75
  42. package/docs/getting-started/installation.md +0 -99
  43. package/docs/openclaw-plugin-usage.md +0 -152
  44. package/dist/{chunk-7SWP5FKU.js → chunk-FSYISBTU.js} +4 -4
  45. package/dist/{chunk-D5U3Q4N5.js → chunk-IOKLQR4W.js} +4 -4
  46. package/dist/{chunk-OFOCU2V4.js → chunk-QL34TMGN.js} +3 -3
package/README.md CHANGED
@@ -267,24 +267,23 @@ 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
+ For hook-based lifecycle integration with OpenClaw:
271
271
 
272
272
  ```bash
273
- # Install ClawVault
274
- npm install -g clawvault
275
-
276
- # Add ClawVault package path to plugins.load.paths in openclaw.json
277
- # (You can use `npm root -g` to locate node_modules)
273
+ # Install and enable hook pack
274
+ openclaw hooks install clawvault
275
+ openclaw hooks enable clawvault
278
276
 
279
- # Enable plugin + memory slot in openclaw.json:
280
- # plugins.slots.memory = "clawvault"
281
- # plugins.entries.clawvault.enabled = true
282
-
283
- # Verify runtime assumptions
277
+ # Verify
278
+ openclaw hooks list --verbose
279
+ openclaw hooks check
284
280
  clawvault compat
285
281
  ```
286
282
 
287
- The plugin provides lifecycle hooks, memory tools, and protocol-safe messaging behavior for OpenClaw sessions.
283
+ The hook automatically:
284
+ - Detects context death and injects recovery alerts
285
+ - Auto-checkpoints before session resets
286
+ - Provides `--profile auto` for context queries
288
287
 
289
288
  ### MEMORY.md vs Vault
290
289
 
@@ -352,26 +351,27 @@ clawvault compat
352
351
 
353
352
  ## OpenClaw Setup (Canonical)
354
353
 
355
- Use this sequence for plugin-based OpenClaw integration:
354
+ If you want hook-based lifecycle integration, use this sequence:
356
355
 
357
356
  ```bash
358
357
  # Install CLI
359
358
  npm install -g clawvault
360
359
 
361
- # Locate global node_modules
362
- npm root -g
360
+ # Install and enable hook pack
361
+ openclaw hooks install clawvault
362
+ openclaw hooks enable clawvault
363
363
 
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
364
+ # Verify
365
+ openclaw hooks list --verbose
366
+ openclaw hooks info clawvault
367
+ openclaw hooks check
368
+ clawvault compat
369
369
  ```
370
370
 
371
371
  Important:
372
372
 
373
- - `clawhub install clawvault` installs skill guidance, but does not configure OpenClaw plugin loading.
374
- - After changing plugin config, restart the OpenClaw gateway process.
373
+ - `clawhub install clawvault` installs skill guidance, but does not replace hook-pack installation.
374
+ - After enabling hooks, restart the OpenClaw gateway process so hook registration reloads.
375
375
 
376
376
  ## Minimal AGENTS.md Additions
377
377
 
@@ -488,10 +488,10 @@ vault/
488
488
  - `qmd` fallback errors:
489
489
  - `qmd` is optional; in-process BM25 search is available without it
490
490
  - if you want fallback compatibility, ensure `qmd --version` works in the same shell
491
- - 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`
491
+ - Hook/plugin not active in OpenClaw:
492
+ - run `openclaw hooks install clawvault`
493
+ - run `openclaw hooks enable clawvault`
494
+ - verify with `openclaw hooks list --verbose`
495
495
  - OpenClaw integration drift:
496
496
  - run `clawvault compat`
497
497
  - 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
+ });