sidecar-cli 0.1.1 → 0.1.2

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 CHANGED
@@ -21,6 +21,12 @@ Install globally (stable):
21
21
  npm install -g sidecar-cli
22
22
  ```
23
23
 
24
+ Note on npm deprecation warnings:
25
+
26
+ - You may see a transitive warning for `prebuild-install@7.1.3` during install.
27
+ - This currently comes from `better-sqlite3` (Sidecar's SQLite dependency), not from Sidecar code directly.
28
+ - Sidecar still installs/works normally; we will update when upstream dependency chain moves off it.
29
+
24
30
  Install beta:
25
31
 
26
32
  ```bash
@@ -103,7 +109,11 @@ Global:
103
109
 
104
110
  - `sidecar init [--force] [--name <project-name>] [--json]`
105
111
  - `sidecar status [--json]`
112
+ - `sidecar preferences show [--json]`
113
+ - `sidecar ui [--no-open] [--port <port>] [--install-only] [--project <path>] [--reinstall]`
106
114
  - `sidecar capabilities --json`
115
+ - `sidecar event add ... [--json]`
116
+ - `sidecar export [--format json|jsonl] [--output <path>]`
107
117
  - `sidecar help`
108
118
 
109
119
  Context and summary:
@@ -168,7 +178,18 @@ Optional local enforcement:
168
178
  npm run install:hooks
169
179
  ```
170
180
 
171
- This installs a non-blocking pre-commit reminder that runs `npm run sidecar:reminder`.
181
+ This is optional and per-repository clone. `sidecar init` does not install git hooks automatically.
182
+
183
+ This installs a pre-commit guard that checks staged non-doc code changes.
184
+ If staged code changes are present, commit is blocked unless both are recorded since the last commit:
185
+
186
+ - a `worklog` event
187
+ - a `summary refresh` event
188
+
189
+ The guard command is:
190
+
191
+ - `npm run sidecar:reminder -- --staged --enforce`
192
+
172
193
  If a pre-commit hook already exists, Sidecar will not overwrite it unless you run:
173
194
 
174
195
  ```bash
@@ -205,6 +226,54 @@ Primary tables:
205
226
 
206
227
  No network dependency is required for normal operation.
207
228
 
229
+ ## Optional local UI
230
+
231
+ `sidecar ui` launches a local browser UI for the selected Sidecar project.
232
+
233
+ Lazy-install behavior:
234
+
235
+ 1. `sidecar ui` resolves the nearest `.sidecar` project root (or uses `--project`).
236
+ 2. Sidecar checks for `@sidecar/ui` in `~/.sidecar/ui`.
237
+ 3. If missing/incompatible, Sidecar installs or updates it automatically.
238
+ 4. Sidecar starts a local UI server and opens the browser (unless `--no-open`).
239
+
240
+ UI runtime location:
241
+
242
+ - `~/.sidecar/ui`
243
+ - the CLI installs `@sidecar/ui` here (not in your project repo)
244
+
245
+ Version compatibility rule:
246
+
247
+ - CLI and UI must share the same major version.
248
+ - If majors differ, `sidecar ui` auto-reinstalls/updates UI.
249
+
250
+ Common examples:
251
+
252
+ ```bash
253
+ sidecar ui
254
+ sidecar ui --no-open --port 4311
255
+ sidecar ui --install-only
256
+ sidecar ui --project ../other-repo
257
+ sidecar ui --reinstall
258
+ ```
259
+
260
+ Initial UI screens:
261
+
262
+ - Overview: project info, active session, recent decisions/worklogs, open tasks, recent notes
263
+ - Timeline: recent events in chronological order
264
+ - Tasks: open and completed tasks
265
+ - Decisions: decision records with summary and timestamps
266
+ - Preferences: `.sidecar/preferences.json` and `.sidecar/summary.md`
267
+
268
+ UI write support (v1):
269
+
270
+ - Add notes from Overview
271
+ - Add open tasks from Overview
272
+ - Edit `.sidecar/preferences.json` from Preferences
273
+ - `output.humanTime` controls timestamp style in human-readable CLI output:
274
+ - `true`: friendly local times (for example `3/18/2026, 11:51 AM`)
275
+ - `false`: raw ISO-style timestamps
276
+
208
277
  ## JSON output
209
278
 
210
279
  Most commands support `--json` and return structured output:
@@ -216,6 +285,68 @@ Most commands support `--json` and return structured output:
216
285
 
217
286
  This makes Sidecar easy to automate from scripts and AI agents.
218
287
 
288
+ ## Integration API
289
+
290
+ Sidecar CLI is the first integration API for scripts, agents, and local tooling.
291
+
292
+ Standard JSON envelope:
293
+
294
+ ```json
295
+ {
296
+ "ok": true,
297
+ "version": "1.0",
298
+ "command": "task add",
299
+ "data": {},
300
+ "errors": []
301
+ }
302
+ ```
303
+
304
+ Failure envelope:
305
+
306
+ ```json
307
+ {
308
+ "ok": false,
309
+ "version": "1.0",
310
+ "command": "task add",
311
+ "data": null,
312
+ "errors": ["..."]
313
+ }
314
+ ```
315
+
316
+ Generic event ingest:
317
+
318
+ ```bash
319
+ sidecar event add --type decision --title "Use SQLite" --summary "Simple local storage for v1" --created-by agent --source cli --json
320
+ sidecar event add --json-input '{"type":"note","summary":"Captured context","created_by":"agent"}' --json
321
+ cat event.json | sidecar event add --stdin --json
322
+ ```
323
+
324
+ Capabilities metadata:
325
+
326
+ ```bash
327
+ sidecar capabilities --json
328
+ ```
329
+
330
+ Includes:
331
+
332
+ - `cli_version`
333
+ - `json_contract_version`
334
+ - `features`
335
+ - command and option metadata
336
+
337
+ Export project memory:
338
+
339
+ ```bash
340
+ sidecar export --format json
341
+ sidecar export --format json --output ./exports/sidecar.json
342
+ sidecar export --format jsonl > sidecar-events.jsonl
343
+ ```
344
+
345
+ JSONL note:
346
+
347
+ - JSONL export currently emits events only, one JSON object per line.
348
+ - each JSONL line includes `"version": "1.0"` and `"record_type": "event"`.
349
+
219
350
  ## Release and distribution
220
351
 
221
352
  See [RELEASE.md](./RELEASE.md) for publishing/release details.
package/dist/cli.js CHANGED
@@ -5,12 +5,13 @@ import { Command } from 'commander';
5
5
  import Database from 'better-sqlite3';
6
6
  import { z } from 'zod';
7
7
  import { initializeSchema } from './db/schema.js';
8
- import { getSidecarPaths } from './lib/paths.js';
8
+ import { findSidecarRoot, getSidecarPaths } from './lib/paths.js';
9
9
  import { nowIso, humanTime, stringifyJson } from './lib/format.js';
10
10
  import { SidecarError } from './lib/errors.js';
11
11
  import { jsonFailure, jsonSuccess, printJsonEnvelope } from './lib/output.js';
12
12
  import { bannerDisabled, renderBanner } from './lib/banner.js';
13
13
  import { getUpdateNotice } from './lib/update-check.js';
14
+ import { ensureUiInstalled, launchUiServer } from './lib/ui.js';
14
15
  import { requireInitialized } from './db/client.js';
15
16
  import { renderAgentsMarkdown, renderClaudeMarkdown } from './templates/agents.js';
16
17
  import { refreshSummaryFile } from './services/summary-service.js';
@@ -20,11 +21,14 @@ import { addArtifact, listArtifacts } from './services/artifact-service.js';
20
21
  import { addDecision, addNote, addWorklog, getActiveSessionId, listRecentEvents } from './services/event-service.js';
21
22
  import { addTask, listTasks, markTaskDone } from './services/task-service.js';
22
23
  import { currentSession, endSession, startSession, verifySessionHygiene } from './services/session-service.js';
24
+ import { eventIngestSchema, ingestEvent } from './services/event-ingest-service.js';
25
+ import { buildExportJson, buildExportJsonlEvents, writeOutputFile } from './services/export-service.js';
23
26
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
24
27
  const actorSchema = z.enum(['human', 'agent']);
25
28
  const taskPrioritySchema = z.enum(['low', 'medium', 'high']);
26
29
  const artifactKindSchema = z.enum(['file', 'doc', 'screenshot', 'other']);
27
30
  const taskStatusSchema = z.enum(['open', 'done', 'all']);
31
+ const exportFormatSchema = z.enum(['json', 'jsonl']);
28
32
  const NOT_INITIALIZED_MSG = 'Sidecar is not initialized in this directory or any parent directory';
29
33
  function fail(message) {
30
34
  throw new SidecarError(message);
@@ -166,6 +170,21 @@ function renderContextMarkdown(data) {
166
170
  }
167
171
  return lines.join('\n');
168
172
  }
173
+ async function readStdinText() {
174
+ const chunks = [];
175
+ for await (const chunk of process.stdin) {
176
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
177
+ }
178
+ return Buffer.concat(chunks).toString('utf8');
179
+ }
180
+ function resolveProjectRoot(projectPath) {
181
+ const basePath = projectPath ? path.resolve(projectPath) : process.cwd();
182
+ const root = findSidecarRoot(basePath);
183
+ if (!root) {
184
+ throw new SidecarError(NOT_INITIALIZED_MSG);
185
+ }
186
+ return root;
187
+ }
169
188
  const program = new Command();
170
189
  program.name('sidecar').description('Local-first project memory and recording CLI').version(pkg.version);
171
190
  program.option('--no-banner', 'Disable Sidecar banner output');
@@ -183,6 +202,53 @@ function maybePrintUpdateNotice() {
183
202
  console.log(`Update available: ${pkg.version} -> ${notice.latestVersion}`);
184
203
  console.log(`Run: npm install -g sidecar-cli@${installTag}`);
185
204
  }
205
+ program
206
+ .command('ui')
207
+ .description('Launch the optional local Sidecar UI')
208
+ .option('--no-open', 'Do not open the browser automatically')
209
+ .option('--port <port>', 'Port to run the UI on', (v) => Number.parseInt(v, 10), 4310)
210
+ .option('--install-only', 'Install/update UI package but do not launch')
211
+ .option('--project <path>', 'Project path (defaults to nearest Sidecar root)')
212
+ .option('--reinstall', 'Force reinstall UI package')
213
+ .addHelpText('after', '\nExamples:\n $ sidecar ui\n $ sidecar ui --no-open --port 4311\n $ sidecar ui --project ../my-repo --install-only')
214
+ .action((opts) => {
215
+ const command = 'ui';
216
+ try {
217
+ const projectRoot = resolveProjectRoot(opts.project);
218
+ const port = Number(opts.port);
219
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
220
+ fail('Port must be an integer between 1 and 65535');
221
+ }
222
+ if (!bannerDisabled()) {
223
+ console.log(renderBanner());
224
+ console.log('');
225
+ }
226
+ console.log('Launching Sidecar UI');
227
+ console.log(`Project: ${projectRoot}`);
228
+ const { installedVersion } = ensureUiInstalled({
229
+ cliVersion: pkg.version,
230
+ reinstall: Boolean(opts.reinstall),
231
+ onStatus: (line) => console.log(line),
232
+ });
233
+ console.log(`UI version: ${installedVersion}`);
234
+ if (opts.installOnly) {
235
+ console.log('Install-only mode complete.');
236
+ return;
237
+ }
238
+ const { url } = launchUiServer({
239
+ projectPath: projectRoot,
240
+ port,
241
+ openBrowser: opts.open !== false,
242
+ });
243
+ console.log(`URL: ${url}`);
244
+ if (opts.open === false) {
245
+ console.log('Browser auto-open disabled.');
246
+ }
247
+ }
248
+ catch (err) {
249
+ handleCommandError(command, false, err);
250
+ }
251
+ });
186
252
  program
187
253
  .command('init')
188
254
  .description('Initialize Sidecar in the current directory')
@@ -307,7 +373,7 @@ program
307
373
  };
308
374
  const recent = db.prepare(`SELECT type, title, created_at FROM events WHERE project_id = ? ORDER BY created_at DESC LIMIT 5`).all(projectId);
309
375
  db.close();
310
- const data = { initialized: true, project, counts, recent };
376
+ const data = { project, counts, recent_events: recent };
311
377
  respondSuccess(command, Boolean(opts.json), data, [
312
378
  `Project: ${project.name}`,
313
379
  `Root: ${project.root_path}`,
@@ -323,6 +389,24 @@ program
323
389
  handleCommandError(command, Boolean(opts.json), normalized);
324
390
  }
325
391
  });
392
+ const preferences = program.command('preferences').description('Preferences commands');
393
+ preferences
394
+ .command('show')
395
+ .description('Show project preferences')
396
+ .option('--json', 'Print machine-readable JSON output')
397
+ .addHelpText('after', '\nExamples:\n $ sidecar preferences show\n $ sidecar preferences show --json')
398
+ .action((opts) => {
399
+ const command = 'preferences show';
400
+ try {
401
+ const { rootPath } = requireInitialized();
402
+ const prefsPath = getSidecarPaths(rootPath).preferencesPath;
403
+ const preferencesData = fs.existsSync(prefsPath) ? JSON.parse(fs.readFileSync(prefsPath, 'utf8')) : {};
404
+ respondSuccess(command, Boolean(opts.json), { project: { root_path: rootPath }, preferences: preferencesData, path: prefsPath }, [`Preferences path: ${prefsPath}`, stringifyJson(preferencesData)]);
405
+ }
406
+ catch (err) {
407
+ handleCommandError(command, Boolean(opts.json), err);
408
+ }
409
+ });
326
410
  program
327
411
  .command('capabilities')
328
412
  .description('Output a machine-readable manifest of Sidecar commands')
@@ -336,6 +420,120 @@ program
336
420
  else
337
421
  console.log(stringifyJson(manifest));
338
422
  });
423
+ const event = program.command('event').description('Generic event ingest commands');
424
+ event
425
+ .command('add')
426
+ .description('Add a validated generic Sidecar event')
427
+ .option('--type <type>', 'note|decision|worklog|task_created|task_completed|summary_generated')
428
+ .option('--title <title>', 'Event title')
429
+ .option('--summary <summary>', 'Event summary')
430
+ .option('--details-json <json>', 'JSON object for details_json')
431
+ .option('--created-by <by>', 'human|agent|system')
432
+ .option('--source <source>', 'cli|imported|generated')
433
+ .option('--session-id <id>', 'Optional session id', (v) => Number.parseInt(v, 10))
434
+ .option('--json-input <json>', 'Raw JSON event payload')
435
+ .option('--stdin', 'Read JSON event payload from stdin')
436
+ .option('--json', 'Print machine-readable JSON output')
437
+ .addHelpText('after', '\nExamples:\n $ sidecar event add --type note --summary "Captured context"\n $ sidecar event add --json-input \'{"type":"decision","title":"Use SQLite","summary":"Local-first"}\' --json\n $ cat event.json | sidecar event add --stdin --json')
438
+ .action(async (opts) => {
439
+ const command = 'event add';
440
+ try {
441
+ const payloadSources = [Boolean(opts.jsonInput), Boolean(opts.stdin), Boolean(opts.type || opts.title || opts.summary || opts.detailsJson || opts.createdBy || opts.source || opts.sessionId)];
442
+ if (payloadSources.filter(Boolean).length !== 1) {
443
+ fail('Provide exactly one payload source: structured flags OR --json-input OR --stdin');
444
+ }
445
+ let payloadRaw;
446
+ if (opts.jsonInput) {
447
+ payloadRaw = JSON.parse(opts.jsonInput);
448
+ }
449
+ else if (opts.stdin) {
450
+ const raw = (await readStdinText()).trim();
451
+ if (!raw)
452
+ fail('STDIN payload is empty');
453
+ payloadRaw = JSON.parse(raw);
454
+ }
455
+ else {
456
+ payloadRaw = {
457
+ type: opts.type,
458
+ title: opts.title,
459
+ summary: opts.summary,
460
+ details_json: opts.detailsJson ? JSON.parse(opts.detailsJson) : undefined,
461
+ created_by: opts.createdBy,
462
+ source: opts.source,
463
+ session_id: Number.isInteger(opts.sessionId) ? opts.sessionId : undefined,
464
+ };
465
+ }
466
+ const payload = eventIngestSchema.parse(payloadRaw);
467
+ const { db, projectId } = requireInitialized();
468
+ const created = ingestEvent(db, { project_id: projectId, payload });
469
+ db.close();
470
+ respondSuccess(command, Boolean(opts.json), { event: { ...created, created_at: nowIso() } }, [`Recorded ${created.type} event #${created.id}.`]);
471
+ }
472
+ catch (err) {
473
+ handleCommandError(command, Boolean(opts.json), err);
474
+ }
475
+ });
476
+ program
477
+ .command('export')
478
+ .description('Export project memory in JSON or JSONL')
479
+ .option('--format <format>', 'json|jsonl', 'json')
480
+ .option('--limit <n>', 'Limit exported events', (v) => Number.parseInt(v, 10))
481
+ .option('--type <event-type>', 'Filter exported events by type')
482
+ .option('--since <iso-date>', 'Filter events created_at >= since')
483
+ .option('--until <iso-date>', 'Filter events created_at <= until')
484
+ .option('--output <path>', 'Write export to file path instead of stdout')
485
+ .option('--json', 'Wrap command metadata in JSON envelope when writing to file')
486
+ .addHelpText('after', '\nExamples:\n $ sidecar export --format json\n $ sidecar export --format jsonl --output sidecar-events.jsonl\n $ sidecar export --type decision --since 2026-01-01T00:00:00Z')
487
+ .action((opts) => {
488
+ const command = 'export';
489
+ try {
490
+ const format = exportFormatSchema.parse(opts.format);
491
+ if (opts.since && Number.isNaN(Date.parse(opts.since)))
492
+ fail('--since must be a valid ISO date');
493
+ if (opts.until && Number.isNaN(Date.parse(opts.until)))
494
+ fail('--until must be a valid ISO date');
495
+ const { db, projectId, rootPath } = requireInitialized();
496
+ if (format === 'json') {
497
+ const payload = buildExportJson(db, {
498
+ projectId,
499
+ rootPath,
500
+ limit: opts.limit,
501
+ type: opts.type,
502
+ since: opts.since,
503
+ until: opts.until,
504
+ });
505
+ db.close();
506
+ const rendered = stringifyJson(payload);
507
+ if (opts.output) {
508
+ const filePath = writeOutputFile(opts.output, `${rendered}\n`);
509
+ respondSuccess(command, Boolean(opts.json), { format, output_path: filePath }, [`Export written: ${filePath}`]);
510
+ }
511
+ else {
512
+ console.log(rendered);
513
+ }
514
+ return;
515
+ }
516
+ const lines = buildExportJsonlEvents(db, {
517
+ projectId,
518
+ limit: opts.limit,
519
+ type: opts.type,
520
+ since: opts.since,
521
+ until: opts.until,
522
+ });
523
+ db.close();
524
+ const rendered = `${lines.join('\n')}${lines.length > 0 ? '\n' : ''}`;
525
+ if (opts.output) {
526
+ const filePath = writeOutputFile(opts.output, rendered);
527
+ respondSuccess(command, Boolean(opts.json), { format, output_path: filePath, records: lines.length }, [`Export written: ${filePath}`]);
528
+ }
529
+ else {
530
+ process.stdout.write(rendered);
531
+ }
532
+ }
533
+ catch (err) {
534
+ handleCommandError(command, Boolean(opts.json), err);
535
+ }
536
+ });
339
537
  program
340
538
  .command('context')
341
539
  .description('Generate a compact context snapshot for a work session')
@@ -380,7 +578,7 @@ summary
380
578
  const { db, projectId, rootPath } = requireInitialized();
381
579
  const out = refreshSummaryFile(db, rootPath, projectId, Math.max(1, opts.limit));
382
580
  db.close();
383
- respondSuccess(command, Boolean(opts.json), { ...out, timestamp: nowIso() }, ['Summary refreshed.', `Path: ${out.path}`]);
581
+ respondSuccess(command, Boolean(opts.json), { summary: { path: out.path, generated_at: out.generatedAt } }, ['Summary refreshed.', `Path: ${out.path}`]);
384
582
  }
385
583
  catch (err) {
386
584
  handleCommandError(command, Boolean(opts.json), err);
@@ -400,7 +598,7 @@ program
400
598
  const rows = listRecentEvents(db, { projectId, type: opts.type, limit: Math.max(1, opts.limit) });
401
599
  db.close();
402
600
  if (opts.json)
403
- printJsonEnvelope(jsonSuccess(command, rows));
601
+ printJsonEnvelope(jsonSuccess(command, { events: rows }));
404
602
  else {
405
603
  if (rows.length === 0) {
406
604
  console.log('No events found.');
@@ -432,7 +630,7 @@ program
432
630
  const sessionId = maybeSessionId(db, projectId, opts.session);
433
631
  const eventId = addNote(db, { projectId, text, title: opts.title, by, sessionId });
434
632
  db.close();
435
- respondSuccess(command, Boolean(opts.json), { eventId, timestamp: nowIso() }, [`Recorded note event #${eventId}.`]);
633
+ respondSuccess(command, Boolean(opts.json), { event: { id: eventId, type: 'note', title: opts.title?.trim() || 'Note', summary: text, created_by: by, session_id: sessionId, created_at: nowIso() } }, [`Recorded note event #${eventId}.`]);
436
634
  }
437
635
  catch (err) {
438
636
  handleCommandError(command, Boolean(opts.json), err);
@@ -457,7 +655,7 @@ decision
457
655
  const sessionId = maybeSessionId(db, projectId, opts.session);
458
656
  const eventId = addDecision(db, { projectId, title: opts.title, summary: opts.summary, details: opts.details, by, sessionId });
459
657
  db.close();
460
- respondSuccess(command, Boolean(opts.json), { eventId, timestamp: nowIso() }, [`Recorded decision event #${eventId}.`]);
658
+ respondSuccess(command, Boolean(opts.json), { event: { id: eventId, type: 'decision', title: opts.title, summary: opts.summary, created_by: by, session_id: sessionId, created_at: nowIso() } }, [`Recorded decision event #${eventId}.`]);
461
659
  }
462
660
  catch (err) {
463
661
  handleCommandError(command, Boolean(opts.json), err);
@@ -496,7 +694,7 @@ worklog
496
694
  addArtifact(db, { projectId, path: filePath, kind: 'file' });
497
695
  }
498
696
  db.close();
499
- respondSuccess(command, Boolean(opts.json), { ...result, timestamp: nowIso() }, [
697
+ respondSuccess(command, Boolean(opts.json), { event: { id: result.eventId, type: 'worklog', summary: opts.done, created_by: by, session_id: sessionId, created_at: nowIso() }, artifacts: result.files.map((p) => ({ path: p, kind: 'file' })) }, [
500
698
  `Recorded worklog event #${result.eventId}.`,
501
699
  `Artifacts linked: ${result.files.length}`,
502
700
  ]);
@@ -522,7 +720,7 @@ task
522
720
  const { db, projectId } = requireInitialized();
523
721
  const result = addTask(db, { projectId, title, description: opts.description, priority, by });
524
722
  db.close();
525
- respondSuccess(command, Boolean(opts.json), { ...result, timestamp: nowIso() }, [`Added task #${result.taskId}.`]);
723
+ respondSuccess(command, Boolean(opts.json), { task: { id: result.taskId, title, description: opts.description ?? null, status: 'open', priority }, event: { id: result.eventId, type: 'task_created' } }, [`Added task #${result.taskId}.`]);
526
724
  }
527
725
  catch (err) {
528
726
  handleCommandError(command, Boolean(opts.json), err);
@@ -546,7 +744,7 @@ task
546
744
  db.close();
547
745
  if (!result.ok)
548
746
  fail(result.reason);
549
- respondSuccess(command, Boolean(opts.json), { taskId, eventId: result.eventId, timestamp: nowIso() }, [`Completed task #${taskId}.`]);
747
+ respondSuccess(command, Boolean(opts.json), { task: { id: taskId, status: 'done' }, event: { id: result.eventId, type: 'task_completed' } }, [`Completed task #${taskId}.`]);
550
748
  }
551
749
  catch (err) {
552
750
  handleCommandError(command, Boolean(opts.json), err);
@@ -568,7 +766,7 @@ task
568
766
  db.close();
569
767
  if (opts.format === 'json' || opts.json) {
570
768
  if (opts.json)
571
- printJsonEnvelope(jsonSuccess(command, rows));
769
+ printJsonEnvelope(jsonSuccess(command, { status, tasks: rows }));
572
770
  else
573
771
  console.log(stringifyJson(rows));
574
772
  return;
@@ -610,7 +808,7 @@ session
610
808
  db.close();
611
809
  if (!result.ok)
612
810
  fail(result.reason);
613
- respondSuccess(command, Boolean(opts.json), { ...result, timestamp: nowIso() }, [`Started session #${result.sessionId}.`]);
811
+ respondSuccess(command, Boolean(opts.json), { session: { id: result.sessionId, actor_type: actor, actor_name: opts.name ?? null, started_at: nowIso() } }, [`Started session #${result.sessionId}.`]);
614
812
  }
615
813
  catch (err) {
616
814
  handleCommandError(command, Boolean(opts.json), err);
@@ -630,7 +828,7 @@ session
630
828
  db.close();
631
829
  if (!result.ok)
632
830
  fail(result.reason);
633
- respondSuccess(command, Boolean(opts.json), { ...result, timestamp: nowIso() }, [`Ended session #${result.sessionId}.`]);
831
+ respondSuccess(command, Boolean(opts.json), { session: { id: result.sessionId, ended_at: nowIso(), summary: opts.summary ?? null } }, [`Ended session #${result.sessionId}.`]);
634
832
  }
635
833
  catch (err) {
636
834
  handleCommandError(command, Boolean(opts.json), err);
@@ -648,7 +846,7 @@ session
648
846
  const current = currentSession(db, projectId);
649
847
  db.close();
650
848
  if (opts.json)
651
- printJsonEnvelope(jsonSuccess(command, { current: current ?? null }));
849
+ printJsonEnvelope(jsonSuccess(command, { session: current ?? null }));
652
850
  else if (!current) {
653
851
  console.log('No active session.');
654
852
  }
@@ -712,7 +910,7 @@ artifact
712
910
  const { db, projectId } = requireInitialized();
713
911
  const artifactId = addArtifact(db, { projectId, path: artifactPath, kind, note: opts.note });
714
912
  db.close();
715
- respondSuccess(command, Boolean(opts.json), { artifactId, timestamp: nowIso() }, [`Added artifact #${artifactId}.`]);
913
+ respondSuccess(command, Boolean(opts.json), { artifact: { id: artifactId, path: artifactPath, kind, note: opts.note ?? null, created_at: nowIso() } }, [`Added artifact #${artifactId}.`]);
716
914
  }
717
915
  catch (err) {
718
916
  handleCommandError(command, Boolean(opts.json), err);
@@ -730,7 +928,7 @@ artifact
730
928
  const rows = listArtifacts(db, projectId);
731
929
  db.close();
732
930
  if (opts.json)
733
- printJsonEnvelope(jsonSuccess(command, rows));
931
+ printJsonEnvelope(jsonSuccess(command, { artifacts: rows }));
734
932
  else {
735
933
  if (rows.length === 0) {
736
934
  console.log('No artifacts found.');
@@ -1,7 +1,9 @@
1
1
  import { stringifyJson } from './format.js';
2
+ export const JSON_CONTRACT_VERSION = '1.0';
2
3
  export function jsonSuccess(command, data) {
3
4
  return {
4
5
  ok: true,
6
+ version: JSON_CONTRACT_VERSION,
5
7
  command,
6
8
  data,
7
9
  errors: [],
@@ -10,6 +12,7 @@ export function jsonSuccess(command, data) {
10
12
  export function jsonFailure(command, message) {
11
13
  return {
12
14
  ok: false,
15
+ version: JSON_CONTRACT_VERSION,
13
16
  command,
14
17
  data: null,
15
18
  errors: [message],
package/dist/lib/ui.js ADDED
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFileSync, spawn } from 'node:child_process';
5
+ import { detectReleaseChannel } from './update-check.js';
6
+ import { SidecarError } from './errors.js';
7
+ const UI_PACKAGE = '@sidecar/ui';
8
+ const UI_RUNTIME_DIR = path.join(os.homedir(), '.sidecar', 'ui');
9
+ function ensureRuntimeDir() {
10
+ fs.mkdirSync(UI_RUNTIME_DIR, { recursive: true });
11
+ const runtimePkgPath = path.join(UI_RUNTIME_DIR, 'package.json');
12
+ if (!fs.existsSync(runtimePkgPath)) {
13
+ fs.writeFileSync(runtimePkgPath, JSON.stringify({ name: 'sidecar-ui-runtime', private: true, version: '0.0.0' }, null, 2));
14
+ }
15
+ }
16
+ function readInstalledUiVersion() {
17
+ const p = path.join(UI_RUNTIME_DIR, 'node_modules', '@sidecar', 'ui', 'package.json');
18
+ if (!fs.existsSync(p))
19
+ return null;
20
+ try {
21
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
22
+ return pkg.version ?? null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function major(version) {
29
+ const m = version.match(/^(\d+)\./);
30
+ if (!m)
31
+ return null;
32
+ return Number.parseInt(m[1], 10);
33
+ }
34
+ function isCompatible(cliVersion, uiVersion) {
35
+ if (!uiVersion)
36
+ return false;
37
+ const cliMajor = major(cliVersion);
38
+ const uiMajor = major(uiVersion);
39
+ return cliMajor !== null && uiMajor !== null && cliMajor === uiMajor;
40
+ }
41
+ function npmInstall(spec) {
42
+ execFileSync('npm', ['install', '--no-audit', '--no-fund', spec], {
43
+ cwd: UI_RUNTIME_DIR,
44
+ stdio: 'inherit',
45
+ });
46
+ }
47
+ function getDesiredTag(cliVersion) {
48
+ return detectReleaseChannel(cliVersion);
49
+ }
50
+ export function ensureUiInstalled(options) {
51
+ ensureRuntimeDir();
52
+ const tag = getDesiredTag(options.cliVersion);
53
+ const installed = readInstalledUiVersion();
54
+ const shouldInstall = Boolean(options.reinstall) || !isCompatible(options.cliVersion, installed);
55
+ if (shouldInstall) {
56
+ options.onStatus?.(installed
57
+ ? `Updating Sidecar UI (${installed}) for CLI compatibility...`
58
+ : 'Installing Sidecar UI for first use...');
59
+ const spec = `${UI_PACKAGE}@${tag}`;
60
+ try {
61
+ npmInstall(spec);
62
+ }
63
+ catch {
64
+ const localUiPkg = path.resolve(process.cwd(), 'packages', 'ui');
65
+ if (fs.existsSync(path.join(localUiPkg, 'package.json'))) {
66
+ options.onStatus?.('Falling back to local workspace UI package...');
67
+ npmInstall(localUiPkg);
68
+ }
69
+ else {
70
+ throw new SidecarError('Failed to install Sidecar UI package. Check npm access/network and retry `sidecar ui --reinstall`.');
71
+ }
72
+ }
73
+ }
74
+ const finalVersion = readInstalledUiVersion();
75
+ if (!finalVersion) {
76
+ throw new SidecarError('Sidecar UI appears missing after install. Retry with `sidecar ui --reinstall`.');
77
+ }
78
+ return { installedVersion: finalVersion };
79
+ }
80
+ export function launchUiServer(options) {
81
+ const serverPath = path.join(UI_RUNTIME_DIR, 'node_modules', '@sidecar', 'ui', 'server.js');
82
+ if (!fs.existsSync(serverPath)) {
83
+ throw new SidecarError('Sidecar UI server entry was not found after install.');
84
+ }
85
+ const child = spawn(process.execPath, [serverPath, '--project', options.projectPath, '--port', String(options.port)], {
86
+ stdio: 'inherit',
87
+ env: { ...process.env },
88
+ });
89
+ const url = `http://localhost:${options.port}`;
90
+ if (options.openBrowser) {
91
+ openBrowser(url);
92
+ }
93
+ return { url, child };
94
+ }
95
+ export function openBrowser(url) {
96
+ const platform = process.platform;
97
+ if (platform === 'darwin') {
98
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
99
+ return;
100
+ }
101
+ if (platform === 'win32') {
102
+ spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
103
+ return;
104
+ }
105
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
106
+ }
@@ -1,10 +1,17 @@
1
+ import { JSON_CONTRACT_VERSION } from '../lib/output.js';
1
2
  export function getCapabilitiesManifest(version) {
2
3
  return {
3
- schema_version: 1,
4
- cli: {
5
- name: 'sidecar',
6
- version,
7
- },
4
+ cli_name: 'sidecar',
5
+ cli_version: version,
6
+ json_contract_version: JSON_CONTRACT_VERSION,
7
+ features: [
8
+ 'json_envelope_v1',
9
+ 'capabilities_manifest',
10
+ 'event_ingest',
11
+ 'export_json',
12
+ 'export_jsonl_events',
13
+ 'optional_local_ui',
14
+ ],
8
15
  commands: [
9
16
  {
10
17
  name: 'init',
@@ -20,6 +27,29 @@ export function getCapabilitiesManifest(version) {
20
27
  arguments: [],
21
28
  options: ['--json'],
22
29
  },
30
+ {
31
+ name: 'preferences',
32
+ description: 'Preferences operations',
33
+ json_output: true,
34
+ arguments: [],
35
+ options: [],
36
+ subcommands: [
37
+ {
38
+ name: 'show',
39
+ description: 'Show project preferences',
40
+ json_output: true,
41
+ arguments: [],
42
+ options: ['--json'],
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ name: 'ui',
48
+ description: 'Launch optional local Sidecar UI (lazy-installed on first run)',
49
+ json_output: false,
50
+ arguments: [],
51
+ options: ['--no-open', '--port <port>', '--install-only', '--project <path>', '--reinstall'],
52
+ },
23
53
  {
24
54
  name: 'capabilities',
25
55
  description: 'Show machine-readable CLI manifest',
@@ -34,6 +64,40 @@ export function getCapabilitiesManifest(version) {
34
64
  arguments: [],
35
65
  options: ['--limit <n>', '--format text|markdown|json', '--json'],
36
66
  },
67
+ {
68
+ name: 'event',
69
+ description: 'Generic event ingest operations',
70
+ json_output: true,
71
+ arguments: [],
72
+ options: [],
73
+ subcommands: [
74
+ {
75
+ name: 'add',
76
+ description: 'Add a validated generic event payload',
77
+ json_output: true,
78
+ arguments: [],
79
+ options: [
80
+ '--type <type>',
81
+ '--title <title>',
82
+ '--summary <summary>',
83
+ '--details-json <json>',
84
+ '--created-by <by>',
85
+ '--source <source>',
86
+ '--session-id <id>',
87
+ '--json-input <json>',
88
+ '--stdin',
89
+ '--json',
90
+ ],
91
+ },
92
+ ],
93
+ },
94
+ {
95
+ name: 'export',
96
+ description: 'Export project memory in JSON or JSONL',
97
+ json_output: true,
98
+ arguments: [],
99
+ options: ['--format json|jsonl', '--limit <n>', '--type <event-type>', '--since <iso-date>', '--until <iso-date>', '--output <path>', '--json'],
100
+ },
37
101
  {
38
102
  name: 'summary',
39
103
  description: 'Summary operations',
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { createEvent } from './event-service.js';
3
+ const eventTypeSchema = z.enum([
4
+ 'note',
5
+ 'decision',
6
+ 'worklog',
7
+ 'task_created',
8
+ 'task_completed',
9
+ 'summary_generated',
10
+ ]);
11
+ const createdBySchema = z.enum(['human', 'agent', 'system']).default('system');
12
+ const sourceSchema = z.enum(['cli', 'imported', 'generated']).default('cli');
13
+ export const eventIngestSchema = z
14
+ .object({
15
+ type: eventTypeSchema,
16
+ title: z.string().trim().min(1).optional(),
17
+ summary: z.string().trim().min(1).optional(),
18
+ details_json: z.record(z.string(), z.unknown()).optional(),
19
+ created_by: createdBySchema.optional(),
20
+ source: sourceSchema.optional(),
21
+ session_id: z.number().int().positive().nullable().optional(),
22
+ })
23
+ .superRefine((payload, ctx) => {
24
+ if (payload.type === 'decision') {
25
+ if (!payload.title)
26
+ ctx.addIssue({ code: 'custom', path: ['title'], message: 'title is required for decision events' });
27
+ if (!payload.summary)
28
+ ctx.addIssue({ code: 'custom', path: ['summary'], message: 'summary is required for decision events' });
29
+ return;
30
+ }
31
+ if (payload.type === 'summary_generated')
32
+ return;
33
+ if (!payload.summary) {
34
+ ctx.addIssue({ code: 'custom', path: ['summary'], message: `summary is required for ${payload.type} events` });
35
+ }
36
+ });
37
+ export function ingestEvent(db, input) {
38
+ const payload = eventIngestSchema.parse(input.payload);
39
+ const title = payload.title ??
40
+ (payload.type === 'note'
41
+ ? 'Note'
42
+ : payload.type === 'worklog'
43
+ ? 'Worklog entry'
44
+ : payload.type === 'task_created'
45
+ ? 'Task created'
46
+ : payload.type === 'task_completed'
47
+ ? 'Task completed'
48
+ : payload.type === 'summary_generated'
49
+ ? 'Summary refreshed'
50
+ : 'Event');
51
+ const summary = payload.summary ?? '';
52
+ const eventId = createEvent(db, {
53
+ projectId: input.project_id,
54
+ type: payload.type,
55
+ title,
56
+ summary,
57
+ details: payload.details_json ?? {},
58
+ createdBy: payload.created_by ?? 'system',
59
+ source: payload.source ?? 'cli',
60
+ sessionId: payload.session_id ?? null,
61
+ });
62
+ return {
63
+ id: eventId,
64
+ type: payload.type,
65
+ title,
66
+ summary,
67
+ details_json: payload.details_json ?? {},
68
+ created_by: payload.created_by ?? 'system',
69
+ source: payload.source ?? 'cli',
70
+ session_id: payload.session_id ?? null,
71
+ };
72
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { JSON_CONTRACT_VERSION } from '../lib/output.js';
4
+ export function buildExportJson(db, input) {
5
+ const project = db.prepare('SELECT id, name, root_path, created_at, updated_at FROM projects WHERE id = ?').get(input.projectId);
6
+ const preferencesPath = path.join(input.rootPath, '.sidecar', 'preferences.json');
7
+ const preferences = fs.existsSync(preferencesPath)
8
+ ? JSON.parse(fs.readFileSync(preferencesPath, 'utf8'))
9
+ : null;
10
+ const sessions = db
11
+ .prepare('SELECT id, project_id, started_at, ended_at, actor_type, actor_name, summary FROM sessions WHERE project_id = ? ORDER BY started_at DESC')
12
+ .all(input.projectId);
13
+ const tasks = db
14
+ .prepare(`SELECT id, project_id, title, description, status, priority, created_at, updated_at, closed_at, origin_event_id FROM tasks WHERE project_id = ? ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, updated_at DESC`)
15
+ .all(input.projectId);
16
+ const artifacts = db
17
+ .prepare('SELECT id, project_id, path, kind, note, created_at FROM artifacts WHERE project_id = ? ORDER BY created_at DESC')
18
+ .all(input.projectId);
19
+ const eventWhere = ['project_id = ?'];
20
+ const eventArgs = [input.projectId];
21
+ if (input.type) {
22
+ eventWhere.push('type = ?');
23
+ eventArgs.push(input.type);
24
+ }
25
+ if (input.since) {
26
+ eventWhere.push('created_at >= ?');
27
+ eventArgs.push(input.since);
28
+ }
29
+ if (input.until) {
30
+ eventWhere.push('created_at <= ?');
31
+ eventArgs.push(input.until);
32
+ }
33
+ const limitClause = input.limit && input.limit > 0 ? ` LIMIT ${Math.floor(input.limit)}` : '';
34
+ const events = db
35
+ .prepare(`SELECT id, project_id, type, title, summary, details_json, created_at, created_by, source, session_id
36
+ FROM events
37
+ WHERE ${eventWhere.join(' AND ')}
38
+ ORDER BY created_at DESC${limitClause}`)
39
+ .all(...eventArgs);
40
+ return {
41
+ version: JSON_CONTRACT_VERSION,
42
+ project,
43
+ preferences: preferences,
44
+ sessions: sessions,
45
+ tasks: tasks,
46
+ artifacts: artifacts,
47
+ events,
48
+ };
49
+ }
50
+ export function buildExportJsonlEvents(db, input) {
51
+ const where = ['project_id = ?'];
52
+ const args = [input.projectId];
53
+ if (input.type) {
54
+ where.push('type = ?');
55
+ args.push(input.type);
56
+ }
57
+ if (input.since) {
58
+ where.push('created_at >= ?');
59
+ args.push(input.since);
60
+ }
61
+ if (input.until) {
62
+ where.push('created_at <= ?');
63
+ args.push(input.until);
64
+ }
65
+ const limitClause = input.limit && input.limit > 0 ? ` LIMIT ${Math.floor(input.limit)}` : '';
66
+ const rows = db
67
+ .prepare(`SELECT id, project_id, type, title, summary, details_json, created_at, created_by, source, session_id
68
+ FROM events
69
+ WHERE ${where.join(' AND ')}
70
+ ORDER BY created_at DESC${limitClause}`)
71
+ .all(...args);
72
+ return rows.map((row) => JSON.stringify({ version: JSON_CONTRACT_VERSION, record_type: 'event', project_id: row.project_id, data: row }));
73
+ }
74
+ export function writeOutputFile(outputPath, content) {
75
+ const abs = path.resolve(outputPath);
76
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
77
+ fs.writeFileSync(abs, content);
78
+ return abs;
79
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidecar-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local-first project memory and recording tool",
5
5
  "scripts": {
6
6
  "build": "npm run clean && tsc -p tsconfig.json",