@ivotoby/postgram-cli 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @postgram/cli
2
+
3
+ Command-line client for [Postgram](https://github.com/example/postgram).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @postgram/cli
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Set environment variables or create `~/.pgmrc`:
14
+
15
+ ```bash
16
+ # Option 1: environment variables
17
+ export PGM_API_URL=http://localhost:3100
18
+ export PGM_API_KEY=your-api-key
19
+
20
+ # Option 2: ~/.pgmrc
21
+ echo '{"api_url":"http://localhost:3100","api_key":"your-api-key"}' > ~/.pgmrc
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ # Store an entity
28
+ pgm store "decided to use pgvector" --type memory --tags "decisions,architecture"
29
+
30
+ # Search
31
+ pgm search "pgvector decisions" --limit 5
32
+
33
+ # Recall by ID
34
+ pgm recall <entity-id>
35
+
36
+ # List entities
37
+ pgm list --type memory
38
+
39
+ # Update an entity
40
+ pgm update <entity-id> --content "updated content" --version 1
41
+
42
+ # Delete an entity
43
+ pgm delete <entity-id>
44
+
45
+ # Create a relation
46
+ pgm link <source-id> <target-id> --relation involves
47
+
48
+ # Show graph neighborhood
49
+ pgm expand <entity-id> --depth 2
50
+
51
+ # Tasks
52
+ pgm task add "write integration tests" --context @dev --status next
53
+ pgm task list --status inbox
54
+ pgm task complete <task-id> --version 1
55
+
56
+ # Sync markdown directory
57
+ pgm sync ./notes --repo my-notes
58
+
59
+ # JSON output (all commands)
60
+ pgm store "hello" --json
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ cd cli
67
+ npm install
68
+ npm run build
69
+ npm test
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,162 @@
1
+ import { AppError, ErrorCode } from './errors.js';
2
+ async function parseResponse(response) {
3
+ const contentType = response.headers.get('content-type') ?? '';
4
+ const payload = contentType.includes('application/json')
5
+ ? (await response.json())
6
+ : (await response.text());
7
+ return payload;
8
+ }
9
+ function normalizeErrorBody(body) {
10
+ if (!body || typeof body !== 'object') {
11
+ return {};
12
+ }
13
+ return body;
14
+ }
15
+ async function request(options, path, requestOptions = {}) {
16
+ const headers = {
17
+ Authorization: `Bearer ${options.apiKey}`,
18
+ ...(requestOptions.body === undefined
19
+ ? {}
20
+ : {
21
+ 'Content-Type': 'application/json'
22
+ })
23
+ };
24
+ const init = {
25
+ method: requestOptions.method ?? 'GET',
26
+ headers
27
+ };
28
+ if (requestOptions.body !== undefined) {
29
+ init.body = JSON.stringify(requestOptions.body);
30
+ }
31
+ const response = await fetch(`${options.apiUrl}${path}`, init);
32
+ if (!response.ok) {
33
+ const body = normalizeErrorBody(await parseResponse(response));
34
+ throw new AppError(body.error?.code ?? ErrorCode.INTERNAL, body.error?.message ?? `Request failed with status ${response.status}`, body.error?.details ?? {});
35
+ }
36
+ return parseResponse(response);
37
+ }
38
+ export function createPgmClient(options) {
39
+ return {
40
+ storeEntity(input) {
41
+ return request(options, '/api/entities', {
42
+ method: 'POST',
43
+ body: input
44
+ });
45
+ },
46
+ recallEntity(id) {
47
+ return request(options, `/api/entities/${id}`);
48
+ },
49
+ searchEntities(input) {
50
+ return request(options, '/api/search', {
51
+ method: 'POST',
52
+ body: input
53
+ });
54
+ },
55
+ updateEntity(id, input) {
56
+ return request(options, `/api/entities/${id}`, {
57
+ method: 'PATCH',
58
+ body: input
59
+ });
60
+ },
61
+ deleteEntity(id) {
62
+ return request(options, `/api/entities/${id}`, {
63
+ method: 'DELETE'
64
+ });
65
+ },
66
+ listEntities(input = {}) {
67
+ const params = new URLSearchParams();
68
+ if (input.type) {
69
+ params.set('type', input.type);
70
+ }
71
+ if (input.status) {
72
+ params.set('status', input.status);
73
+ }
74
+ if (input.visibility) {
75
+ params.set('visibility', input.visibility);
76
+ }
77
+ if (input.tags?.length) {
78
+ params.set('tags', input.tags.join(','));
79
+ }
80
+ if (input.limit !== undefined) {
81
+ params.set('limit', String(input.limit));
82
+ }
83
+ if (input.offset !== undefined) {
84
+ params.set('offset', String(input.offset));
85
+ }
86
+ const query = params.toString();
87
+ return request(options, `/api/entities${query ? `?${query}` : ''}`);
88
+ },
89
+ createTask(input) {
90
+ return request(options, '/api/tasks', {
91
+ method: 'POST',
92
+ body: input
93
+ });
94
+ },
95
+ listTasks(input = {}) {
96
+ const params = new URLSearchParams();
97
+ if (input.status) {
98
+ params.set('status', input.status);
99
+ }
100
+ if (input.context) {
101
+ params.set('context', input.context);
102
+ }
103
+ if (input.limit !== undefined) {
104
+ params.set('limit', String(input.limit));
105
+ }
106
+ if (input.offset !== undefined) {
107
+ params.set('offset', String(input.offset));
108
+ }
109
+ return request(options, `/api/tasks?${params.toString()}`);
110
+ },
111
+ updateTask(id, input) {
112
+ return request(options, `/api/tasks/${id}`, {
113
+ method: 'PATCH',
114
+ body: input
115
+ });
116
+ },
117
+ completeTask(id, version) {
118
+ return request(options, `/api/tasks/${id}/complete`, {
119
+ method: 'POST',
120
+ body: { version }
121
+ });
122
+ },
123
+ syncRepo(input) {
124
+ return request(options, '/api/sync', {
125
+ method: 'POST',
126
+ body: input
127
+ });
128
+ },
129
+ getSyncStatus(repo) {
130
+ return request(options, `/api/sync/status/${encodeURIComponent(repo)}`);
131
+ },
132
+ createEdge(input) {
133
+ return request(options, '/api/edges', {
134
+ method: 'POST',
135
+ body: input
136
+ });
137
+ },
138
+ deleteEdge(id) {
139
+ return request(options, `/api/edges/${id}`, {
140
+ method: 'DELETE'
141
+ });
142
+ },
143
+ listEdges(entityId, params = {}) {
144
+ const qs = new URLSearchParams();
145
+ if (params.relation)
146
+ qs.set('relation', params.relation);
147
+ if (params.direction)
148
+ qs.set('direction', params.direction);
149
+ const query = qs.toString();
150
+ return request(options, `/api/entities/${entityId}/edges${query ? `?${query}` : ''}`);
151
+ },
152
+ expandGraph(entityId, params = {}) {
153
+ const qs = new URLSearchParams();
154
+ if (params.depth !== undefined)
155
+ qs.set('depth', String(params.depth));
156
+ if (params.relationTypes?.length)
157
+ qs.set('relation_types', params.relationTypes.join(','));
158
+ const query = qs.toString();
159
+ return request(options, `/api/entities/${entityId}/graph${query ? `?${query}` : ''}`);
160
+ }
161
+ };
162
+ }
@@ -0,0 +1,32 @@
1
+ // Standalone error types for the CLI package.
2
+ // This breaks the circular dependency that exists in the server package
3
+ // between src/util/errors.ts and src/types/api.ts.
4
+ export var ErrorCode;
5
+ (function (ErrorCode) {
6
+ ErrorCode["VALIDATION"] = "VALIDATION";
7
+ ErrorCode["UNAUTHORIZED"] = "UNAUTHORIZED";
8
+ ErrorCode["FORBIDDEN"] = "FORBIDDEN";
9
+ ErrorCode["NOT_FOUND"] = "NOT_FOUND";
10
+ ErrorCode["CONFLICT"] = "CONFLICT";
11
+ ErrorCode["EMBEDDING_FAILED"] = "EMBEDDING_FAILED";
12
+ ErrorCode["INTERNAL"] = "INTERNAL";
13
+ })(ErrorCode || (ErrorCode = {}));
14
+ export class AppError extends Error {
15
+ code;
16
+ details;
17
+ constructor(code, message, details = {}) {
18
+ super(message);
19
+ this.name = 'AppError';
20
+ this.code = code;
21
+ this.details = details;
22
+ }
23
+ }
24
+ export function toErrorResponse(error) {
25
+ return {
26
+ error: {
27
+ code: error.code,
28
+ message: error.message,
29
+ details: error.details
30
+ }
31
+ };
32
+ }
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir, readdir, readFile as fsReadFile, stat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { Command } from 'commander';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { createPgmClient } from './client.js';
10
+ import { handleCliFailure, isJsonMode, parseCommaList, parseJsonObject, printHuman, printJson, readStdinText, resolvePgmConfig, shortId } from './shared.js';
11
+ import { AppError, ErrorCode } from './errors.js';
12
+ function formatStoredEntity(entity) {
13
+ return [
14
+ `${entity.type} ${shortId(entity.id)}${entity.status ? ` [${entity.status}]` : ''}`,
15
+ `visibility: ${entity.visibility}`,
16
+ `tags: ${entity.tags.join(', ') || '-'}`,
17
+ entity.content ? `content: ${entity.content}` : 'content: -'
18
+ ];
19
+ }
20
+ function formatSearchResults(results) {
21
+ if (results.length === 0) {
22
+ return ['No results'];
23
+ }
24
+ const lines = [];
25
+ for (const result of results) {
26
+ lines.push(`${result.entity.type} ${shortId(result.entity.id)} score=${result.score.toFixed(3)}`);
27
+ lines.push(` ${result.chunk_content}`);
28
+ if (result.entity.content && result.entity.content !== result.chunk_content) {
29
+ lines.push(` entity: ${result.entity.content}`);
30
+ }
31
+ }
32
+ return lines;
33
+ }
34
+ function formatTaskList(items) {
35
+ if (items.length === 0) {
36
+ return ['No tasks'];
37
+ }
38
+ return items.flatMap((item) => {
39
+ const metadata = item.metadata;
40
+ const suffix = [
41
+ metadata.context ? `context=${metadata.context}` : undefined,
42
+ metadata.due_date ? `due=${metadata.due_date}` : undefined
43
+ ]
44
+ .filter(Boolean)
45
+ .join(' ');
46
+ return [
47
+ `${shortId(item.id)}${item.status ? ` [${item.status}]` : ''} ${item.content ?? ''}`.trim(),
48
+ suffix ? ` ${suffix}` : ' -'
49
+ ];
50
+ });
51
+ }
52
+ async function resolveStoreContent(content) {
53
+ if (content !== undefined) {
54
+ return content;
55
+ }
56
+ const stdin = await readStdinText();
57
+ if (!stdin) {
58
+ throw new AppError(ErrorCode.VALIDATION, 'content is required');
59
+ }
60
+ return stdin;
61
+ }
62
+ async function runWithClient(command, handler) {
63
+ const json = isJsonMode(command);
64
+ try {
65
+ const config = await resolvePgmConfig();
66
+ const client = createPgmClient(config);
67
+ const result = await handler(client, json);
68
+ if (result !== undefined) {
69
+ if (json) {
70
+ printJson(result);
71
+ }
72
+ else if (Array.isArray(result)) {
73
+ printHuman(result.map(String));
74
+ }
75
+ else {
76
+ printHuman([String(result)]);
77
+ }
78
+ }
79
+ }
80
+ catch (error) {
81
+ await handleCliFailure(error, json);
82
+ }
83
+ }
84
+ async function runBackup(output, encrypt) {
85
+ if (!output) {
86
+ throw new AppError(ErrorCode.VALIDATION, '--output is required for backup');
87
+ }
88
+ const databaseUrl = process.env.DATABASE_URL ?? process.env.PGM_DATABASE_URL;
89
+ if (!databaseUrl) {
90
+ throw new AppError(ErrorCode.VALIDATION, 'DATABASE_URL must be set for backup');
91
+ }
92
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
93
+ let destination = output;
94
+ const backupExtension = encrypt ? '.dump.gpg' : '.dump';
95
+ try {
96
+ const fileStat = await stat(output);
97
+ if (fileStat.isDirectory()) {
98
+ destination = path.join(output, `postgram-backup-${timestamp}${backupExtension}`);
99
+ }
100
+ }
101
+ catch (error) {
102
+ if (error instanceof Error &&
103
+ 'code' in error &&
104
+ error.code === 'ENOENT' &&
105
+ output.endsWith('/')) {
106
+ destination = path.join(output, `postgram-backup-${timestamp}${backupExtension}`);
107
+ }
108
+ }
109
+ await mkdir(path.dirname(destination), { recursive: true });
110
+ const hasLocalPgDump = spawnSync('sh', ['-lc', 'command -v pg_dump >/dev/null']).status === 0;
111
+ const dockerService = process.env.PGM_BACKUP_DOCKER_SERVICE ?? 'postgres';
112
+ const dockerUser = process.env.PGM_BACKUP_DOCKER_USER ?? 'postgram';
113
+ const dockerDatabase = process.env.PGM_BACKUP_DOCKER_DB ?? 'postgram';
114
+ const pgDump = hasLocalPgDump
115
+ ? spawn('pg_dump', [
116
+ '--dbname',
117
+ databaseUrl,
118
+ '--format=custom',
119
+ '--no-owner',
120
+ '--no-privileges'
121
+ ])
122
+ : spawn('docker', [
123
+ 'compose',
124
+ 'exec',
125
+ '-T',
126
+ dockerService,
127
+ 'pg_dump',
128
+ '-U',
129
+ dockerUser,
130
+ '-d',
131
+ dockerDatabase,
132
+ '--format=custom',
133
+ '--no-owner',
134
+ '--no-privileges'
135
+ ]);
136
+ if (encrypt) {
137
+ const passphrase = process.env.PGM_BACKUP_PASSPHRASE;
138
+ if (!passphrase) {
139
+ throw new AppError(ErrorCode.VALIDATION, 'PGM_BACKUP_PASSPHRASE must be set when using --encrypt');
140
+ }
141
+ const gpg = spawn('gpg', [
142
+ '--symmetric',
143
+ '--cipher-algo',
144
+ 'AES256',
145
+ '--batch',
146
+ '--yes',
147
+ '--pinentry-mode',
148
+ 'loopback',
149
+ '--passphrase',
150
+ passphrase,
151
+ '--output',
152
+ destination
153
+ ]);
154
+ const waitForExit = (child) => new Promise((resolve, reject) => {
155
+ child.once('error', reject);
156
+ child.once('close', (code) => {
157
+ if (code === 0) {
158
+ resolve();
159
+ }
160
+ else {
161
+ reject(new AppError(ErrorCode.INTERNAL, `${child.spawnfile} exited with code ${code ?? 'unknown'}`));
162
+ }
163
+ });
164
+ });
165
+ await Promise.all([
166
+ pipeline(pgDump.stdout, gpg.stdin),
167
+ waitForExit(pgDump),
168
+ waitForExit(gpg)
169
+ ]);
170
+ return;
171
+ }
172
+ const file = createWriteStream(destination);
173
+ const waitForExit = (child) => new Promise((resolve, reject) => {
174
+ child.once('error', reject);
175
+ child.once('close', (code) => {
176
+ if (code === 0) {
177
+ resolve();
178
+ }
179
+ else {
180
+ reject(new AppError(ErrorCode.INTERNAL, `${child.spawnfile} exited with code ${code ?? 'unknown'}`));
181
+ }
182
+ });
183
+ });
184
+ await Promise.all([pipeline(pgDump.stdout, file), waitForExit(pgDump)]);
185
+ }
186
+ const program = new Command();
187
+ program
188
+ .name('pgm')
189
+ .description('Postgram human CLI')
190
+ .option('--json', 'emit JSON output');
191
+ program
192
+ .command('store')
193
+ .description('Store an entity')
194
+ .argument('[content]', 'entity content')
195
+ .option('--type <type>', 'entity type', 'memory')
196
+ .option('--visibility <visibility>', 'entity visibility', 'shared')
197
+ .option('--status <status>', 'entity status')
198
+ .option('--tags <tags>', 'comma-separated tags')
199
+ .option('--source <source>', 'entity source')
200
+ .option('--metadata <json>', 'JSON metadata object')
201
+ .action(async (content, options, command) => {
202
+ await runWithClient(command, async (client, json) => {
203
+ const body = await client.storeEntity({
204
+ type: options.type,
205
+ content: await resolveStoreContent(content),
206
+ visibility: options.visibility,
207
+ status: options.status,
208
+ tags: parseCommaList(options.tags),
209
+ source: options.source,
210
+ metadata: parseJsonObject(options.metadata)
211
+ });
212
+ return json
213
+ ? body
214
+ : formatStoredEntity({
215
+ id: body.entity.id,
216
+ type: body.entity.type,
217
+ content: body.entity.content,
218
+ status: body.entity.status,
219
+ visibility: body.entity.visibility,
220
+ tags: body.entity.tags
221
+ });
222
+ });
223
+ });
224
+ program
225
+ .command('search')
226
+ .description('Search stored entities')
227
+ .argument('query', 'search query')
228
+ .option('--type <type>', 'entity type')
229
+ .option('--tags <tags>', 'comma-separated tags')
230
+ .option('--visibility <visibility>', 'entity visibility filter')
231
+ .option('--limit <limit>', 'result limit', '10')
232
+ .option('--threshold <threshold>', 'similarity threshold', '0.35')
233
+ .option('--recency-weight <recencyWeight>', 'recency weight', '0.1')
234
+ .action(async (query, options, command) => {
235
+ await runWithClient(command, async (client, json) => {
236
+ const body = await client.searchEntities({
237
+ query,
238
+ type: options.type,
239
+ tags: parseCommaList(options.tags),
240
+ visibility: options.visibility,
241
+ limit: Number(options.limit),
242
+ threshold: Number(options.threshold),
243
+ recency_weight: Number(options.recencyWeight)
244
+ });
245
+ return json ? body : formatSearchResults(body.results);
246
+ });
247
+ });
248
+ program
249
+ .command('recall')
250
+ .description('Recall an entity by ID')
251
+ .argument('id', 'entity ID')
252
+ .action(async (id, _options, command) => {
253
+ await runWithClient(command, async (client, json) => {
254
+ const body = await client.recallEntity(id);
255
+ return json ? body : formatStoredEntity(body.entity);
256
+ });
257
+ });
258
+ program
259
+ .command('list')
260
+ .description('List entities')
261
+ .option('--type <type>', 'filter by type')
262
+ .option('--status <status>', 'filter by status')
263
+ .option('--visibility <visibility>', 'filter by visibility')
264
+ .option('--tags <tags>', 'comma-separated tags')
265
+ .option('--limit <limit>', 'result limit', '50')
266
+ .option('--offset <offset>', 'result offset', '0')
267
+ .action(async (options, command) => {
268
+ await runWithClient(command, async (client, json) => {
269
+ const body = await client.listEntities({
270
+ type: options.type,
271
+ status: options.status,
272
+ visibility: options.visibility,
273
+ tags: parseCommaList(options.tags),
274
+ limit: Number(options.limit),
275
+ offset: Number(options.offset)
276
+ });
277
+ if (json) {
278
+ return body;
279
+ }
280
+ if (body.items.length === 0) {
281
+ return ['No entities'];
282
+ }
283
+ const lines = body.items.flatMap((item) => {
284
+ const preview = item.content
285
+ ? item.content.length > 60
286
+ ? `${item.content.slice(0, 60)}...`
287
+ : item.content
288
+ : '-';
289
+ return [
290
+ `${item.type} ${shortId(item.id)} ${preview}`,
291
+ ` tags: ${item.tags.join(', ') || '-'} | ${item.visibility} | ${item.created_at.slice(0, 10)}`
292
+ ];
293
+ });
294
+ lines.push('');
295
+ lines.push(`${body.total} entities (showing ${body.offset + 1}-${body.offset + body.items.length})`);
296
+ return lines;
297
+ });
298
+ });
299
+ program
300
+ .command('update')
301
+ .description('Update an entity')
302
+ .argument('id', 'entity ID')
303
+ .option('--content <content>', 'updated content')
304
+ .option('--visibility <visibility>', 'updated visibility')
305
+ .option('--status <status>', 'updated status')
306
+ .option('--tags <tags>', 'comma-separated tags')
307
+ .option('--source <source>', 'updated source')
308
+ .option('--metadata <json>', 'JSON metadata object')
309
+ .option('--version <version>', 'expected version')
310
+ .option('--force', 'retry using the latest version on conflict')
311
+ .action(async (id, options, command) => {
312
+ await runWithClient(command, async (client, json) => {
313
+ const payload = {
314
+ content: options.content,
315
+ visibility: options.visibility,
316
+ status: options.status,
317
+ tags: parseCommaList(options.tags),
318
+ source: options.source,
319
+ metadata: parseJsonObject(options.metadata)
320
+ };
321
+ const updateOnce = async (version) => client.updateEntity(id, {
322
+ version,
323
+ ...payload
324
+ });
325
+ let body;
326
+ if (options.version !== undefined) {
327
+ body = await updateOnce(Number(options.version));
328
+ }
329
+ else if (options.force) {
330
+ const current = await client.recallEntity(id);
331
+ body = await updateOnce(current.entity.version);
332
+ }
333
+ else {
334
+ throw new Error('--version is required unless --force is set');
335
+ }
336
+ return json ? body : formatStoredEntity(body.entity);
337
+ });
338
+ });
339
+ program
340
+ .command('delete')
341
+ .description('Soft delete an entity')
342
+ .argument('id', 'entity ID')
343
+ .action(async (id, _options, command) => {
344
+ await runWithClient(command, async (client, json) => {
345
+ const body = await client.deleteEntity(id);
346
+ return json ? body : [`Deleted ${shortId(body.id)}`];
347
+ });
348
+ });
349
+ const taskCommand = program.command('task').description('Task commands');
350
+ taskCommand
351
+ .command('add')
352
+ .description('Create a task')
353
+ .argument('[content]', 'task content')
354
+ .option('--context <context>', 'GTD context')
355
+ .option('--status <status>', 'task status', 'inbox')
356
+ .option('--due <dueDate>', 'due date')
357
+ .option('--tags <tags>', 'comma-separated tags')
358
+ .option('--visibility <visibility>', 'task visibility', 'shared')
359
+ .option('--metadata <json>', 'JSON metadata object')
360
+ .action(async (content, options, command) => {
361
+ await runWithClient(command, async (client, json) => {
362
+ const taskContent = await resolveStoreContent(content);
363
+ const body = await client.createTask({
364
+ content: taskContent,
365
+ context: options.context,
366
+ status: options.status,
367
+ due_date: options.due,
368
+ tags: parseCommaList(options.tags),
369
+ visibility: options.visibility,
370
+ metadata: parseJsonObject(options.metadata)
371
+ });
372
+ return json
373
+ ? body
374
+ : formatStoredEntity({
375
+ id: body.entity.id,
376
+ type: body.entity.type,
377
+ content: body.entity.content,
378
+ status: body.entity.status,
379
+ visibility: body.entity.visibility,
380
+ tags: body.entity.tags
381
+ });
382
+ });
383
+ });
384
+ taskCommand
385
+ .command('list')
386
+ .description('List tasks')
387
+ .option('--status <status>', 'filter by status')
388
+ .option('--context <context>', 'filter by context')
389
+ .option('--limit <limit>', 'result limit', '50')
390
+ .option('--offset <offset>', 'result offset', '0')
391
+ .action(async (options, command) => {
392
+ await runWithClient(command, async (client, json) => {
393
+ const body = await client.listTasks({
394
+ status: options.status,
395
+ context: options.context,
396
+ limit: Number(options.limit),
397
+ offset: Number(options.offset)
398
+ });
399
+ return json ? body : formatTaskList(body.items);
400
+ });
401
+ });
402
+ taskCommand
403
+ .command('update')
404
+ .description('Update a task')
405
+ .argument('id', 'task ID')
406
+ .option('--content <content>', 'updated content')
407
+ .option('--context <context>', 'updated context')
408
+ .option('--status <status>', 'updated status')
409
+ .option('--due <dueDate>', 'updated due date')
410
+ .option('--tags <tags>', 'comma-separated tags')
411
+ .option('--visibility <visibility>', 'updated task visibility')
412
+ .option('--metadata <json>', 'JSON metadata object')
413
+ .option('--version <version>', 'expected version', '')
414
+ .action(async (id, options, command) => {
415
+ await runWithClient(command, async (client, json) => {
416
+ if (!options.version) {
417
+ throw new AppError(ErrorCode.VALIDATION, '--version is required');
418
+ }
419
+ const body = await client.updateTask(id, {
420
+ version: Number(options.version),
421
+ content: options.content,
422
+ context: options.context,
423
+ status: options.status,
424
+ due_date: options.due,
425
+ tags: parseCommaList(options.tags),
426
+ visibility: options.visibility,
427
+ metadata: parseJsonObject(options.metadata)
428
+ });
429
+ return json
430
+ ? body
431
+ : formatStoredEntity({
432
+ id: body.entity.id,
433
+ type: body.entity.type,
434
+ content: body.entity.content,
435
+ status: body.entity.status,
436
+ visibility: body.entity.visibility,
437
+ tags: body.entity.tags
438
+ });
439
+ });
440
+ });
441
+ taskCommand
442
+ .command('complete')
443
+ .description('Mark a task complete')
444
+ .argument('id', 'task ID')
445
+ .option('--version <version>', 'expected version')
446
+ .action(async (id, options, command) => {
447
+ await runWithClient(command, async (client, json) => {
448
+ if (options.version === undefined) {
449
+ throw new AppError(ErrorCode.VALIDATION, '--version is required');
450
+ }
451
+ const body = await client.completeTask(id, Number(options.version));
452
+ return json
453
+ ? body
454
+ : formatStoredEntity({
455
+ id: body.entity.id,
456
+ type: body.entity.type,
457
+ content: body.entity.content,
458
+ status: body.entity.status,
459
+ visibility: body.entity.visibility,
460
+ tags: body.entity.tags
461
+ });
462
+ });
463
+ });
464
+ program
465
+ .command('backup')
466
+ .description('Create a database backup')
467
+ .option('--output <path>', 'backup output path')
468
+ .option('--encrypt', 'encrypt the backup with GPG')
469
+ .action(async (options, command) => {
470
+ const json = isJsonMode(command);
471
+ try {
472
+ await runBackup(options.output, Boolean(options.encrypt));
473
+ if (json) {
474
+ printJson({ ok: true });
475
+ }
476
+ else {
477
+ printHuman(['Backup completed']);
478
+ }
479
+ }
480
+ catch (error) {
481
+ await handleCliFailure(error, json);
482
+ }
483
+ });
484
+ program
485
+ .command('sync')
486
+ .description('Sync a local directory of markdown files')
487
+ .argument('<dir>', 'directory path to sync')
488
+ .option('--repo <name>', 'repo identifier (defaults to directory name)')
489
+ .option('--dry-run', 'show what would change without syncing')
490
+ .option('--quiet', 'suppress output')
491
+ .action(async (dir, options, command) => {
492
+ const json = isJsonMode(command);
493
+ try {
494
+ const resolvedDir = path.resolve(dir);
495
+ const repoName = options.repo ?? path.basename(resolvedDir);
496
+ const files = [];
497
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.obsidian', '.trash']);
498
+ async function walk(dirPath, prefix) {
499
+ const entries = await readdir(dirPath, { withFileTypes: true });
500
+ for (const entry of entries) {
501
+ if (entry.isDirectory()) {
502
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
503
+ await walk(path.join(dirPath, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
504
+ }
505
+ }
506
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
507
+ const fullPath = path.join(dirPath, entry.name);
508
+ const content = await fsReadFile(fullPath, 'utf8');
509
+ const sha = createHash('sha256').update(content).digest('hex');
510
+ const relativePath = prefix
511
+ ? `${prefix}/${entry.name}`
512
+ : entry.name;
513
+ files.push({ path: relativePath, sha, content });
514
+ }
515
+ }
516
+ }
517
+ await walk(resolvedDir, '');
518
+ const config = await resolvePgmConfig();
519
+ const client = createPgmClient(config);
520
+ if (options.dryRun) {
521
+ const status = await client.getSyncStatus(repoName);
522
+ const existingByPath = new Map(status.files.map((f) => [f.path, f]));
523
+ const incomingPaths = new Set(files.map((f) => f.path));
524
+ let newCount = 0;
525
+ let changedCount = 0;
526
+ let unchangedCount = 0;
527
+ for (const file of files) {
528
+ const existing = existingByPath.get(file.path);
529
+ if (!existing) {
530
+ newCount += 1;
531
+ }
532
+ else if (existing.sha !== file.sha) {
533
+ changedCount += 1;
534
+ }
535
+ else {
536
+ unchangedCount += 1;
537
+ }
538
+ }
539
+ let deletedCount = 0;
540
+ for (const [existingPath, entry] of existingByPath) {
541
+ if (!incomingPaths.has(existingPath) && entry.syncStatus !== 'stale') {
542
+ deletedCount += 1;
543
+ }
544
+ }
545
+ const result = {
546
+ created: newCount,
547
+ updated: changedCount,
548
+ unchanged: unchangedCount,
549
+ deleted: deletedCount
550
+ };
551
+ if (json) {
552
+ printJson(result);
553
+ }
554
+ else if (!options.quiet) {
555
+ printHuman([
556
+ `Dry run ${repoName}: ${result.created} to create, ${result.updated} to update, ${result.unchanged} unchanged, ${result.deleted} to delete`
557
+ ]);
558
+ }
559
+ return;
560
+ }
561
+ const result = await client.syncRepo({ repo: repoName, files });
562
+ if (json) {
563
+ printJson(result);
564
+ }
565
+ else if (!options.quiet) {
566
+ printHuman([
567
+ `Synced ${repoName}: ${result.created} created, ${result.updated} updated, ${result.unchanged} unchanged, ${result.deleted} deleted`
568
+ ]);
569
+ }
570
+ }
571
+ catch (error) {
572
+ await handleCliFailure(error, json);
573
+ }
574
+ });
575
+ program
576
+ .command('link')
577
+ .description('Create an edge between two entities')
578
+ .argument('<source-id>', 'source entity ID')
579
+ .argument('<target-id>', 'target entity ID')
580
+ .requiredOption('--relation <relation>', 'relationship type')
581
+ .option('--confidence <n>', 'confidence score 0-1', '1.0')
582
+ .action(async (sourceId, targetId, options, command) => {
583
+ await runWithClient(command, async (client, json) => {
584
+ const body = await client.createEdge({
585
+ source_id: sourceId,
586
+ target_id: targetId,
587
+ relation: options.relation,
588
+ confidence: Number(options.confidence)
589
+ });
590
+ if (json)
591
+ return body;
592
+ return [`Linked ${shortId(sourceId)} → ${shortId(targetId)} (${options.relation})`];
593
+ });
594
+ });
595
+ program
596
+ .command('unlink')
597
+ .description('Delete an edge')
598
+ .argument('<edge-id>', 'edge ID')
599
+ .action(async (edgeId, _options, command) => {
600
+ await runWithClient(command, async (client, json) => {
601
+ const body = await client.deleteEdge(edgeId);
602
+ return json ? body : [`Deleted edge ${shortId(edgeId)}`];
603
+ });
604
+ });
605
+ program
606
+ .command('expand')
607
+ .description('Show graph neighborhood of an entity')
608
+ .argument('<entity-id>', 'entity ID')
609
+ .option('--depth <n>', 'traversal depth (1-3)', '1')
610
+ .option('--relation <types>', 'comma-separated relation types')
611
+ .action(async (entityId, options, command) => {
612
+ await runWithClient(command, async (client, json) => {
613
+ const relationTypes = parseCommaList(options.relation);
614
+ const body = await client.expandGraph(entityId, {
615
+ depth: Number(options.depth),
616
+ ...(relationTypes !== undefined ? { relationTypes } : {})
617
+ });
618
+ if (json)
619
+ return body;
620
+ const lines = [];
621
+ lines.push(`Graph for ${shortId(entityId)}:`);
622
+ lines.push(` ${body.entities.length} entities, ${body.edges.length} edges`);
623
+ for (const edge of body.edges) {
624
+ lines.push(` ${shortId(edge.source_id)} → ${shortId(edge.target_id)} (${edge.relation})`);
625
+ }
626
+ return lines;
627
+ });
628
+ });
629
+ await program.parseAsync(process.argv);
@@ -0,0 +1,94 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { AppError, ErrorCode, toErrorResponse } from './errors.js';
5
+ export function isJsonMode(command) {
6
+ return Boolean(command.optsWithGlobals().json);
7
+ }
8
+ export function parseCommaList(value) {
9
+ if (value === undefined) {
10
+ return undefined;
11
+ }
12
+ const items = value
13
+ .split(',')
14
+ .map((item) => item.trim())
15
+ .filter(Boolean);
16
+ return items.length > 0 ? items : undefined;
17
+ }
18
+ export function parseJsonObject(value, fallback = {}) {
19
+ if (value === undefined) {
20
+ return fallback;
21
+ }
22
+ const parsed = JSON.parse(value);
23
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
24
+ throw new AppError(ErrorCode.VALIDATION, 'Metadata must be a JSON object');
25
+ }
26
+ return parsed;
27
+ }
28
+ export async function readStdinText() {
29
+ if (process.stdin.isTTY) {
30
+ return '';
31
+ }
32
+ return await new Promise((resolve, reject) => {
33
+ let data = '';
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', (chunk) => {
36
+ data += chunk;
37
+ });
38
+ process.stdin.on('end', () => resolve(data.trim()));
39
+ process.stdin.on('error', reject);
40
+ });
41
+ }
42
+ function normalizeUrl(url) {
43
+ return url.replace(/\/+$/, '');
44
+ }
45
+ export async function resolvePgmConfig() {
46
+ const envUrl = process.env.PGM_API_URL;
47
+ const envKey = process.env.PGM_API_KEY;
48
+ if (envUrl && envKey) {
49
+ return {
50
+ apiUrl: normalizeUrl(envUrl),
51
+ apiKey: envKey
52
+ };
53
+ }
54
+ const rcPath = path.join(os.homedir(), '.pgmrc');
55
+ try {
56
+ const raw = await readFile(rcPath, 'utf8');
57
+ const parsed = JSON.parse(raw);
58
+ if (!parsed.api_url || !parsed.api_key) {
59
+ throw new Error('missing api_url or api_key');
60
+ }
61
+ return {
62
+ apiUrl: normalizeUrl(parsed.api_url),
63
+ apiKey: parsed.api_key
64
+ };
65
+ }
66
+ catch (error) {
67
+ throw new AppError(ErrorCode.VALIDATION, 'PGM_API_URL and PGM_API_KEY must be set or ~/.pgmrc must exist', error instanceof Error ? { cause: error.message } : {});
68
+ }
69
+ }
70
+ export function shortId(id) {
71
+ return id.slice(0, 8);
72
+ }
73
+ export function printJson(value) {
74
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
75
+ }
76
+ export function printHuman(lines) {
77
+ process.stdout.write(`${lines.join('\n')}\n`);
78
+ }
79
+ export function formatAppError(error) {
80
+ const payload = toErrorResponse(error);
81
+ return [`${payload.error.code}: ${payload.error.message}`];
82
+ }
83
+ export async function handleCliFailure(error, json) {
84
+ const appError = error instanceof AppError
85
+ ? error
86
+ : new AppError(ErrorCode.INTERNAL, error instanceof Error ? error.message : 'Unexpected CLI failure');
87
+ if (json) {
88
+ printJson(toErrorResponse(appError));
89
+ }
90
+ else {
91
+ printHuman(formatAppError(appError));
92
+ }
93
+ process.exitCode = 1;
94
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AppError, ErrorCode, toErrorResponse } from '../../src/errors.js';
3
+ describe('AppError', () => {
4
+ it('creates an error with code and message', () => {
5
+ const error = new AppError(ErrorCode.VALIDATION, 'test message');
6
+ expect(error.code).toBe(ErrorCode.VALIDATION);
7
+ expect(error.message).toBe('test message');
8
+ expect(error.details).toEqual({});
9
+ });
10
+ it('creates an error with details', () => {
11
+ const error = new AppError(ErrorCode.INTERNAL, 'fail', { key: 'value' });
12
+ expect(error.details).toEqual({ key: 'value' });
13
+ });
14
+ });
15
+ describe('toErrorResponse', () => {
16
+ it('formats error response', () => {
17
+ const error = new AppError(ErrorCode.NOT_FOUND, 'missing', { id: 'abc' });
18
+ const response = toErrorResponse(error);
19
+ expect(response).toEqual({
20
+ error: {
21
+ code: 'NOT_FOUND',
22
+ message: 'missing',
23
+ details: { id: 'abc' }
24
+ }
25
+ });
26
+ });
27
+ });
28
+ describe('ErrorCode', () => {
29
+ it('contains all expected codes', () => {
30
+ expect(ErrorCode.VALIDATION).toBe('VALIDATION');
31
+ expect(ErrorCode.UNAUTHORIZED).toBe('UNAUTHORIZED');
32
+ expect(ErrorCode.FORBIDDEN).toBe('FORBIDDEN');
33
+ expect(ErrorCode.NOT_FOUND).toBe('NOT_FOUND');
34
+ expect(ErrorCode.CONFLICT).toBe('CONFLICT');
35
+ expect(ErrorCode.EMBEDDING_FAILED).toBe('EMBEDDING_FAILED');
36
+ expect(ErrorCode.INTERNAL).toBe('INTERNAL');
37
+ });
38
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseCommaList, parseJsonObject, shortId } from '../../src/shared.js';
3
+ import { AppError, ErrorCode } from '../../src/errors.js';
4
+ describe('parseCommaList', () => {
5
+ it('returns undefined for undefined input', () => {
6
+ expect(parseCommaList(undefined)).toBeUndefined();
7
+ });
8
+ it('splits comma-separated values and trims whitespace', () => {
9
+ expect(parseCommaList('a, b, c')).toEqual(['a', 'b', 'c']);
10
+ });
11
+ it('filters empty strings', () => {
12
+ expect(parseCommaList('a,,b')).toEqual(['a', 'b']);
13
+ });
14
+ it('returns undefined for all-empty result', () => {
15
+ expect(parseCommaList(' , , ')).toBeUndefined();
16
+ });
17
+ });
18
+ describe('parseJsonObject', () => {
19
+ it('returns fallback for undefined', () => {
20
+ expect(parseJsonObject(undefined)).toEqual({});
21
+ });
22
+ it('parses valid JSON object', () => {
23
+ expect(parseJsonObject('{"key":"value"}')).toEqual({ key: 'value' });
24
+ });
25
+ it('throws for non-object JSON', () => {
26
+ expect(() => parseJsonObject('[1,2]')).toThrow(AppError);
27
+ });
28
+ it('throws for invalid JSON', () => {
29
+ expect(() => parseJsonObject('not json')).toThrow();
30
+ });
31
+ });
32
+ describe('shortId', () => {
33
+ it('returns first 8 characters', () => {
34
+ expect(shortId('abcdefgh-1234-5678')).toBe('abcdefgh');
35
+ });
36
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ environment: 'node',
5
+ include: ['tests/**/*.test.ts']
6
+ }
7
+ });
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@ivotoby/postgram-cli",
3
+ "version": "0.1.0",
4
+ "description": "Postgram CLI — store, search, and manage entities from the command line",
5
+ "type": "module",
6
+ "bin": {
7
+ "pgm": "dist/src/pgm.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ivo-toby/postgram.git",
16
+ "directory": "cli"
17
+ },
18
+ "homepage": "https://github.com/ivo-toby/postgram#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/ivo-toby/postgram/issues"
21
+ },
22
+ "keywords": [
23
+ "postgram",
24
+ "pgm",
25
+ "knowledge-base",
26
+ "cli",
27
+ "pgvector",
28
+ "embeddings"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "lint": "eslint .",
38
+ "format": "prettier --write .",
39
+ "semantic-release": "semantic-release"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^14.0.1"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "^9.38.0",
46
+ "@semantic-release/changelog": "^6.0.3",
47
+ "@semantic-release/commit-analyzer": "^13.0.1",
48
+ "@semantic-release/git": "^10.0.1",
49
+ "@semantic-release/github": "^11.0.6",
50
+ "@semantic-release/npm": "^12.0.2",
51
+ "@semantic-release/release-notes-generator": "^14.1.0",
52
+ "conventional-changelog-conventionalcommits": "^9.3.1",
53
+ "eslint": "^9.38.0",
54
+ "semantic-release": "^25.0.3",
55
+ "typescript": "^5.9.3",
56
+ "typescript-eslint": "^8.46.1",
57
+ "vitest": "^3.2.4"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ },
62
+ "license": "MIT"
63
+ }