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: 1 addition & 1 deletion bin/command-registration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('CLI command registration modules', () => {
});

const names = listCommandNames(program);
expect(names).toEqual(expect.arrayContaining(['init', 'setup', 'store', 'capture', 'inbox']));
expect(names).toEqual(expect.arrayContaining(['init', 'setup', 'store', 'patch', 'capture', 'inbox']));
});

it('registers query commands with profile option', () => {
Expand Down
1 change: 1 addition & 0 deletions bin/help-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('CLI help contract', () => {
expect(help).toContain('maintain');
expect(help).toContain('embed');
expect(help).toContain('inbox');
expect(help).toContain('patch');
expect(help).toContain('compat');
expect(help).toContain('graph');
expect(help).toContain('reflect');
Expand Down
60 changes: 60 additions & 0 deletions bin/register-core-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,66 @@ export function registerCoreCommands(
}
});

// === CAPTURE ===
program
.command('patch <idOrPath>')
.description('Patch an existing memory document')
.option('--append <text>', 'Append text to the document body (or target section)')
.option('--replace <text>', 'Text to replace')
.option('--with <text>', 'Replacement text used with --replace')
.option('--section <heading>', 'Limit patching to a markdown section heading')
.option('--content <text>', 'Replace document body (or section body) with text')
.option('-v, --vault <path>', 'Vault path (default: find nearest)')
.action(async (idOrPath, options) => {
try {
const modeFlags = [
typeof options.append === 'string',
typeof options.replace === 'string',
typeof options.content === 'string'
];
const selectedModes = modeFlags.filter(Boolean).length;
if (selectedModes !== 1) {
throw new Error('Select exactly one patch mode: --append, --replace/--with, or --content.');
}

if (typeof options.with === 'string' && typeof options.replace !== 'string') {
throw new Error('--with can only be used together with --replace.');
}

const vault = await getVault(options.vault);
const patchOptions = {
idOrPath,
mode: 'content'
};

if (typeof options.append === 'string') {
patchOptions.mode = 'append';
patchOptions.append = options.append;
} else if (typeof options.replace === 'string') {
if (typeof options.with !== 'string') {
throw new Error('--replace requires --with.');
}
patchOptions.mode = 'replace';
patchOptions.replace = options.replace;
patchOptions.with = options.with;
} else if (typeof options.content === 'string') {
patchOptions.mode = 'content';
patchOptions.content = options.content;
}

if (typeof options.section === 'string') {
patchOptions.section = options.section;
}

const doc = await vault.patch(patchOptions);
console.log(chalk.green(`✓ Patched: ${doc.id}`));
console.log(chalk.dim(` Path: ${doc.path}`));
} catch (err) {
console.error(chalk.red(`Error: ${err.message}`));
process.exit(1);
}
});

// === CAPTURE ===
program
.command('capture <note>')
Expand Down
80 changes: 80 additions & 0 deletions bin/register-core-commands.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import * as fs from 'fs';
import * as path from 'path';
import { registerCoreCommands } from './register-core-commands.js';
import { chalkStub } from './test-helpers/cli-command-fixtures.js';

function buildProgram(patchImpl) {
const program = new Command();
registerCoreCommands(program, {
chalk: chalkStub,
path,
fs,
createVault: async () => ({ getCategories: () => [], getQmdRoot: () => '', getQmdCollection: () => '' }),
getVault: async () => ({ patch: patchImpl }),
runQmd: async () => {}
});
return program;
}

describe('register-core-commands patch command', () => {
it('exposes patch command mode flags', () => {
const program = buildProgram(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
const patchCommand = program.commands.find((command) => command.name() === 'patch');
expect(patchCommand).toBeDefined();
const optionFlags = patchCommand?.options.map((option) => option.flags) ?? [];
expect(optionFlags).toEqual(expect.arrayContaining([
'--append <text>',
'--replace <text>',
'--with <text>',
'--section <heading>',
'--content <text>',
'-v, --vault <path>'
]));
});

it('forwards append mode payload to vault.patch', async () => {
const patchMock = vi.fn(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
const program = buildProgram(patchMock);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await program.parseAsync(['patch', 'decisions/example', '--append', 'new line'], { from: 'user' });
expect(patchMock).toHaveBeenCalledWith({
idOrPath: 'decisions/example',
mode: 'append',
append: 'new line'
});
expect(errorSpy).not.toHaveBeenCalled();
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
}
});

it('forwards section/content mode payload to vault.patch', async () => {
const patchMock = vi.fn(async () => ({ id: 'decisions/example', path: '/vault/decisions/example.md' }));
const program = buildProgram(patchMock);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await program.parseAsync(
['patch', 'decisions/example', '--section', 'Notes', '--content', 'updated notes'],
{ from: 'user' }
);
expect(patchMock).toHaveBeenCalledWith({
idOrPath: 'decisions/example',
mode: 'content',
content: 'updated notes',
section: 'Notes'
});
expect(errorSpy).not.toHaveBeenCalled();
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
1 change: 1 addition & 0 deletions bin/test-helpers/cli-command-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function stubResolveVaultPath(value) {
export function createVaultStub(overrides = {}) {
return {
store: async () => ({}),
patch: async () => ({}),
capture: async () => ({}),
find: async () => [],
vsearch: async () => [],
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ export type {
EmbeddingProvider,
RerankProvider,
StoreOptions,
PatchMode,
PatchOptions,
SyncOptions,
SyncResult,
Category,
Expand Down
97 changes: 97 additions & 0 deletions src/lib/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,103 @@ describe('vault core', () => {
expect(qmdEmbedMock).toHaveBeenCalledWith(vault.getQmdCollection(), 'secondary-index');
});

it('patches a document via append mode and refreshes only that document index entry', async () => {
const vault = await createVault(tempDir, { name: 'Patch Vault' }, { skipGraph: true, skipBases: true });
await vault.store({
category: 'decisions',
title: 'Patch Target',
content: 'Initial decision text'
});
await vault.store({
category: 'decisions',
title: 'Untouched Doc',
content: 'This remains unchanged'
});

const reindexSpy = vi.spyOn(vault, 'reindex');
const patched = await vault.patch({
idOrPath: 'decisions/patch-target',
mode: 'append',
append: 'Follow-up action added'
});

expect(patched.id).toBe('decisions/patch-target');
expect(patched.content).toContain('Initial decision text');
expect(patched.content).toContain('Follow-up action added');
expect(reindexSpy).not.toHaveBeenCalled();

const untouched = await vault.get('decisions/untouched-doc');
expect(untouched?.content).toContain('This remains unchanged');

const results = await vault.find('Follow-up action added');
expect(results.some((result) => result.document.id === 'decisions/patch-target')).toBe(true);
});

it('supports replace mode and section/content mode in patch', async () => {
const vault = await createVault(tempDir, { name: 'Patch Modes Vault' }, { skipGraph: true, skipBases: true });
const created = await vault.store({
category: 'projects',
title: 'Patch Modes',
content: [
'# Overview',
'Deploy patch this week.',
'',
'## Notes',
'Deploy patch in canary first.',
'',
'## Risks',
'Regression risk is low.'
].join('\n')
});

await vault.patch({
idOrPath: created.id,
mode: 'replace',
replace: 'Deploy patch',
with: 'Ship rollout',
section: 'Notes'
});

await vault.patch({
idOrPath: created.id,
mode: 'content',
section: 'Risks',
content: 'Regression risk is medium without smoke tests.'
});

const patched = await vault.get(created.id);
expect(patched?.content).toContain('# Overview\nDeploy patch this week.');
expect(patched?.content).toContain('## Notes\nShip rollout in canary first.');
expect(patched?.content).toContain('## Risks\nRegression risk is medium without smoke tests.');
});

it('throws clear errors for missing patch targets', async () => {
const vault = await createVault(tempDir, { name: 'Patch Errors Vault' }, { skipGraph: true, skipBases: true });
await vault.store({
category: 'inbox',
title: 'Patch Error Seed',
content: 'Body text'
});

await expect(
vault.patch({
idOrPath: 'inbox/patch-error-seed',
mode: 'replace',
replace: 'missing token',
with: 'new token'
})
).rejects.toThrow('No matches found');

await expect(
vault.patch({
idOrPath: 'inbox/patch-error-seed',
mode: 'content',
section: 'Unknown Section',
content: 'new body'
})
).rejects.toThrow('Section not found');
});

it('syncs files with dry-run and orphan deletion support', async () => {
const vault = await createVault(tempDir, { name: 'Sync Vault' }, { skipGraph: true, skipBases: true });
await vault.store({
Expand Down
Loading
Loading