Skip to content

Commit 46e3ca1

Browse files
G9Pedrocursoragent
andauthored
feat: add patch command for incremental document updates (#167)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: G9Pedro <G9Pedro@users.noreply.github.com>
1 parent bb0dcd1 commit 46e3ca1

File tree

3 files changed

+333
-0
lines changed

3 files changed

+333
-0
lines changed

src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { registerReflectCommand } from '../commands/reflect.js';
77
import { registerEmbedCommand } from '../commands/embed.js';
88
import { registerInboxCommand } from '../commands/inbox.js';
99
import { registerMaintainCommand } from '../commands/maintain.js';
10+
import { registerPatchCommand } from '../commands/patch.js';
1011
import { registerTailscaleCommands } from '../commands/tailscale.js';
1112

1213
export function registerCliCommands(program: Command): Command {
@@ -18,6 +19,7 @@ export function registerCliCommands(program: Command): Command {
1819
registerEmbedCommand(program);
1920
registerInboxCommand(program);
2021
registerMaintainCommand(program);
22+
registerPatchCommand(program);
2123
registerTailscaleCommands(program);
2224
return program;
2325
}

src/commands/patch.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import matter from 'gray-matter';
6+
import { Command } from 'commander';
7+
import { createVault, type ClawVault } from '../lib/vault.js';
8+
import { patchCommand, registerPatchCommand } from './patch.js';
9+
10+
const createdTempDirs: string[] = [];
11+
12+
async function makeVault(): Promise<{ vaultPath: string; vault: ClawVault }> {
13+
const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-patch-cmd-'));
14+
createdTempDirs.push(vaultPath);
15+
const vault = await createVault(vaultPath, { name: 'patch-test' }, { skipGraph: true });
16+
return { vaultPath, vault };
17+
}
18+
19+
function readDoc(vaultPath: string, id: string): string {
20+
const filePath = path.join(vaultPath, `${id}.md`);
21+
return fs.readFileSync(filePath, 'utf-8');
22+
}
23+
24+
afterEach(() => {
25+
while (createdTempDirs.length > 0) {
26+
const dir = createdTempDirs.pop();
27+
if (dir) {
28+
fs.rmSync(dir, { recursive: true, force: true });
29+
}
30+
}
31+
});
32+
33+
describe('patchCommand', () => {
34+
it('appends content while preserving frontmatter and indexed docs', async () => {
35+
const { vaultPath, vault } = await makeVault();
36+
const target = await vault.store({
37+
category: 'decisions',
38+
title: 'patch-target',
39+
content: 'Line A',
40+
frontmatter: { owner: 'ops' }
41+
});
42+
const untouched = await vault.store({
43+
category: 'inbox',
44+
title: 'untouched',
45+
content: 'Do not change me'
46+
});
47+
48+
await patchCommand(target.id, {
49+
vaultPath,
50+
append: 'Line B'
51+
});
52+
53+
const raw = readDoc(vaultPath, target.id);
54+
const parsed = matter(raw);
55+
expect(parsed.data.owner).toBe('ops');
56+
expect(parsed.content).toContain('Line A');
57+
expect(parsed.content.trimEnd()).toMatch(/Line A\nLine B$/);
58+
59+
const indexPath = path.join(vaultPath, '.clawvault-index.json');
60+
const indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8')) as {
61+
documents: Array<{ id: string }>;
62+
};
63+
const indexedIds = indexData.documents.map((doc) => doc.id);
64+
expect(indexedIds).toContain(target.id);
65+
expect(indexedIds).toContain(untouched.id);
66+
});
67+
68+
it('replaces all occurrences for --replace/--with mode', async () => {
69+
const { vaultPath, vault } = await makeVault();
70+
const target = await vault.store({
71+
category: 'decisions',
72+
title: 'replace-target',
73+
content: 'old value and old value'
74+
});
75+
76+
await patchCommand(target.id, {
77+
vaultPath,
78+
replace: 'old',
79+
with: 'new'
80+
});
81+
82+
const raw = readDoc(vaultPath, target.id);
83+
const parsed = matter(raw);
84+
expect(parsed.content).toContain('new value and new value');
85+
expect(parsed.content).not.toContain('old value');
86+
});
87+
88+
it('upserts an existing markdown section body', async () => {
89+
const { vaultPath, vault } = await makeVault();
90+
const target = await vault.store({
91+
category: 'projects',
92+
title: 'section-existing',
93+
content: [
94+
'Intro',
95+
'',
96+
'## Plan',
97+
'Old plan',
98+
'',
99+
'## Next',
100+
'Keep this'
101+
].join('\n')
102+
});
103+
104+
await patchCommand(target.id, {
105+
vaultPath,
106+
section: '## Plan',
107+
content: 'Updated plan'
108+
});
109+
110+
const raw = readDoc(vaultPath, target.id);
111+
const parsed = matter(raw);
112+
expect(parsed.content).toContain('## Plan\nUpdated plan');
113+
expect(parsed.content).toContain('## Next\nKeep this');
114+
const headingMatches = parsed.content.match(/^## Plan$/gm) ?? [];
115+
expect(headingMatches).toHaveLength(1);
116+
});
117+
118+
it('upserts a missing markdown section by appending it', async () => {
119+
const { vaultPath, vault } = await makeVault();
120+
const target = await vault.store({
121+
category: 'projects',
122+
title: 'section-missing',
123+
content: 'Intro only'
124+
});
125+
126+
await patchCommand(target.id, {
127+
vaultPath,
128+
section: '## Notes',
129+
content: 'Added body'
130+
});
131+
132+
const raw = readDoc(vaultPath, target.id);
133+
const parsed = matter(raw);
134+
expect(parsed.content).toContain('Intro only');
135+
expect(parsed.content.trimEnd()).toMatch(/## Notes\nAdded body$/);
136+
});
137+
});
138+
139+
describe('registerPatchCommand', () => {
140+
it('registers patch command and applies append mode', async () => {
141+
const { vaultPath, vault } = await makeVault();
142+
const target = await vault.store({
143+
category: 'inbox',
144+
title: 'cli-append',
145+
content: 'start'
146+
});
147+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
148+
const program = new Command();
149+
registerPatchCommand(program);
150+
151+
await program.parseAsync(
152+
['patch', target.id, '--append', 'finish', '--vault', vaultPath],
153+
{ from: 'user' }
154+
);
155+
156+
const raw = readDoc(vaultPath, target.id);
157+
const parsed = matter(raw);
158+
expect(parsed.content.trimEnd()).toMatch(/start\nfinish$/);
159+
expect(logSpy).toHaveBeenCalledWith(`Patched: ${target.id}`);
160+
logSpy.mockRestore();
161+
});
162+
163+
it('throws when more than one patch mode is selected', async () => {
164+
const program = new Command();
165+
registerPatchCommand(program);
166+
167+
await expect(
168+
program.parseAsync(
169+
['patch', 'decisions/test', '--append', 'one', '--replace', 'one', '--with', 'two'],
170+
{ from: 'user' }
171+
)
172+
).rejects.toThrow(
173+
'Select exactly one patch mode: --append, --replace with --with, or --section with --content.'
174+
);
175+
});
176+
});

src/commands/patch.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { Command } from 'commander';
2+
import { ClawVault } from '../lib/vault.js';
3+
import { resolveVaultPath } from '../lib/config.js';
4+
import type { Document, PatchOptions } from '../types.js';
5+
6+
export interface PatchCommandOptions {
7+
vaultPath?: string;
8+
append?: string;
9+
replace?: string;
10+
with?: string;
11+
section?: string;
12+
content?: string;
13+
}
14+
15+
function normalizeSectionHeading(heading: string): string {
16+
const trimmed = heading.trim();
17+
if (!trimmed) {
18+
throw new Error('Section heading cannot be empty.');
19+
}
20+
if (trimmed.startsWith('#')) {
21+
return trimmed;
22+
}
23+
return `## ${trimmed}`;
24+
}
25+
26+
function buildSectionAppendText(heading: string, content: string): string {
27+
const headingLine = normalizeSectionHeading(heading);
28+
return `\n${headingLine}\n${content}`;
29+
}
30+
31+
function isSectionNotFoundError(error: unknown): boolean {
32+
return error instanceof Error && error.message.startsWith('Section not found:');
33+
}
34+
35+
function resolvePatchOptions(idOrPath: string, options: PatchCommandOptions): PatchOptions {
36+
const appendSelected = typeof options.append === 'string';
37+
const replaceSelected = typeof options.replace === 'string' || typeof options.with === 'string';
38+
const sectionSelected = typeof options.section === 'string' || typeof options.content === 'string';
39+
const selectedModeCount = [appendSelected, replaceSelected, sectionSelected]
40+
.filter(Boolean)
41+
.length;
42+
43+
if (selectedModeCount !== 1) {
44+
throw new Error(
45+
'Select exactly one patch mode: --append, --replace with --with, or --section with --content.'
46+
);
47+
}
48+
49+
if (appendSelected) {
50+
return {
51+
idOrPath,
52+
mode: 'append',
53+
append: options.append
54+
};
55+
}
56+
57+
if (replaceSelected) {
58+
if (typeof options.replace !== 'string' || typeof options.with !== 'string') {
59+
throw new Error('Replace mode requires both --replace and --with.');
60+
}
61+
return {
62+
idOrPath,
63+
mode: 'replace',
64+
replace: options.replace,
65+
with: options.with
66+
};
67+
}
68+
69+
if (typeof options.section !== 'string' || typeof options.content !== 'string') {
70+
throw new Error('Section mode requires both --section and --content.');
71+
}
72+
73+
return {
74+
idOrPath,
75+
mode: 'content',
76+
section: options.section,
77+
content: options.content
78+
};
79+
}
80+
81+
async function runSectionUpsert(
82+
vault: ClawVault,
83+
idOrPath: string,
84+
section: string,
85+
content: string
86+
): Promise<Document> {
87+
try {
88+
return await vault.patch({
89+
idOrPath,
90+
mode: 'content',
91+
section,
92+
content
93+
});
94+
} catch (error: unknown) {
95+
if (!isSectionNotFoundError(error)) {
96+
throw error;
97+
}
98+
99+
return vault.patch({
100+
idOrPath,
101+
mode: 'append',
102+
append: buildSectionAppendText(section, content)
103+
});
104+
}
105+
}
106+
107+
export async function patchCommand(idOrPath: string, options: PatchCommandOptions): Promise<Document> {
108+
const patchOptions = resolvePatchOptions(idOrPath, options);
109+
const vaultPath = resolveVaultPath({ explicitPath: options.vaultPath });
110+
const vault = new ClawVault(vaultPath);
111+
await vault.load();
112+
113+
if (patchOptions.mode === 'content') {
114+
return runSectionUpsert(
115+
vault,
116+
patchOptions.idOrPath,
117+
patchOptions.section as string,
118+
patchOptions.content as string
119+
);
120+
}
121+
return vault.patch(patchOptions);
122+
}
123+
124+
export function registerPatchCommand(program: Command): void {
125+
program
126+
.command('patch <path>')
127+
.description('Patch an existing memory document')
128+
.option('--append <content>', 'Append text to the end of a document')
129+
.option('--replace <old>', 'Text to find for replacement')
130+
.option('--with <new>', 'Replacement text used with --replace')
131+
.option('--section <heading>', 'Markdown heading to upsert')
132+
.option('--content <body>', 'Section body content used with --section')
133+
.option('-v, --vault <path>', 'Vault path')
134+
.action(async (
135+
pathArg: string,
136+
rawOptions: {
137+
append?: string;
138+
replace?: string;
139+
with?: string;
140+
section?: string;
141+
content?: string;
142+
vault?: string;
143+
}
144+
) => {
145+
const doc = await patchCommand(pathArg, {
146+
vaultPath: rawOptions.vault,
147+
append: rawOptions.append,
148+
replace: rawOptions.replace,
149+
with: rawOptions.with,
150+
section: rawOptions.section,
151+
content: rawOptions.content
152+
});
153+
console.log(`Patched: ${doc.id}`);
154+
});
155+
}

0 commit comments

Comments
 (0)