omgbase 0.4.0 → 0.5.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.
Files changed (44) hide show
  1. package/dist/build-info.json +4 -0
  2. package/dist/src/build.d.ts +22 -0
  3. package/dist/src/build.js +58 -0
  4. package/dist/src/cmd/_mutate.d.ts +12 -0
  5. package/dist/src/cmd/_mutate.js +26 -13
  6. package/dist/src/cmd/admin.js +25 -22
  7. package/dist/src/cmd/bootstrap.js +14 -22
  8. package/dist/src/cmd/cat.js +10 -12
  9. package/dist/src/cmd/diff.js +39 -27
  10. package/dist/src/cmd/docs.js +44 -6
  11. package/dist/src/cmd/find.js +3 -15
  12. package/dist/src/cmd/help.js +1 -1
  13. package/dist/src/cmd/hist.js +4 -10
  14. package/dist/src/cmd/links.js +21 -16
  15. package/dist/src/cmd/log.js +8 -18
  16. package/dist/src/cmd/ls.js +3 -15
  17. package/dist/src/cmd/mcp.js +16 -32
  18. package/dist/src/cmd/mutate.js +24 -3
  19. package/dist/src/cmd/node.js +7 -3
  20. package/dist/src/cmd/oqx.d.ts +10 -0
  21. package/dist/src/cmd/oqx.js +25 -28
  22. package/dist/src/cmd/outline.js +5 -8
  23. package/dist/src/cmd/retarget.js +6 -21
  24. package/dist/src/cmd/run.js +5 -32
  25. package/dist/src/cmd/show.js +11 -12
  26. package/dist/src/cmd/source.js +18 -32
  27. package/dist/src/cmd/status.js +13 -25
  28. package/dist/src/cmd/sync.js +8 -9
  29. package/dist/src/cmd/update.js +10 -5
  30. package/dist/src/cmd/version.d.ts +2 -0
  31. package/dist/src/cmd/version.js +68 -0
  32. package/dist/src/commands.js +2 -0
  33. package/dist/src/context.d.ts +4 -0
  34. package/dist/src/context.js +16 -0
  35. package/dist/src/dispatch.d.ts +3 -0
  36. package/dist/src/dispatch.js +35 -18
  37. package/dist/src/main.d.ts +2 -0
  38. package/dist/src/main.js +19 -13
  39. package/dist/src/output.d.ts +28 -1
  40. package/dist/src/output.js +66 -20
  41. package/dist/src/seams.d.ts +13 -0
  42. package/dist/src/seams.js +37 -0
  43. package/dist/src/shell/session.js +4 -2
  44. package/package.json +4 -4
@@ -0,0 +1,4 @@
1
+ {
2
+ "commit": "902a85a",
3
+ "built": "2026-09-28T03:16:45.277Z"
4
+ }
@@ -0,0 +1,22 @@
1
+ import { type HostInfo } from "@omgbase/core";
2
+ /**
3
+ * `--version` prints THIS package's version (spec/cli §2.5), read from the
4
+ * nearest `package.json` named `omgbase` above the running module (the source
5
+ * tree and the built `dist/src/` sit at different depths). The engine's own
6
+ * `VERSION` is the fallback only if the package file cannot be found.
7
+ */
8
+ export declare function cliVersion(): string;
9
+ export interface BuildInfo {
10
+ commit: string | null;
11
+ built: string | null;
12
+ }
13
+ /**
14
+ * `dist/build-info.json`, written by `pnpm build` (`scripts/build-info.mjs`):
15
+ * `{ commit: "<short sha>" | null, built: "<RFC 3339>" }`. It sits one level
16
+ * above this module's directory (`dist/src/` → `dist/`), so a source-tree run
17
+ * (vitest over `src/`) looks for `packages/cli/build-info.json`, finds nothing,
18
+ * and reports nulls — the same answer a build outside a git checkout gives.
19
+ */
20
+ export declare function buildInfo(): BuildInfo;
21
+ /** The `HostInfo` this binary hands the engine's `version` tool (`omg mcp`) and verb (`omg version`). */
22
+ export declare function hostInfo(): HostInfo;
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { VERSION } from "@omgbase/core";
5
+ // What this binary knows about itself (spec/cli §2.5 `--version`, §6 `version`):
6
+ // its package version and the build's git revision + time.
7
+ /**
8
+ * `--version` prints THIS package's version (spec/cli §2.5), read from the
9
+ * nearest `package.json` named `omgbase` above the running module (the source
10
+ * tree and the built `dist/src/` sit at different depths). The engine's own
11
+ * `VERSION` is the fallback only if the package file cannot be found.
12
+ */
13
+ export function cliVersion() {
14
+ let dir = dirname(fileURLToPath(import.meta.url));
15
+ for (;;) {
16
+ const candidate = join(dir, "package.json");
17
+ if (existsSync(candidate)) {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
20
+ if (pkg.name === "omgbase" && typeof pkg.version === "string")
21
+ return pkg.version;
22
+ }
23
+ catch {
24
+ /* keep walking */
25
+ }
26
+ }
27
+ const parent = dirname(dir);
28
+ if (parent === dir)
29
+ return VERSION;
30
+ dir = parent;
31
+ }
32
+ }
33
+ /**
34
+ * `dist/build-info.json`, written by `pnpm build` (`scripts/build-info.mjs`):
35
+ * `{ commit: "<short sha>" | null, built: "<RFC 3339>" }`. It sits one level
36
+ * above this module's directory (`dist/src/` → `dist/`), so a source-tree run
37
+ * (vitest over `src/`) looks for `packages/cli/build-info.json`, finds nothing,
38
+ * and reports nulls — the same answer a build outside a git checkout gives.
39
+ */
40
+ export function buildInfo() {
41
+ const path = new URL("../build-info.json", import.meta.url);
42
+ try {
43
+ const raw = JSON.parse(readFileSync(path, "utf8"));
44
+ return {
45
+ commit: typeof raw.commit === "string" ? raw.commit : null,
46
+ built: typeof raw.built === "string" ? raw.built : null,
47
+ };
48
+ }
49
+ catch {
50
+ return { commit: null, built: null };
51
+ }
52
+ }
53
+ /** The `HostInfo` this binary hands the engine's `version` tool (`omg mcp`) and verb (`omg version`). */
54
+ export function hostInfo() {
55
+ const { commit, built } = buildInfo();
56
+ return { version: cliVersion(), commit, built, resolveFrom: import.meta.url };
57
+ }
58
+ //# sourceMappingURL=build.js.map
@@ -44,4 +44,16 @@ export declare function runOps(cli: Cli, ws: Workspace, repo: RepoRow, ops: Op[]
44
44
  * tool's dry_run so the preview comes back without committing.
45
45
  */
46
46
  export declare function runOpsRemote(cli: Cli, tool: string, toolArgs: Record<string, unknown>): Promise<number>;
47
+ /**
48
+ * The dry-run rendering every write verb shares (spec/cli §3.6): the note on
49
+ * stderr, then per changed file the path, the unified diff of before → after
50
+ * (`spec/surface` §3) and an empty line. Document verbs hand their
51
+ * `DocOpResult.diffs` here; block verbs their `ApplyResult.diffs`.
52
+ */
53
+ export declare function renderDiffs(cli: Cli, diffs: Record<string, {
54
+ before: string;
55
+ after: string;
56
+ }>): void;
57
+ /** The unified diff of before → after (`spec/surface` §3), one styled line per entry. */
58
+ export declare function unifiedDiffLines(cli: Cli, before: string, after: string): string[];
47
59
  export { EXIT_OK, EXIT_ERROR };
@@ -162,22 +162,35 @@ function renderApply(cli, result) {
162
162
  return cli.flags.dryRun ? renderDryRun(cli, result) : renderCommitted(cli, result);
163
163
  }
164
164
  function renderDryRun(cli, result) {
165
- const { render, style, io } = cli;
166
- io.err(style.dim(` dry run — ${Object.keys(result.diffs ?? {}).length} file(s) would change, nothing committed`));
167
- for (const [path, { before, after }] of Object.entries(result.diffs ?? {})) {
165
+ renderDiffs(cli, result.diffs ?? {});
166
+ return EXIT_OK;
167
+ }
168
+ /**
169
+ * The dry-run rendering every write verb shares (spec/cli §3.6): the note on
170
+ * stderr, then per changed file the path, the unified diff of before → after
171
+ * (`spec/surface` §3) and an empty line. Document verbs hand their
172
+ * `DocOpResult.diffs` here; block verbs their `ApplyResult.diffs`.
173
+ */
174
+ export function renderDiffs(cli, diffs) {
175
+ const { style, io } = cli;
176
+ io.err(style.dim(` dry run — ${Object.keys(diffs).length} file(s) would change, nothing committed`));
177
+ for (const [path, { before, after }] of Object.entries(diffs)) {
168
178
  io.out(`${style.bold(path)}`);
169
- for (const line of unifiedDiff(before, after).split("\n")) {
170
- if (line.startsWith("+"))
171
- io.out(style.ok(line));
172
- else if (line.startsWith("-"))
173
- io.out(style.err(line));
174
- else
175
- io.out(style.dim(line));
176
- }
179
+ for (const line of unifiedDiffLines(cli, before, after))
180
+ io.out(line);
177
181
  io.out("");
178
182
  }
179
- void render;
180
- return EXIT_OK;
183
+ }
184
+ /** The unified diff of before → after (`spec/surface` §3), one styled line per entry. */
185
+ export function unifiedDiffLines(cli, before, after) {
186
+ const { style } = cli;
187
+ return unifiedDiff(before, after).split("\n").map((line) => {
188
+ if (line.startsWith("+"))
189
+ return style.ok(line);
190
+ if (line.startsWith("-"))
191
+ return style.err(line);
192
+ return style.dim(line);
193
+ });
181
194
  }
182
195
  function renderCommitted(cli, result) {
183
196
  const { render, style, io } = cli;
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
- import { rebuildIndex, runGc, reposStatus, buildEmbedTasks, buildDocEmbedTasks, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, RepoSelectionError, } from "@omgbase/core";
3
- import { CliUsageError, EngineErrorLike, EXIT_OK, EXIT_ERROR, renderHelp } from "../output.js";
2
+ import { rebuildIndex, runGc, reposStatus, buildEmbedTasks, buildDocEmbedTasks, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, ftsIndexedRowCount, liveLeafCount, } from "@omgbase/core";
3
+ import { CliUsageError, EngineErrorLike, EXIT_OK, EXIT_ERROR, emitMachine, renderHelp } from "../output.js";
4
4
  import { loadEmbedding, drainEmbeddings } from "./_embed.js";
5
5
  // admin/maintenance (11 §5.8–5.9). The live watcher moved to `omg sync --watch`.
6
6
  // ---- rebuild-index ----------------------------------------------------------
@@ -27,6 +27,9 @@ function runRebuild(cli, args) {
27
27
  const target = values.sections ? "sections" : values.edges ? "edges" : values.fts ? "fts" : values["block-changes"] ? "block_changes" : "all";
28
28
  const ws = cli.workspace();
29
29
  rebuildIndex(ws.store, target);
30
+ // The library call returns nothing; the machine result names what was rebuilt.
31
+ if (cli.flags.mode !== "human")
32
+ return emitMachine(cli, { rebuilt: target });
30
33
  cli.io.err(cli.style.dim(` ${cli.style.ok(cli.render.g.ok)} rebuilt ${target}`));
31
34
  return EXIT_OK;
32
35
  }
@@ -52,9 +55,8 @@ function runGcCmd(cli, args) {
52
55
  }
53
56
  const result = runGc(ws.store, { enabled: enabled || dryRun });
54
57
  if (cli.flags.mode !== "human")
55
- cli.io.out(JSON.stringify(result));
56
- else
57
- cli.io.err(cli.style.dim(` swept ${result.blobsSwept} blobs, ${result.treeNodesSwept} tree nodes`));
58
+ return emitMachine(cli, result);
59
+ cli.io.err(cli.style.dim(` swept ${result.blobsSwept} blobs, ${result.treeNodesSwept} tree nodes`));
58
60
  return EXIT_OK;
59
61
  }
60
62
  // ---- doctor -----------------------------------------------------------------
@@ -63,7 +65,7 @@ function runDoctor(cli, args) {
63
65
  if (values.help) {
64
66
  return renderHelp(cli, {
65
67
  name: "doctor",
66
- summary: "Invariant sweep: convergence, FTS rows vs live blocks, dangling revisions, SQLite integrity — exit 1 on any failure (CI-able)",
68
+ summary: "Invariant sweep: convergence, FTS rows vs live leaf blocks, dangling revisions, SQLite integrity — exit 1 on any failure (CI-able)",
67
69
  usage: "doctor [--json]",
68
70
  options: [["--json", "`{ ok, checks: [{ name, ok, detail }] }`"]],
69
71
  });
@@ -74,16 +76,19 @@ function runDoctor(cli, args) {
74
76
  const checks = [];
75
77
  const status = reposStatus(ws.store, repo.repoId);
76
78
  checks.push({ name: "convergence", ok: status.unconverged === 0, detail: `${status.unconverged} unconverged` });
77
- const ftsCount = db.prepare("SELECT count(*) c FROM blocks_fts").get().c;
78
- const liveBlocks = db.prepare("SELECT count(*) c FROM blocks WHERE deleted_commit IS NULL").get().c;
79
- checks.push({ name: "fts rows == live blocks", ok: ftsCount === liveBlocks, detail: `fts=${ftsCount} live=${liveBlocks}` });
79
+ // The index holds exactly the live LEAF blocks (spec/search §1.1): a container
80
+ // has no row, a tombstone none. Counted from the index's shadow table — a plain
81
+ // count(*) on an external-content FTS table answers from `blocks` instead.
82
+ const ftsCount = ftsIndexedRowCount(db);
83
+ const leaves = liveLeafCount(db);
84
+ checks.push({ name: "fts rows == live leaf blocks", ok: ftsCount === leaves, detail: `fts=${ftsCount} leaves=${leaves}` });
80
85
  const dangling = db.prepare("SELECT count(*) c FROM docs d WHERE d.deleted_commit IS NULL AND d.current_rev IS NOT NULL AND NOT EXISTS (SELECT 1 FROM revisions r WHERE r.rev_id = d.current_rev)").get().c;
81
86
  checks.push({ name: "no dangling current_rev", ok: dangling === 0, detail: `${dangling} dangling` });
82
87
  const integrity = db.pragma("integrity_check", { simple: true });
83
88
  checks.push({ name: "sqlite integrity", ok: integrity === "ok", detail: integrity });
84
89
  const allOk = checks.every((c) => c.ok);
85
90
  if (cli.flags.mode !== "human") {
86
- cli.io.out(JSON.stringify({ ok: allOk, checks }));
91
+ emitMachine(cli, { ok: allOk, checks });
87
92
  return allOk ? EXIT_OK : EXIT_ERROR;
88
93
  }
89
94
  const { style, io, render } = cli;
@@ -104,9 +109,10 @@ function resolveConfigScope(cli, ws) {
104
109
  // An explicit --repo <slug> that doesn't resolve is a real error. A bare
105
110
  // (cwd-based) selection that's ambiguous or empty just means "workspace
106
111
  // layer" — the workspace is what you mean when not clearly inside a repo.
112
+ // (`Cli.repo` has already wrapped the RepoSelectionError as repo_not_found.)
107
113
  if (cli.flags.repo)
108
114
  throw err;
109
- if (err instanceof RepoSelectionError)
115
+ if (err instanceof EngineErrorLike && err.code === "repo_not_found")
110
116
  return { kind: "workspace" };
111
117
  throw err;
112
118
  }
@@ -139,7 +145,7 @@ function runConfig(cli, args) {
139
145
  if (scope.kind === "workspace") {
140
146
  const s = workspaceSettings(ws.store);
141
147
  if (cli.flags.mode !== "human")
142
- cli.io.out(JSON.stringify(s));
148
+ return emitMachine(cli, s);
143
149
  else {
144
150
  cli.io.err(cli.style.dim(` workspace defaults`));
145
151
  for (const [k, v] of Object.entries(s))
@@ -149,10 +155,8 @@ function runConfig(cli, args) {
149
155
  }
150
156
  const effective = resolveSettings(ws.store, scope.repoId);
151
157
  const own = repoOwnSettings(ws.store, scope.repoId);
152
- if (cli.flags.mode !== "human") {
153
- cli.io.out(JSON.stringify(effective));
154
- return EXIT_OK;
155
- }
158
+ if (cli.flags.mode !== "human")
159
+ return emitMachine(cli, effective);
156
160
  cli.io.err(cli.style.dim(` ${label} (effective; ${cli.render.g.diamond} = overrides workspace default)`));
157
161
  for (const [k, v] of Object.entries(effective)) {
158
162
  const overridden = Object.prototype.hasOwnProperty.call(own, k);
@@ -210,17 +214,16 @@ async function runEmbed(cli, args) {
210
214
  // No provider configured (05 §6). Honest report; not an error.
211
215
  const hint = 'set one with `omg config set embedding.provider <command|url>` (e.g. omgbase-embedder)';
212
216
  if (cli.flags.mode !== "human")
213
- cli.io.out(JSON.stringify({ provider: null, queued: 0 }));
214
- else
215
- cli.io.err(cli.style.dim(` no embedding provider configured — ${hint}`));
217
+ return emitMachine(cli, { provider: null, queued: 0 });
218
+ cli.io.err(cli.style.dim(` no embedding provider configured — ${hint}`));
216
219
  return EXIT_OK;
217
220
  }
218
221
  if (sub === "drain") {
219
222
  await loaded.close(); // drainEmbeddings connects its own provider
220
223
  const result = await drainEmbeddings(cli, ws, repo.repoId, { verbose, prune });
221
224
  if (cli.flags.mode !== "human")
222
- cli.io.out(JSON.stringify({ provider: loaded.providerName, ...(result ?? { embedded: 0, cached: 0 }) }));
223
- else if (result)
225
+ return emitMachine(cli, { provider: loaded.providerName, ...(result ?? { embedded: 0, cached: 0 }) });
226
+ if (result)
224
227
  cli.io.err(` ${cli.style.ok(cli.render.g.ok)} embedded ${result.embedded}, cached ${result.cached}`);
225
228
  return EXIT_OK;
226
229
  }
@@ -236,7 +239,7 @@ async function runEmbed(cli, args) {
236
239
  // status
237
240
  const payload = { provider: loaded.providerName, model: loaded.provider.model, dim: loaded.provider.dim, embeddable: tasks.length, queued: pending.length, docs: docTasks.length, docsQueued: docPending.length, foreignBlocks: foreign.blocks, foreignDocs: foreign.docs };
238
241
  if (cli.flags.mode !== "human")
239
- cli.io.out(JSON.stringify(payload));
242
+ return emitMachine(cli, payload);
240
243
  else {
241
244
  cli.io.out(` provider ${cli.style.accent(loaded.providerName)} ${cli.style.dim(`(${loaded.provider.model}, ${loaded.provider.dim}d)`)}`);
242
245
  cli.io.out(` embeddable ${tasks.length} ${cli.style.dim("queued")} ${payload.queued}`);
@@ -3,9 +3,9 @@ import { join, resolve, dirname, relative, sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { createInterface } from "node:readline";
5
5
  import { spawnSync } from "node:child_process";
6
- import { Workspace, reposStatus, workspaceSettings, writeWorkspaceSettings } from "@omgbase/core";
6
+ import { Workspace, reposStatus, reposList, workspaceSettings, writeWorkspaceSettings } from "@omgbase/core";
7
7
  import { columns as columnsLocal } from "../render.js";
8
- import { EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
8
+ import { EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
9
9
  // Bootstrap: init / repos (11 §5.1). Pointing a repo at a directory is
10
10
  // `omg source add <dir>` (ADR-014) — a repo owns identity; sources bring bytes.
11
11
  async function confirmTTY(cli, question) {
@@ -183,10 +183,8 @@ async function runInit(cli, args) {
183
183
  finally {
184
184
  ws.close();
185
185
  }
186
- if (cli.flags.mode !== "human") {
187
- cli.io.out(JSON.stringify({ workspace: dir }));
188
- return EXIT_OK;
189
- }
186
+ if (cli.flags.mode !== "human")
187
+ return emitMachine(cli, { workspace: dir });
190
188
  const { render, style, io } = cli;
191
189
  io.out(render.wordmark("initialized"));
192
190
  io.out(render.rule(40));
@@ -209,25 +207,19 @@ function runRepos(cli, args) {
209
207
  name: "repos",
210
208
  summary: "List the repos in this workspace: slug, root path (from its fs source), doc/block counts",
211
209
  usage: "repos [--ids|--json|--jsonl]",
212
- options: [["--ids", "slugs only, one per line"]],
210
+ options: [
211
+ ["--ids", "slugs only, one per line"],
212
+ ["--json", "the `repos` tool's result: `{ repos: [{ slug, hasSource }] }` (counts are `status --json`)"],
213
+ ],
213
214
  });
214
215
  }
215
216
  const ws = cli.workspace();
217
+ // `--json` is the `repos` tool's result; the human table adds each repo's root
218
+ // and its `repos_status` counts, which the reader wants at a glance.
219
+ const list = reposList(ws.store);
220
+ if (cli.flags.mode !== "human")
221
+ return emitMachine(cli, list, { items: list.repos, ids: list.repos.map((r) => r.slug) });
216
222
  const repos = ws.repos().map((r) => ({ ...r, status: reposStatus(ws.store, r.repoId) }));
217
- if (cli.flags.mode === "json") {
218
- cli.io.out(JSON.stringify(repos.map((r) => ({ slug: r.slug, root: r.rootPath, docs: r.status.docs, blocks: r.status.blocks }))));
219
- return EXIT_OK;
220
- }
221
- if (cli.flags.mode === "jsonl") {
222
- for (const r of repos)
223
- cli.io.out(JSON.stringify({ slug: r.slug, root: r.rootPath, docs: r.status.docs, blocks: r.status.blocks }));
224
- return EXIT_OK;
225
- }
226
- if (cli.flags.mode === "ids") {
227
- for (const r of repos)
228
- cli.io.out(r.slug);
229
- return EXIT_OK;
230
- }
231
223
  const { render, style, io } = cli;
232
224
  const rows = repos.map((r) => [
233
225
  ` ${render.g.diamond} ${style.accent(r.slug)}`,
@@ -236,7 +228,7 @@ function runRepos(cli, args) {
236
228
  style.dim(`${r.status.blocks} blocks`),
237
229
  ]);
238
230
  if (rows.length === 0)
239
- io.out(style.dim(" no repos attached"));
231
+ io.err(style.dim(" no repos attached"));
240
232
  else
241
233
  for (const line of columnsLocal(rows))
242
234
  io.out(line);
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { resolveRef, nodesGet, docsRead } from "@omgbase/core";
3
- import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
3
+ import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { remoteCall } from "./_remote.js";
5
5
  import { expandBlockArgs } from "./_mutate.js";
6
6
  /** `--resolution` only means something for a block; say so (stderr) rather than
@@ -16,7 +16,10 @@ async function catOne(cli, ref, resolution) {
16
16
  const r = await remoteCall(cli, "read_ref", { ref, ...(resolution !== "raw" ? { resolution } : {}) });
17
17
  if (r.kind === "document") {
18
18
  warnDocResolution(cli, ref, resolution);
19
- return { text: String(r.content ?? ""), json: { doc: r.docId, path: r.path, content: r.content }, capture: r.content };
19
+ // The `docs_read` result is the `read_ref` document result without `kind`.
20
+ const { kind: _kind, ...doc } = r;
21
+ void _kind;
22
+ return { text: String(r.content ?? ""), json: doc, capture: r.content };
20
23
  }
21
24
  return { text: String(r.raw ?? r.text ?? r.label ?? ""), json: r, capture: r };
22
25
  }
@@ -35,8 +38,8 @@ async function catOne(cli, ref, resolution) {
35
38
  if (!res)
36
39
  throw new EngineErrorLike("doc_missing", `no document ${ref}`);
37
40
  warnDocResolution(cli, ref, resolution);
38
- // shell: the bytes (a string; @_ only)
39
- return { text: res.content, json: { doc: res.docId, path: res.path, content: res.content }, capture: res.content };
41
+ // shell: the bytes (a string; @_ only). `--json` is the `docs_read` result.
42
+ return { text: res.content, json: res, capture: res.content };
40
43
  }
41
44
  const node = nodesGet(ws.store, resolved.docId, resolved.blockId, { resolution });
42
45
  if (!node)
@@ -72,14 +75,9 @@ async function runCat(cli, args) {
72
75
  // One ref keeps the single-entity shape (a string/object); several become a list.
73
76
  const single = results.length === 1 ? results[0] : null;
74
77
  cli.capture?.(single ? single.capture : results.map((r) => r.capture));
75
- if (cli.flags.mode === "json") {
76
- cli.io.out(JSON.stringify(single ? single.json : results.map((r) => r.json)));
77
- return EXIT_OK;
78
- }
79
- if (cli.flags.mode === "jsonl") {
80
- for (const r of results)
81
- cli.io.out(JSON.stringify(r.json));
82
- return EXIT_OK;
78
+ if (cli.flags.mode !== "human") {
79
+ const docs = results.map((r) => r.json);
80
+ return single ? emitMachine(cli, single.json) : emitMachine(cli, docs, { items: docs });
83
81
  }
84
82
  for (const r of results)
85
83
  cli.io.out(r.text);
@@ -1,37 +1,39 @@
1
1
  import { parseArgs } from "node:util";
2
- import { findDoc, diffUnified } from "@omgbase/core";
3
- import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
2
+ import { findDoc, docDiffUnified, diffBlocks } from "@omgbase/core";
3
+ import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { remoteCall } from "./_remote.js";
5
- // `omg diff <doc>` (11 §5.5) — unified diff. With no revisions: current vs
6
- // previous ("what did the last commit do here").
5
+ // `omg diff <doc>` (11 §5.5) — `diff_unified`: the unified diff, `--json` the
6
+ // tool's `{ doc, path, from, to, diff }`. With no revisions: current vs previous
7
+ // ("what did the last commit do here"). `--blocks` is the block-grain `diff`
8
+ // tool: added/removed/changed blocks between the same two revisions.
7
9
  async function runDiff(cli, args) {
8
10
  const { values, positionals } = parseArgs({
9
11
  args,
10
12
  allowPositionals: true,
11
- options: { from: { type: "string" }, to: { type: "string" }, help: { type: "boolean" } },
13
+ options: { from: { type: "string" }, to: { type: "string" }, blocks: { type: "boolean" }, help: { type: "boolean" } },
12
14
  });
13
15
  if (values.help) {
14
16
  return renderHelp(cli, {
15
17
  name: "diff",
16
18
  summary: "Unified diff of a document between two revisions (default: previous → current)",
17
- usage: "diff <doc> [--from <rev>] [--to <rev>]",
19
+ usage: "diff <doc> [--from <rev>] [--to <rev>] [--blocks]",
18
20
  options: [
19
21
  ["--from <rev>", "older revision id (default: the one before --to)"],
20
22
  ["--to <rev>", "newer revision id (default: current)"],
23
+ ["--blocks", "block-grain: which blocks were added, removed or changed (the `diff` tool)"],
21
24
  ],
22
25
  });
23
26
  }
24
27
  const ref = positionals[0];
25
28
  if (!ref)
26
29
  throw new CliUsageError("diff requires a <doc>");
27
- let docId, path, fromRev, toRev, text;
30
+ let result;
28
31
  if (cli.flags.server) {
29
- const r = await remoteCall(cli, "diff_unified", {
32
+ result = await remoteCall(cli, "diff_unified", {
30
33
  doc: ref,
31
34
  ...(values.from ? { from_rev: values.from } : {}),
32
35
  ...(values.to ? { to_rev: values.to } : {}),
33
36
  });
34
- ({ doc: docId, path, from: fromRev, to: toRev, diff: text } = r);
35
37
  }
36
38
  else {
37
39
  const ws = cli.workspace();
@@ -39,30 +41,40 @@ async function runDiff(cli, args) {
39
41
  const info = ref.startsWith("d_") ? findDoc(ws.store, { docId: ref }) : findDoc(ws.store, { repoId: repo.repoId, path: ref });
40
42
  if (!info)
41
43
  throw new EngineErrorLike("doc_missing", `no document ${ref}`);
42
- // Resolve from/to revisions: default is (previous, current) by seq desc.
43
- const revs = ws.store.db
44
- .prepare("SELECT rev_id, seq FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 2")
45
- .all(info.docId);
46
- const to = values.to ?? revs[0]?.rev_id;
47
- const from = values.from ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
48
- if (!to || !from)
49
- throw new EngineErrorLike("doc_missing", `no revisions to diff for ${ref}`);
50
- docId = info.docId;
51
- path = info.path;
52
- fromRev = from;
53
- toRev = to;
54
- text = diffUnified(ws.store, info.docId, from, to);
44
+ result = docDiffUnified(ws.store, info.docId, {
45
+ ...(values.from ? { fromRev: values.from } : {}),
46
+ ...(values.to ? { toRev: values.to } : {}),
47
+ label: ref,
48
+ });
55
49
  }
56
- if (cli.flags.mode === "json") {
57
- cli.io.out(JSON.stringify({ doc: docId, path, from: fromRev, to: toRev, diff: text }));
50
+ const { style, io } = cli;
51
+ if (values.blocks) {
52
+ // Block-grain over the same resolved revision pair.
53
+ let entries;
54
+ if (cli.flags.server)
55
+ entries = await remoteCall(cli, "diff", { doc: ref, from_rev: result.from, to_rev: result.to });
56
+ else
57
+ entries = diffBlocks(cli.workspace().store, result.doc, result.from, result.to);
58
+ if (cli.flags.mode !== "human")
59
+ return emitMachine(cli, entries, { items: entries, ids: entries.map((e) => e.blockId) });
60
+ if (entries.length === 0) {
61
+ io.err(style.dim(" no changes"));
62
+ return EXIT_OK;
63
+ }
64
+ for (const e of entries) {
65
+ const mark = e.kind === "added" ? style.ok("+") : e.kind === "removed" ? style.err("-") : style.warn("~");
66
+ const preview = (e.after ?? e.before ?? "").split("\n")[0] ?? "";
67
+ io.out(`${mark} ${style.id(e.blockId)} ${preview}`.trimEnd());
68
+ }
58
69
  return EXIT_OK;
59
70
  }
60
- const { style, io } = cli;
61
- if (!text) {
71
+ if (cli.flags.mode !== "human")
72
+ return emitMachine(cli, result);
73
+ if (!result.diff) {
62
74
  io.err(style.dim(" no changes"));
63
75
  return EXIT_OK;
64
76
  }
65
- for (const line of text.split("\n")) {
77
+ for (const line of result.diff.split("\n")) {
66
78
  if (line.startsWith("+"))
67
79
  io.out(style.ok(line));
68
80
  else if (line.startsWith("-"))
@@ -2,22 +2,48 @@ import { parseArgs } from "node:util";
2
2
  import { parse as parseYamlScalar } from "yaml";
3
3
  import { docsCreate, docsMove, docsDelete, docsSetMeta } from "@omgbase/core";
4
4
  import { CliUsageError, EXIT_OK, renderHelp } from "../output.js";
5
- import { readContent, extractContentOpts } from "./_mutate.js";
5
+ import { readContent, extractContentOpts, renderDiffs } from "./_mutate.js";
6
6
  import { remoteCall } from "./_remote.js";
7
7
  // Document-level commands (11 §5.6): new / mv / rm --doc / meta. Thin wrappers
8
8
  // over the core doc ops, which own the file-write + commit + flock protocol.
9
+ // The global --dry-run rides as the op's `dryRun` (spec/cli §3.6): the op
10
+ // validates and returns the per-file diffs it would make, committing nothing.
9
11
  function ctxOf(cli, ws, repoId, rootPath, actor) {
10
- return { repoId, ...(rootPath ? { rootPath } : {}), omgbaseDir: ws.omgbaseDir, ...(actor ? { actor } : {}) };
12
+ return {
13
+ repoId,
14
+ ...(rootPath ? { rootPath } : {}),
15
+ omgbaseDir: ws.omgbaseDir,
16
+ ...(actor ? { actor } : {}),
17
+ ...(cli.flags.dryRun ? { dryRun: true } : {}),
18
+ };
11
19
  }
12
20
  function report(cli, verb, res) {
13
21
  if (cli.flags.mode !== "human") {
14
22
  cli.io.out(JSON.stringify(res));
15
23
  return EXIT_OK;
16
24
  }
25
+ if (cli.flags.dryRun) {
26
+ renderDiffs(cli, res.diffs ?? {});
27
+ return EXIT_OK;
28
+ }
17
29
  cli.io.err(cli.style.dim(` ${cli.style.ok(cli.render.g.ok)} ${verb} ${cli.style.accent(res.path)}`));
18
30
  cli.io.out(res.docId);
19
31
  return EXIT_OK;
20
32
  }
33
+ // `mv` does not rewrite inbound links; say so wherever the move is reported
34
+ // (spec/cli §6 `mv`): the human confirmation carries the same `dangling` list
35
+ // the --json result does, with the retarget that fixes them.
36
+ function reportMove(cli, from, res) {
37
+ const code = report(cli, "moved to", res);
38
+ if (cli.flags.mode !== "human" || res.dangling.length === 0)
39
+ return code;
40
+ const { style, io } = cli;
41
+ const where = res.dangling.map((l) => (l.block ? `${l.path} ${l.block}` : `${l.path} (frontmatter)`)).join(", ");
42
+ const n = res.dangling.length;
43
+ io.err(style.warn(` ${style.warn(cli.render.g.warn)} ${n} inbound link${n === 1 ? "" : "s"} still name${n === 1 ? "s" : ""} the old path: ${where}`));
44
+ io.err(style.dim(` fix: ${cli.prog} retarget /${from} /${res.path} --apply`));
45
+ return code;
46
+ }
21
47
  // ---- new --------------------------------------------------------------------
22
48
  async function runNew(cli, args) {
23
49
  const content = extractContentOpts(args);
@@ -44,7 +70,7 @@ async function runNew(cli, args) {
44
70
  throw new CliUsageError("new requires a <path>");
45
71
  const bytes = readContent(content);
46
72
  if (cli.flags.server) {
47
- return report(cli, "created", await remoteCall(cli, "docs_create", { path, markdown: bytes }));
73
+ return report(cli, "created", await remoteCall(cli, "docs_create", { path, markdown: bytes, ...dryRunArg(cli) }));
48
74
  }
49
75
  const ws = cli.workspace();
50
76
  const repo = cli.repo(ws);
@@ -71,17 +97,18 @@ async function runMv(cli, args) {
71
97
  if (!doc || !toPath)
72
98
  throw new CliUsageError("mv requires <doc> and <new-path>");
73
99
  if (cli.flags.server) {
74
- return report(cli, "moved to", await remoteCall(cli, "docs_move", { doc, to_path: toPath }));
100
+ const res = await remoteCall(cli, "docs_move", { doc, to_path: toPath, ...dryRunArg(cli) });
101
+ return reportMove(cli, fromPathOf(res, doc), res);
75
102
  }
76
103
  const ws = cli.workspace();
77
104
  const repo = cli.repo(ws);
78
105
  const res = docsMove(ws.store, ctxOf(cli, ws, repo.repoId, repo.rootPath, values.actor), doc, toPath);
79
- return report(cli, "moved to", res);
106
+ return reportMove(cli, fromPathOf(res, doc), res);
80
107
  }
81
108
  // ---- rm --doc (block rm lives in mutate.ts; this handles the --doc branch) --
82
109
  export async function runRmDoc(cli, doc, actor) {
83
110
  if (cli.flags.server) {
84
- return report(cli, "deleted", await remoteCall(cli, "docs_delete", { doc }));
111
+ return report(cli, "deleted", await remoteCall(cli, "docs_delete", { doc, ...dryRunArg(cli) }));
85
112
  }
86
113
  const ws = cli.workspace();
87
114
  const repo = cli.repo(ws);
@@ -141,6 +168,7 @@ async function runMeta(cli, args) {
141
168
  doc,
142
169
  ...(Object.keys(set).length ? { set } : {}),
143
170
  ...(unset.length ? { unset } : {}),
171
+ ...dryRunArg(cli),
144
172
  }));
145
173
  }
146
174
  const ws = cli.workspace();
@@ -148,6 +176,16 @@ async function runMeta(cli, args) {
148
176
  const res = docsSetMeta(ws.store, ctxOf(cli, ws, repo.repoId, repo.rootPath, values.actor), doc, { set, unset });
149
177
  return report(cli, "patched", res);
150
178
  }
179
+ function dryRunArg(cli) {
180
+ return cli.flags.dryRun ? { dry_run: true } : {};
181
+ }
182
+ // The path a move left behind, for the retarget hint: the dangling links name
183
+ // it as written (`target`), which is the canonical spelling with a leading `/`;
184
+ // fall back to the ref the user typed when nothing dangles.
185
+ function fromPathOf(res, ref) {
186
+ const named = res.dangling.find((l) => l.target)?.target;
187
+ return (named ?? ref).replace(/^\/+/, "");
188
+ }
151
189
  export const cmdNew = { name: "new", summary: "Create a document", run: (c, a) => runNew(c, a) };
152
190
  export const cmdMv = { name: "mv", summary: "Rename a document", run: (c, a) => runMv(c, a) };
153
191
  export const cmdMeta = { name: "meta", summary: "Patch a document's frontmatter", run: (c, a) => runMeta(c, a) };
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { resolve as resolveThing } from "@omgbase/core";
3
- import { CliUsageError, EXIT_OK, renderHelp } from "../output.js";
3
+ import { CliUsageError, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { loadEmbedding } from "./_embed.js";
5
5
  import { remoteCall } from "./_remote.js";
6
6
  // `omg find <text>` (11 §5.2) — resolve: ranked {id, locator, preview,
@@ -68,20 +68,8 @@ async function runFind(cli, args) {
68
68
  cli.io.out(hits[0].id);
69
69
  return EXIT_OK;
70
70
  }
71
- if (cli.flags.mode === "ids") {
72
- for (const h of hits)
73
- cli.io.out(h.id);
74
- return EXIT_OK;
75
- }
76
- if (cli.flags.mode === "json") {
77
- cli.io.out(JSON.stringify(hits));
78
- return EXIT_OK;
79
- }
80
- if (cli.flags.mode === "jsonl") {
81
- for (const h of hits)
82
- cli.io.out(JSON.stringify(h));
83
- return EXIT_OK;
84
- }
71
+ if (cli.flags.mode !== "human")
72
+ return emitMachine(cli, hits, { items: hits, ids: hits.map((h) => h.id) });
85
73
  const { style, io } = cli;
86
74
  if (hits.length === 0) {
87
75
  io.err(style.dim(" no matches"));