A CLI can connect import, summarization, queries, and checks in scheduled work. A reliable pipeline records each exit code, response shape, and final state. A queued message alone is not a reason to publish a report. Commands below are checked against the pinned commit; acceptance exercises use synthetic responses without a real service or personal histories.
Fix the connection and noninteractive output
server/src/cli/index.ts defines global –base-url, –token, –json, and –no-interactive. server/src/cli/connection.ts selects explicit arguments, environment, saved connection, then the implicit local default. Automatic startup applies only to an unavailable implicit local default. User-configured loopback targets also face cloudMode checks. Scheduled jobs should fix their target and credential source so connection changes do not send writes to another instance. Use HTTPS remotely; the current client’s transport check does not replace deployment protection.
These are existing command forms. Only the final example requests summary enqueueing. Check mode and data scope before running them in a reviewed connection environment:
crystal --json status
crystal --json conversations --status imported --limit 20
crystal --json notes list --tag typescript --limit 20
crystal --json search "transaction rollback" --limit 5
crystal --json tags
crystal --json summarize --all
Check JSON shapes and capabilities separately
| Command | Current JSON result | What automation must not assume |
|---|---|---|
| status | Object with stats | No queue; missing fields cannot mean an empty queue |
| conversations / notes list | items, total, and pagination fields | The first page is not all results |
| search / tags | Arrays | Search is not a results wrapper object |
| summarize –all | queued, total, and queue | Enqueueing is not successful summaries and embeddings |
| config get / set | Config result; set accepts llm or embedding dotted keys | No sources.paths config command |
server/src/cli/commands/status.ts and server/src/cli/commands/search.ts provide the individual contracts. Serve uses crystal serve --daemon --port 3721, crystal serve stop, and crystal serve status, rather than a start subcommand. The current serve status error-display path does not explicitly set a failing exit code; config test may also print a connection failure and finish normally. Inspect business fields or the expected response instead of treating either process exit alone as readiness.
server/src/cli/commands/import.ts uses a local scan stream, or a panel under TTY; the local branches do not forward –source. Only remote import applies that filter, and JSON or noninteractive remote use requires –yes. That flag approves source-conversation transmission; it does not enable summaries. Do not report source-filtered acceptance when the actual entry point scanned all enabled sources.
server/src/cli/commands/summarize.ts currently calls summarize twice in its single-ID JSON branch, so that form cannot promise exactly one request. Targeted automation can use the batch-ids and status-ids APIs in server/src/routes/notes.ts with a recorded ID set. Check authentication, HTTP status, success, data, and schema. unknown/error must fail; imported/summarizing remain pending; summarized/filtered are distinct terminal states. Filtered does not mean a note was created, and a summary terminal state does not ensure embedding_status=done.
The remaining command contracts are in notes, tags, config, serve, and the HTTP client. Recheck the pinned implementation’s display and request boundaries before deployment; a newer help page does not establish this commit’s behavior.
An offline pipeline that rejects false success
Save this as workflow.mjs under Node 24 and run node workflow.mjs. It creates real child-process exits, stdout, and stderr, but those children only produce synthetic command responses. They never execute crystal. ID checks and polling bounds are teaching fixtures without HTTP, SSE, models, or a production database.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
// Synthetic executable responses, never a real crystal process.
function commandFixture(stdout, status = 0) {
const code =
'process.stderr.write("fixture diagnostic\\n");process.stdout.write(process.argv[1]);process.exit(Number(process.argv[2]));';
const result = spawnSync(process.execPath, ['-e', code, stdout, String(status)], {
encoding: 'utf8',
timeout: 1000,
});
if (result.error || result.status !== 0) throw new Error('command failed');
return JSON.parse(result.stdout);
}
function requireStatus(value) {
assert.equal(typeof value.stats?.totalConversations, 'number');
return value;
}
function requireImport(value) {
assert.ok(Array.isArray(value.summarizationCandidateIds));
assert.equal(value.errors, 0);
return value.summarizationCandidateIds;
}
function inspectIds(value, expected) {
assert.ok(Array.isArray(value.items));
const byId = new Map(value.items.map((item) => [item.id, item.status]));
assert.equal(byId.size, expected.length);
let pending = false;
for (const id of expected) {
const status = byId.get(id);
if (!status || ['error', 'unknown'].includes(status)) throw new Error(`failed ${id}`);
if (['imported', 'summarizing'].includes(status)) pending = true;
else if (!['summarized', 'filtered'].includes(status)) throw new Error('unknown state');
}
return pending ? 'pending' : 'finished';
}
async function waitForIds(read, ids, maxPolls = 3) {
for (let i = 0; i < maxPolls; i++) {
if (inspectIds(await read(), ids) === 'finished') return;
}
throw new Error('deadline exceeded');
}
const status = requireStatus(commandFixture(JSON.stringify({ stats: { totalConversations: 2 } })));
assert.equal(status.queue, undefined);
assert.throws(() => commandFixture('{}', 7), /command failed/);
assert.throws(() => commandFixture('not JSON'), SyntaxError);
assert.throws(() => requireStatus(commandFixture('{}')));
const ids = requireImport(
commandFixture(JSON.stringify({ errors: 0, summarizationCandidateIds: ['a', 'b'] }))
);
assert.throws(() => requireImport({ errors: 1, summarizationCandidateIds: ids }));
const receipt = commandFixture('{"queued":2,"total":2}');
assert.equal(receipt.queued, 2); // Receipt alone never proves completion.
let polls = 0;
await waitForIds(
async () => ({
items: ids.map((id) => ({ id, status: ++polls <= 2 ? 'imported' : 'summarized' })),
}),
ids
);
assert.ok(polls > 2);
await assert.rejects(waitForIds(async () => ({ items: [] }), ids));
await assert.rejects(
waitForIds(async () => ({ items: ids.map((id) => ({ id, status: 'error' })) }), ids)
);
await assert.rejects(
waitForIds(async () => ({ items: ids.map((id) => ({ id, status: 'imported' })) }), ids),
/deadline/
);
assert.equal(
inspectIds(
{
items: [
{ id: 'a', status: 'filtered' },
{ id: 'b', status: 'summarized' },
],
},
ids
),
'finished'
);
console.log('exit, JSON shape, per-ID terminal state, and deadlines pass on synthetic fixtures');
Faults, scheduling, and backup
Remove the nonzero-exit guard: the exit-7 child reaches parsing and the rejection assertion must fail. Return finished for an empty items array: the missing-ID rejection assertion must fail. Restore and rerun. Keep stderr separate from JSON stdout instead of merging with 2>&1. Timeouts, HTTP errors, invalid JSON, missing fields, unknown IDs, and persistent pending states must fail the job. Poll count is a deterministic exercise cutoff; real jobs need wall-clock deadlines, per-request timeouts, and backoff.
The tracker in server/src/queue/tracker.ts can clear five seconds after becoming idle. A late empty queue cannot prove the previous batch completed. Retain target IDs and query persistent conversation state, then inspect notes, embeddings, and relations required by the job. Add a single-job lock, fixed PATH and working directory, log retention, and failure reporting at the scheduler layer. These are surrounding job design, not new CLI features implemented here.
Use server/src/runtime/paths.ts and server/src/services/vector-index.ts to locate chatcrystal.db, config.json, vectra-index, and related deployment files. Include required authentication files in a controlled recovery plan. Stop every writer or use a snapshot with validated consistency before copying the agreed dataset; copying active files individually is not a cross-file transaction. Restore to an isolated instance and check conversation/note counts, target IDs, and retrieval, rather than merely checking that an archive exists. See desktop lifecycle for connection and shutdown boundaries, and import performance acceptance for batch costs.