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: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerReflectCommand } from '../commands/reflect.js';
import { registerEmbedCommand } from '../commands/embed.js';
import { registerInboxCommand } from '../commands/inbox.js';
import { registerMaintainCommand } from '../commands/maintain.js';
import { registerPatchCommand } from '../commands/patch.js';
import { registerTailscaleCommands } from '../commands/tailscale.js';

export function registerCliCommands(program: Command): Command {
Expand All @@ -18,6 +19,7 @@ export function registerCliCommands(program: Command): Command {
registerEmbedCommand(program);
registerInboxCommand(program);
registerMaintainCommand(program);
registerPatchCommand(program);
registerTailscaleCommands(program);
return program;
}
176 changes: 176 additions & 0 deletions src/commands/patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import matter from 'gray-matter';
import { Command } from 'commander';
import { createVault, type ClawVault } from '../lib/vault.js';
import { patchCommand, registerPatchCommand } from './patch.js';

const createdTempDirs: string[] = [];

async function makeVault(): Promise<{ vaultPath: string; vault: ClawVault }> {
const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-patch-cmd-'));
createdTempDirs.push(vaultPath);
const vault = await createVault(vaultPath, { name: 'patch-test' }, { skipGraph: true });
return { vaultPath, vault };
}

function readDoc(vaultPath: string, id: string): string {
const filePath = path.join(vaultPath, `${id}.md`);
return fs.readFileSync(filePath, 'utf-8');
}

afterEach(() => {
while (createdTempDirs.length > 0) {
const dir = createdTempDirs.pop();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});

describe('patchCommand', () => {
it('appends content while preserving frontmatter and indexed docs', async () => {
const { vaultPath, vault } = await makeVault();
const target = await vault.store({
category: 'decisions',
title: 'patch-target',
content: 'Line A',
frontmatter: { owner: 'ops' }
});
const untouched = await vault.store({
category: 'inbox',
title: 'untouched',
content: 'Do not change me'
});

await patchCommand(target.id, {
vaultPath,
append: 'Line B'
});

const raw = readDoc(vaultPath, target.id);
const parsed = matter(raw);
expect(parsed.data.owner).toBe('ops');
expect(parsed.content).toContain('Line A');
expect(parsed.content.trimEnd()).toMatch(/Line A\nLine B$/);

const indexPath = path.join(vaultPath, '.clawvault-index.json');
const indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8')) as {
documents: Array<{ id: string }>;
};
const indexedIds = indexData.documents.map((doc) => doc.id);
expect(indexedIds).toContain(target.id);
expect(indexedIds).toContain(untouched.id);
});

it('replaces all occurrences for --replace/--with mode', async () => {
const { vaultPath, vault } = await makeVault();
const target = await vault.store({
category: 'decisions',
title: 'replace-target',
content: 'old value and old value'
});

await patchCommand(target.id, {
vaultPath,
replace: 'old',
with: 'new'
});

const raw = readDoc(vaultPath, target.id);
const parsed = matter(raw);
expect(parsed.content).toContain('new value and new value');
expect(parsed.content).not.toContain('old value');
});

it('upserts an existing markdown section body', async () => {
const { vaultPath, vault } = await makeVault();
const target = await vault.store({
category: 'projects',
title: 'section-existing',
content: [
'Intro',
'',
'## Plan',
'Old plan',
'',
'## Next',
'Keep this'
].join('\n')
});

await patchCommand(target.id, {
vaultPath,
section: '## Plan',
content: 'Updated plan'
});

const raw = readDoc(vaultPath, target.id);
const parsed = matter(raw);
expect(parsed.content).toContain('## Plan\nUpdated plan');
expect(parsed.content).toContain('## Next\nKeep this');
const headingMatches = parsed.content.match(/^## Plan$/gm) ?? [];
expect(headingMatches).toHaveLength(1);
});

it('upserts a missing markdown section by appending it', async () => {
const { vaultPath, vault } = await makeVault();
const target = await vault.store({
category: 'projects',
title: 'section-missing',
content: 'Intro only'
});

await patchCommand(target.id, {
vaultPath,
section: '## Notes',
content: 'Added body'
});

const raw = readDoc(vaultPath, target.id);
const parsed = matter(raw);
expect(parsed.content).toContain('Intro only');
expect(parsed.content.trimEnd()).toMatch(/## Notes\nAdded body$/);
});
});

describe('registerPatchCommand', () => {
it('registers patch command and applies append mode', async () => {
const { vaultPath, vault } = await makeVault();
const target = await vault.store({
category: 'inbox',
title: 'cli-append',
content: 'start'
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const program = new Command();
registerPatchCommand(program);

await program.parseAsync(
['patch', target.id, '--append', 'finish', '--vault', vaultPath],
{ from: 'user' }
);

const raw = readDoc(vaultPath, target.id);
const parsed = matter(raw);
expect(parsed.content.trimEnd()).toMatch(/start\nfinish$/);
expect(logSpy).toHaveBeenCalledWith(`Patched: ${target.id}`);
logSpy.mockRestore();
});

it('throws when more than one patch mode is selected', async () => {
const program = new Command();
registerPatchCommand(program);

await expect(
program.parseAsync(
['patch', 'decisions/test', '--append', 'one', '--replace', 'one', '--with', 'two'],
{ from: 'user' }
)
).rejects.toThrow(
'Select exactly one patch mode: --append, --replace with --with, or --section with --content.'
);
});
});
155 changes: 155 additions & 0 deletions src/commands/patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { Command } from 'commander';
import { ClawVault } from '../lib/vault.js';
import { resolveVaultPath } from '../lib/config.js';
import type { Document, PatchOptions } from '../types.js';

export interface PatchCommandOptions {
vaultPath?: string;
append?: string;
replace?: string;
with?: string;
section?: string;
content?: string;
}

function normalizeSectionHeading(heading: string): string {
const trimmed = heading.trim();
if (!trimmed) {
throw new Error('Section heading cannot be empty.');
}
if (trimmed.startsWith('#')) {
return trimmed;
}
return `## ${trimmed}`;
}

function buildSectionAppendText(heading: string, content: string): string {
const headingLine = normalizeSectionHeading(heading);
return `\n${headingLine}\n${content}`;
}

function isSectionNotFoundError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith('Section not found:');
}

function resolvePatchOptions(idOrPath: string, options: PatchCommandOptions): PatchOptions {
const appendSelected = typeof options.append === 'string';
const replaceSelected = typeof options.replace === 'string' || typeof options.with === 'string';
const sectionSelected = typeof options.section === 'string' || typeof options.content === 'string';
const selectedModeCount = [appendSelected, replaceSelected, sectionSelected]
.filter(Boolean)
.length;

if (selectedModeCount !== 1) {
throw new Error(
'Select exactly one patch mode: --append, --replace with --with, or --section with --content.'
);
}

if (appendSelected) {
return {
idOrPath,
mode: 'append',
append: options.append
};
}

if (replaceSelected) {
if (typeof options.replace !== 'string' || typeof options.with !== 'string') {
throw new Error('Replace mode requires both --replace and --with.');
}
return {
idOrPath,
mode: 'replace',
replace: options.replace,
with: options.with
};
}

if (typeof options.section !== 'string' || typeof options.content !== 'string') {
throw new Error('Section mode requires both --section and --content.');
}

return {
idOrPath,
mode: 'content',
section: options.section,
content: options.content
};
}

async function runSectionUpsert(
vault: ClawVault,
idOrPath: string,
section: string,
content: string
): Promise<Document> {
try {
return await vault.patch({
idOrPath,
mode: 'content',
section,
content
});
} catch (error: unknown) {
if (!isSectionNotFoundError(error)) {
throw error;
}

return vault.patch({
idOrPath,
mode: 'append',
append: buildSectionAppendText(section, content)
});
}
}

export async function patchCommand(idOrPath: string, options: PatchCommandOptions): Promise<Document> {
const patchOptions = resolvePatchOptions(idOrPath, options);
const vaultPath = resolveVaultPath({ explicitPath: options.vaultPath });
const vault = new ClawVault(vaultPath);
await vault.load();

if (patchOptions.mode === 'content') {
return runSectionUpsert(
vault,
patchOptions.idOrPath,
patchOptions.section as string,
patchOptions.content as string
);
}
return vault.patch(patchOptions);
}

export function registerPatchCommand(program: Command): void {
program
.command('patch <path>')
.description('Patch an existing memory document')
.option('--append <content>', 'Append text to the end of a document')
.option('--replace <old>', 'Text to find for replacement')
.option('--with <new>', 'Replacement text used with --replace')
.option('--section <heading>', 'Markdown heading to upsert')
.option('--content <body>', 'Section body content used with --section')
.option('-v, --vault <path>', 'Vault path')
.action(async (
pathArg: string,
rawOptions: {
append?: string;
replace?: string;
with?: string;
section?: string;
content?: string;
vault?: string;
}
) => {
const doc = await patchCommand(pathArg, {
vaultPath: rawOptions.vault,
append: rawOptions.append,
replace: rawOptions.replace,
with: rawOptions.with,
section: rawOptions.section,
content: rawOptions.content
});
console.log(`Patched: ${doc.id}`);
});
}
Loading