Skip to content

Commit 9ac0b28

Browse files
committed
Streamline memory tool
1 parent 3368d54 commit 9ac0b28

File tree

3 files changed

+191
-69
lines changed

3 files changed

+191
-69
lines changed

src/cli-formatters.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,7 @@ export function formatSearch(
363363
if (query) titleParts.push(`"${query}"`);
364364
if (intent) titleParts.push(`intent: ${intent}`);
365365
const boxTitle =
366-
titleParts.length > 0
367-
? `Search: ${titleParts.join(` ${g.box.h.repeat(3)} `)}`
368-
: 'Search';
366+
titleParts.length > 0 ? `Search: ${titleParts.join(` ${g.box.h.repeat(3)} `)}` : 'Search';
369367

370368
console.log('');
371369
if (boxLines.length > 0) {

src/cli-memory.ts

Lines changed: 124 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import path from 'path';
6-
import type { Memory, MemoryCategory, MemoryType } from './types/index.js';
6+
import type { Memory } from './types/index.js';
77
import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js';
88
import {
99
appendMemoryFile,
@@ -19,69 +19,100 @@ const MEMORY_CATEGORIES = [
1919
'testing',
2020
'dependencies',
2121
'conventions'
22-
] as const satisfies readonly MemoryCategory[];
22+
] as const;
23+
type CliMemoryCategory = (typeof MEMORY_CATEGORIES)[number];
2324

24-
const MEMORY_TYPES = [
25-
'convention',
26-
'decision',
27-
'gotcha',
28-
'failure'
29-
] as const satisfies readonly MemoryType[];
25+
const MEMORY_TYPES = ['convention', 'decision', 'gotcha', 'failure'] as const;
26+
type CliMemoryType = (typeof MEMORY_TYPES)[number];
3027

3128
const MEMORY_CATEGORY_SET: ReadonlySet<string> = new Set(MEMORY_CATEGORIES);
32-
function isMemoryCategory(value: string): value is MemoryCategory {
29+
function isCliMemoryCategory(value: string): value is CliMemoryCategory {
3330
return MEMORY_CATEGORY_SET.has(value);
3431
}
3532

3633
const MEMORY_TYPE_SET: ReadonlySet<string> = new Set(MEMORY_TYPES);
37-
function isMemoryType(value: string): value is MemoryType {
34+
function isCliMemoryType(value: string): value is CliMemoryType {
3835
return MEMORY_TYPE_SET.has(value);
3936
}
4037

41-
function exitWithError(message: string): never {
42-
console.error(message);
43-
process.exit(1);
44-
}
45-
4638
export async function handleMemoryCli(args: string[]): Promise<void> {
4739
// Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path)
4840
const cliRoot = process.env.CODEBASE_ROOT || process.cwd();
4941
const memoryPath = path.join(cliRoot, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME);
5042
const subcommand = args[0]; // list | add | remove
43+
const useJson = args.includes('--json');
44+
45+
const listUsage =
46+
'Usage: codebase-context memory list [--category <cat>] [--type <type>] [--query <text>] [--json]';
47+
const addUsage =
48+
'Usage: codebase-context memory add --type <type> --category <category> --memory <text> --reason <text> [--json]';
49+
const removeUsage = 'Usage: codebase-context memory remove <id> [--json]';
50+
51+
const exitWithUsageError = (message: string, usage?: string): never => {
52+
if (useJson) {
53+
console.log(
54+
JSON.stringify(
55+
{
56+
status: 'error',
57+
message,
58+
...(usage ? { usage } : {})
59+
},
60+
null,
61+
2
62+
)
63+
);
64+
} else {
65+
console.error(message);
66+
if (usage) console.error(usage);
67+
}
68+
process.exit(1);
69+
};
5170

5271
if (subcommand === 'list') {
5372
const memories = await readMemoriesFile(memoryPath);
54-
const opts: { category?: MemoryCategory; type?: MemoryType; query?: string } = {};
73+
const opts: { category?: CliMemoryCategory; type?: CliMemoryType; query?: string } = {};
5574

5675
for (let i = 1; i < args.length; i++) {
5776
if (args[i] === '--category') {
5877
const value = args[i + 1];
5978
if (!value || value.startsWith('--')) {
60-
exitWithError(
61-
`Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`
79+
exitWithUsageError(
80+
`Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`,
81+
listUsage
6282
);
6383
}
64-
if (!isMemoryCategory(value)) {
65-
exitWithError(
66-
`Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`
84+
85+
if (isCliMemoryCategory(value)) {
86+
opts.category = value;
87+
} else {
88+
exitWithUsageError(
89+
`Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`,
90+
listUsage
6791
);
6892
}
69-
opts.category = value;
7093
i++;
7194
} else if (args[i] === '--type') {
7295
const value = args[i + 1];
7396
if (!value || value.startsWith('--')) {
74-
exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`);
97+
exitWithUsageError(
98+
`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`,
99+
listUsage
100+
);
75101
}
76-
if (!isMemoryType(value)) {
77-
exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`);
102+
103+
if (isCliMemoryType(value)) {
104+
opts.type = value;
105+
} else {
106+
exitWithUsageError(
107+
`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`,
108+
listUsage
109+
);
78110
}
79-
opts.type = value;
80111
i++;
81112
} else if (args[i] === '--query') {
82113
const value = args[i + 1];
83114
if (!value || value.startsWith('--')) {
84-
exitWithError('Error: --query requires a value.');
115+
exitWithUsageError('Error: --query requires a value.', listUsage);
85116
}
86117
opts.query = value;
87118
i++;
@@ -92,7 +123,6 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
92123

93124
const filtered = filterMemories(memories, opts);
94125
const enriched = withConfidence(filtered);
95-
const useJson = args.includes('--json');
96126

97127
if (useJson) {
98128
console.log(JSON.stringify(enriched, null, 2));
@@ -111,101 +141,129 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
111141
}
112142
}
113143
} else if (subcommand === 'add') {
114-
let type: MemoryType = 'decision';
115-
let category: MemoryCategory | undefined;
144+
let type: CliMemoryType = 'decision';
145+
let category: CliMemoryCategory | undefined;
116146
let memory: string | undefined;
117147
let reason: string | undefined;
118148

119149
for (let i = 1; i < args.length; i++) {
120150
if (args[i] === '--type') {
121151
const value = args[i + 1];
122152
if (!value || value.startsWith('--')) {
123-
exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`);
153+
exitWithUsageError(
154+
`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`,
155+
addUsage
156+
);
124157
}
125-
if (!isMemoryType(value)) {
126-
exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`);
158+
159+
if (isCliMemoryType(value)) {
160+
type = value;
161+
} else {
162+
exitWithUsageError(
163+
`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`,
164+
addUsage
165+
);
127166
}
128-
type = value;
129167
i++;
130168
} else if (args[i] === '--category') {
131169
const value = args[i + 1];
132170
if (!value || value.startsWith('--')) {
133-
exitWithError(
134-
`Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`
171+
exitWithUsageError(
172+
`Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`,
173+
addUsage
135174
);
136175
}
137-
if (!isMemoryCategory(value)) {
138-
exitWithError(
139-
`Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`
176+
177+
if (isCliMemoryCategory(value)) {
178+
category = value;
179+
} else {
180+
exitWithUsageError(
181+
`Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`,
182+
addUsage
140183
);
141184
}
142-
category = value;
143185
i++;
144186
} else if (args[i] === '--memory') {
145187
const value = args[i + 1];
146188
if (!value || value.startsWith('--')) {
147-
exitWithError('Error: --memory requires a value.');
189+
exitWithUsageError('Error: --memory requires a value.', addUsage);
148190
}
149191
memory = value;
150192
i++;
151193
} else if (args[i] === '--reason') {
152194
const value = args[i + 1];
153195
if (!value || value.startsWith('--')) {
154-
exitWithError('Error: --reason requires a value.');
196+
exitWithUsageError('Error: --reason requires a value.', addUsage);
155197
}
156198
reason = value;
157199
i++;
200+
} else if (args[i] === '--json') {
201+
// handled above
158202
}
159203
}
160204

161205
if (!category || !memory || !reason) {
162-
console.error(
163-
'Usage: codebase-context memory add --type <type> --category <category> --memory <text> --reason <text>'
164-
);
165-
console.error('Required: --category, --memory, --reason');
166-
process.exit(1);
206+
exitWithUsageError('Error: required flags missing: --category, --memory, --reason', addUsage);
207+
return;
167208
}
168209

210+
const requiredCategory = category;
211+
const requiredMemory = memory;
212+
const requiredReason = reason;
213+
169214
const crypto = await import('crypto');
170-
const hashContent = `${type}:${category}:${memory}:${reason}`;
215+
const hashContent = `${type}:${requiredCategory}:${requiredMemory}:${requiredReason}`;
171216
const hash = crypto.createHash('sha256').update(hashContent).digest('hex');
172217
const id = hash.substring(0, 12);
173218

174219
const newMemory: Memory = {
175220
id,
176221
type,
177-
category,
178-
memory,
179-
reason,
222+
category: requiredCategory,
223+
memory: requiredMemory,
224+
reason: requiredReason,
180225
date: new Date().toISOString()
181226
};
182227
const result = await appendMemoryFile(memoryPath, newMemory);
183228

229+
if (useJson) {
230+
console.log(JSON.stringify(result, null, 2));
231+
return;
232+
}
233+
184234
if (result.status === 'duplicate') {
185235
console.log(`Already exists: [${id}] ${memory}`);
186-
} else {
187-
console.log(`Added: [${id}] ${memory}`);
236+
return;
188237
}
238+
239+
console.log(`Added: [${id}] ${memory}`);
189240
} else if (subcommand === 'remove') {
190-
const id = args[1];
191-
if (!id) {
192-
console.error('Usage: codebase-context memory remove <id>');
193-
process.exit(1);
241+
const id = args.slice(1).find((value) => value !== '--json' && !value.startsWith('--'));
242+
if (id === undefined) {
243+
exitWithUsageError('Error: missing memory id.', removeUsage);
244+
return;
194245
}
195246

196247
const result = await removeMemory(memoryPath, id);
197248
if (result.status === 'not_found') {
198-
console.error(`Memory not found: ${id}`);
249+
if (useJson) {
250+
console.log(JSON.stringify({ status: 'not_found', id }, null, 2));
251+
} else {
252+
console.error(`Memory not found: ${id}`);
253+
}
199254
process.exit(1);
200-
} else {
201-
console.log(`Removed: ${id}`);
202255
}
256+
257+
if (useJson) {
258+
console.log(JSON.stringify({ status: 'removed', id }, null, 2));
259+
return;
260+
}
261+
262+
console.log(`Removed: ${id}`);
203263
} else {
204-
console.error('Usage: codebase-context memory <list|add|remove>');
205-
console.error('');
206-
console.error(' list [--category <cat>] [--type <type>] [--query <text>] [--json]');
207-
console.error(' add --type <type> --category <category> --memory <text> --reason <text>');
208-
console.error(' remove <id>');
209-
process.exit(1);
264+
exitWithUsageError(
265+
'Error: unknown subcommand. Expected: list | add | remove',
266+
'Usage: codebase-context memory <list|add|remove>'
267+
);
210268
}
211269
}

tests/cli.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import path from 'path';
3+
import os from 'os';
4+
import { promises as fs } from 'fs';
25

36
const toolMocks = vi.hoisted(() => ({
47
dispatchTool: vi.fn()
@@ -129,5 +132,68 @@ describe('CLI', () => {
129132
await expect(handleMemoryCli(['list', '--type', 'nope'])).rejects.toThrow(/process\.exit:1/);
130133
expect(errorSpy).toHaveBeenCalled();
131134
});
135+
136+
it('memory add/remove support --json output', async () => {
137+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-memory-json-'));
138+
const originalEnvRoot = process.env.CODEBASE_ROOT;
139+
process.env.CODEBASE_ROOT = tempDir;
140+
141+
try {
142+
logSpy.mockClear();
143+
await handleMemoryCli([
144+
'add',
145+
'--type',
146+
'decision',
147+
'--category',
148+
'tooling',
149+
'--memory',
150+
'Use pnpm, not npm',
151+
'--reason',
152+
'Workspace support and speed',
153+
'--json'
154+
]);
155+
156+
const addedText = String(logSpy.mock.calls.at(-1)?.[0] ?? '');
157+
const added = JSON.parse(addedText) as { status: string; memory?: { id?: string } };
158+
expect(added.status).toBe('added');
159+
const id = added.memory?.id;
160+
expect(typeof id).toBe('string');
161+
162+
logSpy.mockClear();
163+
await handleMemoryCli([
164+
'add',
165+
'--type',
166+
'decision',
167+
'--category',
168+
'tooling',
169+
'--memory',
170+
'Use pnpm, not npm',
171+
'--reason',
172+
'Workspace support and speed',
173+
'--json'
174+
]);
175+
const dupText = String(logSpy.mock.calls.at(-1)?.[0] ?? '');
176+
const dup = JSON.parse(dupText) as { status: string };
177+
expect(dup.status).toBe('duplicate');
178+
179+
logSpy.mockClear();
180+
await handleMemoryCli(['remove', String(id), '--json']);
181+
const removedText = String(logSpy.mock.calls.at(-1)?.[0] ?? '');
182+
const removed = JSON.parse(removedText) as { status: string; id: string };
183+
expect(removed).toEqual({ status: 'removed', id });
184+
185+
logSpy.mockClear();
186+
await expect(handleMemoryCli(['remove', 'does-not-exist', '--json'])).rejects.toThrow(
187+
/process\.exit:1/
188+
);
189+
const notFoundText = String(logSpy.mock.calls.at(-1)?.[0] ?? '');
190+
const notFound = JSON.parse(notFoundText) as { status: string; id: string };
191+
expect(notFound).toEqual({ status: 'not_found', id: 'does-not-exist' });
192+
} finally {
193+
if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT;
194+
else process.env.CODEBASE_ROOT = originalEnvRoot;
195+
await fs.rm(tempDir, { recursive: true, force: true });
196+
}
197+
});
132198
});
133199

0 commit comments

Comments
 (0)