omgbase 0.3.0 → 0.4.1

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.
@@ -1,7 +1,8 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { resolveRef, findDoc, loadDocBlocks, blockRaw, oqxRun, oqxRunAsync, collectSemanticPhrases } from "@omgbase/core";
3
- import { CliUsageError, EngineErrorLike, truncationFooter, EXIT_OK, renderHelp, renderHits } from "../output.js";
3
+ import { CliUsageError, EngineErrorLike, renderHelp } from "../output.js";
4
4
  import { loadEmbedding } from "./_embed.js";
5
+ import { renderOqxResult } from "./oqx.js";
5
6
  // `omg run <locator|path>` (11 §5.3) — evaluate the first ```omg fence in a doc
6
7
  // (or the fence at a block locator) and print its results. The fence body is an
7
8
  // OQX source string (the same language `omg query` runs). Strictly read-and-print:
@@ -98,37 +99,9 @@ async function runRun(cli, args) {
98
99
  else {
99
100
  result = oqxRun(ws.store, repo.repoId, source);
100
101
  }
101
- if (cli.flags.mode === "json") {
102
- cli.io.out(JSON.stringify(result));
103
- return EXIT_OK;
104
- }
105
- if (result.consumer === "count" || result.consumer === "exists" || result.consumer === "none") {
106
- cli.io.out(result.consumer === "count" ? String(result.count) : result.consumer === "exists" ? String(result.exists) : String(result.none));
107
- return EXIT_OK;
108
- }
109
- if (result.values) {
110
- for (const v of result.values)
111
- cli.io.out(typeof v === "string" ? v : JSON.stringify(v));
112
- if (result.truncated)
113
- truncationFooter(cli.io, cli.style, result.cursor ?? "");
114
- return EXIT_OK;
115
- }
116
- if (cli.flags.mode === "ids") {
117
- for (const h of result.hits)
118
- cli.io.out(h.id);
119
- return EXIT_OK;
120
- }
121
- const { style, io } = cli;
122
- if (result.hits.length === 0) {
123
- io.err(style.dim(" no hits"));
124
- return EXIT_OK;
125
- }
126
- // Same renderer as `omg query`: `<id> <path>` lines, or a column table when
127
- // the fence projects (a selected `text` shows as its first line).
128
- renderHits(cli, result.hits);
129
- if (result.truncated)
130
- truncationFooter(io, style, result.cursor ?? "");
131
- return EXIT_OK;
102
+ // Same renderer as `omg query`, in every mode: `<id> <path>` lines, or a
103
+ // column table when the fence projects (a selected `text` shows as its first line).
104
+ return renderOqxResult(cli, result);
132
105
  }
133
106
  export const cmdRun = { name: "run", summary: "Evaluate an ```omg fence (OQX, inert, read-only)", run: (c, a) => runRun(c, a) };
134
107
  //# sourceMappingURL=run.js.map
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
- import { resolveRef, nodesGet, findDoc, docLinks, historyNode, docPropertiesMerged } from "@omgbase/core";
3
- import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
2
+ import { resolveRef, nodesGet, docsRead, docLinks, historyNode, docPropertiesMerged } from "@omgbase/core";
3
+ import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  import { expandBlockArgs } from "./_mutate.js";
5
5
  function showOne(cli, ref, include) {
6
6
  const ws = cli.workspace();
@@ -9,11 +9,15 @@ function showOne(cli, ref, include) {
9
9
  if (!resolved)
10
10
  throw new EngineErrorLike("doc_missing", `no node ${ref}`);
11
11
  if (resolved.kind === "document") {
12
- const info = findDoc(ws.store, { docId: resolved.docId });
12
+ // The card reads the merged properties + open edges; `--json` is the
13
+ // `read_ref` result for a document (`{ kind: "document", ...docs_read }`).
14
+ const res = docsRead(ws.store, resolved.docId);
15
+ if (!res)
16
+ throw new EngineErrorLike("doc_missing", `no document ${ref}`);
13
17
  const links = docLinks(ws.store, resolved.docId, { direction: "both" });
14
18
  const properties = docPropertiesMerged(ws.store.db, resolved.docId);
15
- const payload = { kind: "document", id: info.docId, path: info.path, properties, edges: links };
16
- return { payload, render: () => renderDocCard(cli, info.path, properties, links) };
19
+ const payload = { kind: "document", ...res };
20
+ return { payload, render: () => renderDocCard(cli, res.path, properties, links) };
17
21
  }
18
22
  const node = nodesGet(ws.store, resolved.docId, resolved.blockId, { resolution: "full" });
19
23
  if (!node)
@@ -47,14 +51,9 @@ function runShow(cli, args) {
47
51
  // One ref keeps the single-entity shape (→ @_, frame intact); several become a list.
48
52
  const single = cards.length === 1 ? cards[0] : null;
49
53
  cli.capture?.(single ? single.payload : cards.map((c) => c.payload));
50
- if (cli.flags.mode === "json") {
51
- cli.io.out(JSON.stringify(single ? single.payload : cards.map((c) => c.payload)));
52
- return EXIT_OK;
53
- }
54
54
  if (cli.flags.mode !== "human") {
55
- for (const c of cards)
56
- cli.io.out(JSON.stringify(c.payload));
57
- return EXIT_OK;
55
+ const docs = cards.map((c) => c.payload);
56
+ return single ? emitMachine(cli, single.payload) : emitMachine(cli, docs, { items: docs });
58
57
  }
59
58
  for (const c of cards)
60
59
  c.render();
@@ -6,7 +6,7 @@ import { createSource, deleteSource, listSources, getSourceByName, attachSourceT
6
6
  import { columns } from "../render.js";
7
7
  import { drainEmbeddings } from "./_embed.js";
8
8
  import { ensureFsAdapter, FS_ADAPTER } from "./_source.js";
9
- import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
9
+ import { CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
10
10
  // `omg source` (ADR-014) — the source registry: where a repo's bytes come from
11
11
  // (a filesystem directory today; git/S3/etc. via adapters later). A repo owns
12
12
  // identity + history; adding a filesystem source points it at a directory and
@@ -101,14 +101,16 @@ async function runAdd(cli, args) {
101
101
  if (getSourceByName(ws.store, sourceName))
102
102
  throw new EngineErrorLike("path_taken", `a source named '${sourceName}' already exists`);
103
103
  // Consent gate: -y proceeds; a TTY prompts with a live file count; a non-TTY
104
- // without -y refuses rather than absorbing the tree silently.
104
+ // without -y is a usage error (the script forgot the flag; exit 2) rather
105
+ // than a silent absorption of the tree — or a silent success.
105
106
  if (!values.yes) {
107
+ if (!process.stdin.isTTY || !cli.io.stdoutTTY) {
108
+ throw new CliUsageError(`refusing without -y (would ingest files under ${shortenHome(abs)})`, "there is no TTY to confirm on; pass -y to consent to the ingest");
109
+ }
106
110
  const { ok } = await confirmIngestWithCount(cli, abs, slug);
107
- if (cli.io.stdoutTTY)
108
- process.stderr.write("\n");
111
+ process.stderr.write("\n");
109
112
  if (!ok) {
110
- const why = !process.stdin.isTTY || !cli.io.stdoutTTY ? `refusing without -y (would ingest files under ${shortenHome(abs)})` : "cancelled";
111
- cli.io.err(cli.style.dim(` ${why}`));
113
+ cli.io.err(cli.style.dim(" cancelled"));
112
114
  return EXIT_OK;
113
115
  }
114
116
  }
@@ -133,10 +135,8 @@ async function runAdd(cli, args) {
133
135
  return confirmTTY(cli, `embed ${pending} block(s) now via ${where}?`);
134
136
  },
135
137
  });
136
- if (cli.flags.mode !== "human") {
137
- cli.io.out(JSON.stringify({ repo: slug, repoId, source: sourceName, root: abs, ingested: swept.ingested.length, ...(drained ? { embedded: drained.embedded } : {}) }));
138
- return EXIT_OK;
139
- }
138
+ if (cli.flags.mode !== "human")
139
+ return emitMachine(cli, { repo: slug, repoId, source: sourceName, root: abs, ingested: swept.ingested.length, ...(drained ? { embedded: drained.embedded } : {}) });
140
140
  const { render, style, io } = cli;
141
141
  io.out(` ${render.g.diamond} ${style.accent(slug)} ← ${style.path(shortenHome(abs))} ${style.dim(`${swept.ingested.length} files`)}`);
142
142
  if (drained)
@@ -146,23 +146,13 @@ async function runAdd(cli, args) {
146
146
  function runList(cli) {
147
147
  const ws = cli.workspace();
148
148
  const sources = listSources(ws.store).map((s) => ({ ...s, repos: attachedRepoSlugs(cli, ws, s.sourceId) }));
149
- if (cli.flags.mode === "json") {
150
- cli.io.out(JSON.stringify(sources.map((s) => ({ name: s.name, adapter: s.adapter, config: s.config, repos: s.repos }))));
151
- return EXIT_OK;
152
- }
153
- if (cli.flags.mode === "jsonl") {
154
- for (const s of sources)
155
- cli.io.out(JSON.stringify({ name: s.name, adapter: s.adapter, config: s.config, repos: s.repos }));
156
- return EXIT_OK;
157
- }
158
- if (cli.flags.mode === "ids") {
159
- for (const s of sources)
160
- cli.io.out(s.name);
161
- return EXIT_OK;
149
+ if (cli.flags.mode !== "human") {
150
+ const rows = sources.map((s) => ({ name: s.name, adapter: s.adapter, config: s.config, repos: s.repos }));
151
+ return emitMachine(cli, rows, { items: rows, ids: rows.map((r) => r.name) });
162
152
  }
163
153
  const { style, io } = cli;
164
154
  if (sources.length === 0) {
165
- io.out(style.dim(" no sources registered"));
155
+ io.err(style.dim(" no sources registered"));
166
156
  return EXIT_OK;
167
157
  }
168
158
  const rows = sources.map((s) => [
@@ -189,10 +179,8 @@ function runAttachDetach(cli, args, verb) {
189
179
  attachSourceToRepo(ws.store, repo.repoId, source.sourceId);
190
180
  else
191
181
  detachSourceFromRepo(ws.store, repo.repoId, source.sourceId);
192
- if (cli.flags.mode !== "human") {
193
- cli.io.out(JSON.stringify({ source: name, repo: repo.slug, attached: verb === "attach" }));
194
- return EXIT_OK;
195
- }
182
+ if (cli.flags.mode !== "human")
183
+ return emitMachine(cli, { source: name, repo: repo.slug, attached: verb === "attach" });
196
184
  cli.io.out(` ${cli.render.g.ok} ${verb === "attach" ? "attached" : "detached"} ${cli.style.accent(name)} ${verb === "attach" ? "→" : "⇸"} ${cli.style.accent(repo.slug)}`);
197
185
  return EXIT_OK;
198
186
  }
@@ -206,10 +194,8 @@ function runRm(cli, args) {
206
194
  if (!source)
207
195
  throw new EngineErrorLike("target_missing", `no source named '${name}'`);
208
196
  deleteSource(ws.store, source.sourceId);
209
- if (cli.flags.mode !== "human") {
210
- cli.io.out(JSON.stringify({ source: name, deleted: true }));
211
- return EXIT_OK;
212
- }
197
+ if (cli.flags.mode !== "human")
198
+ return emitMachine(cli, { source: name, deleted: true });
213
199
  cli.io.out(` ${cli.render.g.ok} deleted source ${cli.style.accent(name)}`);
214
200
  return EXIT_OK;
215
201
  }
@@ -1,8 +1,10 @@
1
1
  import { parseArgs } from "node:util";
2
- import { reposStatus, syncStatus, watchLeaseLive } from "@omgbase/core";
3
- import { EXIT_OK, renderHelp } from "../output.js";
2
+ import { reposStatus, syncStatus, watchLeaseLive, staleEmbedCount, resolveSettings, embeddingSettings } from "@omgbase/core";
3
+ import { EXIT_OK, emitMachine, renderHelp } from "../output.js";
4
4
  // `omg status` (11 §5.2): repos_status + sync_status + watch-lease probe +
5
- // embedding queue depth. The "where am I" command — the visual showcase.
5
+ // embedding queue depth. The "where am I" command — the visual showcase. The
6
+ // machine shape is the two tools' results, nested as they are on the wire
7
+ // (spec/cli §6): `{ ...repos_status, sync: sync_status, watcher, embedQueue }`.
6
8
  function runStatus(cli, args) {
7
9
  const { values } = parseArgs({ args, allowPositionals: true, options: { help: { type: "boolean" } } });
8
10
  if (values.help) {
@@ -10,7 +12,7 @@ function runStatus(cli, args) {
10
12
  name: "status",
11
13
  summary: "Where am I: the active repo, doc/block/commit counts, sync convergence, watcher, embed queue",
12
14
  usage: "status [--repo <slug>] [--json]",
13
- options: [["--json", "a flat object: repo, root, docs, blocks, commits, openEdges, convergent, disk, watcher, embedQueue"]],
15
+ options: [["--json", "the repos_status result, plus `sync` (the sync_status result), `watcher` (live|none) and `embedQueue` (stale embeddable blocks)"]],
14
16
  });
15
17
  }
16
18
  const ws = cli.workspace();
@@ -18,27 +20,13 @@ function runStatus(cli, args) {
18
20
  const rs = reposStatus(ws.store, repo.repoId, repo.rootPath ?? undefined);
19
21
  const ss = syncStatus(ws.store, repo.repoId, repo.rootPath ?? undefined);
20
22
  const watcher = watchLeaseLive(ws.omgbaseDir);
21
- // Embedding queue depth: count blocks whose embeddings are missing is a v1
22
- // approximation; the queue table isn't wired yet, so report 0 (05 §6 stub).
23
- const queued = 0;
24
- const payload = {
25
- repo: repo.slug,
26
- root: repo.rootPath,
27
- docs: rs.docs,
28
- blocks: rs.blocks,
29
- commits: rs.commits,
30
- openEdges: rs.openEdges,
31
- unconverged: rs.unconverged,
32
- convergent: ss.convergent,
33
- disk: ss.disk,
34
- lastCommitSeq: ss.lastCommitSeq,
35
- watcher: watcher ? "live" : "none",
36
- embedQueue: queued,
37
- };
38
- if (cli.flags.mode !== "human") {
39
- cli.io.out(JSON.stringify(payload));
40
- return EXIT_OK;
41
- }
23
+ // Embedding queue depth (spec/search §2.3): the embeddable blocks whose
24
+ // current context has no cached vector — for the configured model when one is
25
+ // named, else for any. Counted from the cache alone: no provider is spawned.
26
+ const queued = staleEmbedCount(ws.store, repo.repoId, embeddingSettings(resolveSettings(ws.store, repo.repoId)).model);
27
+ const payload = { ...rs, sync: ss, watcher: watcher ? "live" : "none", embedQueue: queued };
28
+ if (cli.flags.mode !== "human")
29
+ return emitMachine(cli, payload);
42
30
  const { render, style, io } = cli;
43
31
  const g = render.g;
44
32
  io.out(render.wordmark(repo.slug));
@@ -1,8 +1,8 @@
1
1
  import { resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
- import { Watcher, WatchLease, EmbedDrainer, freshnessSweep, EngineError } from "@omgbase/core";
3
+ import { Watcher, WatchLease, EmbedDrainer, freshnessSweep, awaitReady, WATCH_READY_PATIENCE_MS, EngineError } from "@omgbase/core";
4
4
  import { runFsMirror } from "@omgbase/sync";
5
- import { EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
5
+ import { EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
6
6
  import { loadEmbedding } from "./_embed.js";
7
7
  import { remoteSpec } from "./_remote.js";
8
8
  import { openRepoSource } from "./_source.js";
@@ -62,17 +62,16 @@ async function runSync(cli, args) {
62
62
  }
63
63
  function runOneShot(cli, ws, repo) {
64
64
  if (!repo.rootPath) {
65
- if (cli.flags.mode === "human")
66
- cli.io.out(cli.style.dim(` ${repo.slug} has no filesystem source — nothing to sync`));
67
- else
68
- cli.io.out(JSON.stringify({ scanned: 0, ingested: [], deleted: [], conflicted: [], changed: false }));
65
+ // Nothing to sweep: an empty-result note (stderr, §3.1), or the empty sweep shape.
66
+ if (cli.flags.mode !== "human")
67
+ return emitMachine(cli, { scanned: 0, ingested: [], deleted: [], conflicted: [], changed: false });
68
+ cli.io.err(cli.style.dim(` ${repo.slug} has no filesystem source — nothing to sync`));
69
69
  return EXIT_OK;
70
70
  }
71
71
  const result = freshnessSweep(ws.store, repo.repoId, repo.rootPath);
72
- if (cli.flags.mode !== "human") {
73
- cli.io.out(JSON.stringify(result));
74
- return EXIT_OK;
75
- }
72
+ // A `SweepResult` is a record (its lists are per-kind): every machine mode is the document.
73
+ if (cli.flags.mode !== "human")
74
+ return emitMachine(cli, result);
76
75
  const { render, style, io } = cli;
77
76
  const g = render.g;
78
77
  io.out(render.wordmark("sync"));
@@ -97,8 +96,11 @@ function runOneShot(cli, ws, repo) {
97
96
  io.out(` ${style.dim("already up to date")}`);
98
97
  return EXIT_OK;
99
98
  }
100
- // Local live watcher (the former `omg watch`): prime with a sweep, then run the
101
- // external fs-adapter watcher until a signal, keeping embeddings warm.
99
+ // Local live watcher (the former `omg watch`), in the order of spec/sync §5:
100
+ // take the lease, open the source, `watch`, wait for `ready` (bounded), THEN the
101
+ // priming sweep, then report live — so an edit landing before the feed is primed
102
+ // is caught by the sweep and one landing after it by the feed. Runs until a
103
+ // signal, keeping embeddings warm.
102
104
  async function runLocalWatch(cli, ws, repo) {
103
105
  const lease = WatchLease.tryAcquire(ws.omgbaseDir);
104
106
  if (!lease)
@@ -124,8 +126,6 @@ async function runLocalWatch(cli, ws, repo) {
124
126
  onError: (err) => cli.io.err(cli.style.dim(` embed drain failed: ${String(err)}`)),
125
127
  })
126
128
  : null;
127
- if (repo.rootPath)
128
- freshnessSweep(ws.store, repo.repoId, repo.rootPath); // start fresh
129
129
  const source = await openRepoSource(ws.store, repo);
130
130
  if (!source) {
131
131
  cli.io.err(cli.style.dim(` ${repo.slug} has no filesystem source — nothing to watch`));
@@ -146,6 +146,11 @@ async function runLocalWatch(cli, ws, repo) {
146
146
  onError: (err) => cli.io.err(cli.style.err(` watch error: ${String(err)}`)),
147
147
  });
148
148
  await watcher.start();
149
+ if (!(await awaitReady(watcher.ready(), WATCH_READY_PATIENCE_MS))) {
150
+ cli.io.err(cli.style.dim(` the fs adapter did not report ready within ${WATCH_READY_PATIENCE_MS / 1000}s — proceeding as if ready (an edit made before now may be missed until the next sweep)`));
151
+ }
152
+ if (repo.rootPath)
153
+ freshnessSweep(ws.store, repo.repoId, repo.rootPath); // prime: anything that landed before the feed was ready
149
154
  drainer?.schedule(); // embed anything already stale at startup, in the background
150
155
  cli.io.err(cli.style.dim(` watching ${repo.slug} — Ctrl-C to stop${drainer ? " · auto-embed on" : ""}`));
151
156
  await new Promise((resolveWait) => {
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { docsUpdate, renderOpsetPlan, isValidId } from "@omgbase/core";
2
+ import { docsUpdate, renderOpsetPlan } from "@omgbase/core";
3
3
  import { CliUsageError, EXIT_OK, renderHelp } from "../output.js";
4
4
  import { readContent, extractContentOpts } from "./_mutate.js";
5
5
  import { cmdUpdate as cmdUpdateBlock } from "./mutate.js";
@@ -13,11 +13,13 @@ import { remoteCall } from "./_remote.js";
13
13
  // --dry-run (or --plan) prints the plan (identity effects + summary), commits
14
14
  // nothing.
15
15
  function runUpdate(cli, args) {
16
- // Peek at the target to dispatch: a b_ id is a block replace; anything else
17
- // (d_ id or path) is a whole-document update. The block path re-parses args.
16
+ // Peek at the target to dispatch: a `b_` token is a block replace — a token
17
+ // that names no live block fails there as block_missing (spec/cli §6), never
18
+ // as a document path; anything else (d_ id or path) is a whole-document
19
+ // update. The block path re-parses args.
18
20
  const peek = extractContentOpts(args);
19
21
  const target = peek.rest.find((a) => !a.startsWith("-"));
20
- if (target && isValidId(target, "b"))
22
+ if (target && target.startsWith("b_"))
21
23
  return cmdUpdateBlock.run(cli, args);
22
24
  return runDocUpdate(cli, args);
23
25
  }
@@ -92,7 +94,10 @@ async function runDocUpdate(cli, args) {
92
94
  }
93
95
  const g = cli.render.g;
94
96
  cli.io.err(cli.style.dim(` ${cli.style.ok(g.ok)} updated ${cli.style.accent(opset.target.path)} · ${opset.summary.preserved} preserved, ${opset.summary.updated} updated, ${opset.summary.moved} moved, ${opset.summary.created} created, ${opset.summary.removed} removed`));
95
- for (const id of result.results.flatMap((r) => r.ids))
97
+ // Every id the opset touched, once each: a block appears in several ops'
98
+ // results (an update then a retile of the same block), the confirmation
99
+ // lists it a single time.
100
+ for (const id of new Set(result.results.flatMap((r) => r.ids)))
96
101
  cli.io.out(id);
97
102
  return EXIT_OK;
98
103
  }
@@ -68,6 +68,10 @@ export interface CliOptions {
68
68
  export declare function progName(argv1: string | undefined): string;
69
69
  export declare function makeCli(flags: GlobalFlags, io: IO, opts?: CliOptions): Cli;
70
70
  export declare const NO_WORKSPACE_OK: Set<string>;
71
+ export declare const HELP_WORD_OK: Set<string>;
72
+ export declare const BARE_IS_HELP: Set<string>;
73
+ /** Does this invocation ask for a command's help card? (the flag, the word, or a bare card-only command) */
74
+ export declare function asksForHelp(flags: GlobalFlags, name: string, args: string[]): boolean;
71
75
  export declare const SKIP_FRESHNESS: Set<string>;
72
76
  export declare const REMOTE_OK: Set<string>;
73
77
  export { CliUsageError };
@@ -57,6 +57,20 @@ function resolveDir(dir) {
57
57
  }
58
58
  // Which commands may run without a workspace (11 §2.1).
59
59
  export const NO_WORKSPACE_OK = new Set(["init", "help", "version"]);
60
+ // Commands whose first argument may be the word `help` (their card), and those
61
+ // whose card is also their bare invocation (spec/cli §5): help is documentation,
62
+ // not work, so these route to the card before any workspace is opened or swept —
63
+ // exactly like the `--help` flag.
64
+ export const HELP_WORD_OK = new Set(["config", "embed", "node", "source"]);
65
+ export const BARE_IS_HELP = new Set(["node", "source"]);
66
+ /** Does this invocation ask for a command's help card? (the flag, the word, or a bare card-only command) */
67
+ export function asksForHelp(flags, name, args) {
68
+ if (flags.help)
69
+ return true;
70
+ if (HELP_WORD_OK.has(name) && args[0] === "help")
71
+ return true;
72
+ return BARE_IS_HELP.has(name) && args.length === 0;
73
+ }
60
74
  // Commands that manage sync themselves — skip the freshness sweep (11 §3.3).
61
75
  // `shell` is exempt because each line it runs sweeps on its own. `source`
62
76
  // creates/binds sources and runs its own initial sync, so it skips the pre-sweep.
@@ -1,8 +1,11 @@
1
1
  import { type Cli, type GlobalFlags } from "./context.js";
2
+ import { CliUsageError } from "./output.js";
2
3
  export interface Parsed {
3
4
  flags: GlobalFlags;
4
5
  command: string | null;
5
6
  rest: string[];
7
+ /** the first malformed global flag, when any (exit 2) */
8
+ error?: CliUsageError;
6
9
  }
7
10
  export declare function parseGlobals(argv: string[]): Parsed;
8
11
  export declare function runCommand(cli: Cli, command: string, rest: string[]): Promise<number>;
@@ -1,6 +1,6 @@
1
1
  import { freshnessSweep, watchLeaseLive } from "@omgbase/core";
2
- import { NO_WORKSPACE_OK, SKIP_FRESHNESS, REMOTE_OK } from "./context.js";
3
- import { CliUsageError, EngineErrorLike, renderError } from "./output.js";
2
+ import { NO_WORKSPACE_OK, SKIP_FRESHNESS, REMOTE_OK, asksForHelp } from "./context.js";
3
+ import { CliUsageError, EngineErrorLike, renderError, usageFromParseArgs } from "./output.js";
4
4
  import { resolveCommand, unknownCommandError } from "./commands.js";
5
5
  export function parseGlobals(argv) {
6
6
  const flags = {
@@ -14,6 +14,12 @@ export function parseGlobals(argv) {
14
14
  let command = null;
15
15
  const rest = [];
16
16
  let passthrough = false;
17
+ let error;
18
+ // Record the first malformed flag and keep parsing (a later `--json` still
19
+ // selects the mode the error is rendered in).
20
+ const fail = (message) => {
21
+ error ??= new CliUsageError(message);
22
+ };
17
23
  for (let i = 0; i < argv.length; i++) {
18
24
  const a = argv[i];
19
25
  if (passthrough) {
@@ -31,30 +37,35 @@ export function parseGlobals(argv) {
31
37
  case "--directory": {
32
38
  const dir = argv[++i];
33
39
  if (dir === undefined)
34
- throw new CliUsageError(`${a} requires a directory`);
35
- flags.directory = dir;
40
+ fail(`${a} requires a directory`);
41
+ else
42
+ flags.directory = dir;
36
43
  continue;
37
44
  }
38
45
  case "--repo": {
39
46
  const slug = argv[++i];
40
47
  if (slug === undefined)
41
- throw new CliUsageError("--repo requires a slug");
42
- flags.repo = slug;
48
+ fail("--repo requires a slug");
49
+ else
50
+ flags.repo = slug;
43
51
  continue;
44
52
  }
45
53
  case "--server": {
54
+ // An empty value is a mistake, not "local": the flag was given.
46
55
  const s = argv[++i];
47
- if (s === undefined)
48
- throw new CliUsageError("--server requires a command or url");
49
- flags.server = s;
56
+ if (s === undefined || s === "")
57
+ fail("--server requires a command or url");
58
+ else
59
+ flags.server = s;
50
60
  continue;
51
61
  }
52
62
  case "-H":
53
63
  case "--header": {
54
64
  const h = argv[++i];
55
65
  if (h === undefined)
56
- throw new CliUsageError(`${a} requires a "Name: value" header`);
57
- (flags.headers ??= []).push(h);
66
+ fail(`${a} requires a "Name: value" header`);
67
+ else
68
+ (flags.headers ??= []).push(h);
58
69
  continue;
59
70
  }
60
71
  case "--json":
@@ -90,12 +101,14 @@ export function parseGlobals(argv) {
90
101
  }
91
102
  // Once the command is known, anything unrecognized is its residual;
92
103
  // a stray flag before any command is a usage error.
93
- if (!command)
94
- throw new CliUsageError(`unknown flag ${a}`);
104
+ if (!command) {
105
+ fail(`unknown flag ${a}`);
106
+ continue;
107
+ }
95
108
  rest.push(a);
96
109
  }
97
110
  }
98
- return { flags, command, rest };
111
+ return { flags, command, rest, ...(error ? { error } : {}) };
99
112
  }
100
113
  // Per-command execution shared by the one-shot entry (main.ts) and the
101
114
  // interactive shell (cmd/shell.ts). Given a resolved Cli, a command name, and
@@ -110,18 +123,20 @@ export async function runCommand(cli, command, rest) {
110
123
  // `--help` after a command → route to help for that command. Help is
111
124
  // documentation, not work: it must never need a workspace, so it also skips
112
125
  // the freshness sweep below (which would otherwise fail with repo_not_found
113
- // from any directory without a `.omgbase/`).
126
+ // from any directory without a `.omgbase/`). The help *words* (`config help`,
127
+ // a bare `node`, …) are the same request spelled differently (spec/cli §5).
114
128
  const args = cli.flags.help ? ["--help", ...rest] : rest;
129
+ const help = asksForHelp(cli.flags, resolved.name, rest);
115
130
  // Global `--server` (remote/MCP mode) is only implemented by some commands so
116
131
  // far (REMOTE_OK); reject it elsewhere rather than silently running locally.
117
- if (cli.flags.server !== undefined && !cli.flags.help && !REMOTE_OK.has(resolved.name)) {
132
+ if (cli.flags.server !== undefined && !help && !REMOTE_OK.has(resolved.name)) {
118
133
  return renderError(new CliUsageError(`--server is not supported for '${resolved.name}' — it needs local ref resolution or a working tree; run it against a local workspace`), cli.io, cli.style, cli.flags.mode !== "human");
119
134
  }
120
135
  try {
121
136
  // Freshness sweep (11 §3.3): current-by-default, unless --stale, a live
122
137
  // watcher holds the lease, remote (`--server`) mode (no local tree to sweep),
123
138
  // or the command manages sync itself.
124
- if (!cli.flags.help && !cli.flags.stale && cli.flags.server === undefined && !SKIP_FRESHNESS.has(resolved.name) && !NO_WORKSPACE_OK.has(resolved.name)) {
139
+ if (!help && !cli.flags.stale && cli.flags.server === undefined && !SKIP_FRESHNESS.has(resolved.name) && !NO_WORKSPACE_OK.has(resolved.name)) {
125
140
  const ws = cli.workspace();
126
141
  if (!watchLeaseLive(ws.omgbaseDir)) {
127
142
  // Best-effort: a workspace with no attached repo (or an unresolvable
@@ -142,7 +157,9 @@ export async function runCommand(cli, command, rest) {
142
157
  return await resolved.run(cli, args);
143
158
  }
144
159
  catch (err) {
145
- return renderError(err, cli.io, cli.style, cli.flags.mode !== "human");
160
+ // A command's own `parseArgs` rejecting an option is a usage error (exit 2),
161
+ // rendered here once for every verb rather than in each parser.
162
+ return renderError(usageFromParseArgs(err, cli.prog, resolved.name), cli.io, cli.style, cli.flags.mode !== "human");
146
163
  }
147
164
  }
148
165
  //# sourceMappingURL=dispatch.js.map
@@ -1,3 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { type IO } from "./render.js";
3
+ /**
4
+ * `--version` prints THIS package's version (spec/cli §2.5), read from the
5
+ * nearest `package.json` named `omgbase` above the running module (the source
6
+ * tree and the built `dist/src/` sit at different depths). The engine's own
7
+ * `VERSION` is the fallback only if the package file cannot be found.
8
+ */
9
+ export declare function cliVersion(): string;
3
10
  export declare function run(argv: string[], io?: IO, prog?: string): Promise<number>;
package/dist/src/main.js CHANGED
@@ -1,30 +1,52 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { VERSION } from "@omgbase/core";
3
6
  import { processIO } from "./render.js";
4
7
  import { makeCli, progName } from "./context.js";
5
- import { renderError, EXIT_OK, EXIT_USAGE } from "./output.js";
8
+ import { renderError, EXIT_OK } from "./output.js";
6
9
  import { resolveCommand, unknownCommandError } from "./commands.js";
7
10
  import { runCommand, parseGlobals } from "./dispatch.js";
8
11
  import { closeRemote } from "./cmd/_remote.js";
12
+ import { installSpecSeams } from "./seams.js";
9
13
  // Hand-rolled router (11 §8: no commander). Splits global flags from the
10
14
  // command + its residual argv (parseGlobals, in dispatch.ts), resolves the
11
15
  // command (with aliases), then delegates to runCommand for the freshness sweep +
12
16
  // dispatch + error mapping — the same path the shell reuses per line.
13
- export async function run(argv, io = processIO, prog = "omg") {
14
- let parsed;
15
- try {
16
- parsed = parseGlobals(argv);
17
- }
18
- catch (err) {
19
- // No Cli/style yet — render minimally.
20
- io.err(String(err.message ?? err));
21
- return EXIT_USAGE;
17
+ /**
18
+ * `--version` prints THIS package's version (spec/cli §2.5), read from the
19
+ * nearest `package.json` named `omgbase` above the running module (the source
20
+ * tree and the built `dist/src/` sit at different depths). The engine's own
21
+ * `VERSION` is the fallback only if the package file cannot be found.
22
+ */
23
+ export function cliVersion() {
24
+ let dir = dirname(fileURLToPath(import.meta.url));
25
+ for (;;) {
26
+ const candidate = join(dir, "package.json");
27
+ if (existsSync(candidate)) {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
30
+ if (pkg.name === "omgbase" && typeof pkg.version === "string")
31
+ return pkg.version;
32
+ }
33
+ catch {
34
+ /* keep walking */
35
+ }
36
+ }
37
+ const parent = dirname(dir);
38
+ if (parent === dir)
39
+ return VERSION;
40
+ dir = parent;
22
41
  }
42
+ }
43
+ export async function run(argv, io = processIO, prog = "omg") {
44
+ const parsed = parseGlobals(argv);
23
45
  const { flags } = parsed;
24
46
  let command = parsed.command;
25
47
  // --version / --help without a command.
26
- if (flags.version && !command) {
27
- io.out(VERSION);
48
+ if (flags.version && !command && !parsed.error) {
49
+ io.out(cliVersion());
28
50
  return EXIT_OK;
29
51
  }
30
52
  if (flags.help && !command)
@@ -32,6 +54,18 @@ export async function run(argv, io = processIO, prog = "omg") {
32
54
  if (!command)
33
55
  command = "help";
34
56
  const cli = makeCli(flags, io, { prog });
57
+ // A malformed global flag is a usage error like any other (spec/cli §2.2):
58
+ // rendered with the `usage:` prefix, in the mode the argv selected.
59
+ if (parsed.error)
60
+ return renderError(parsed.error, io, cli.style, flags.mode !== "human");
61
+ // The conformance seams (spec/cli §2.6) apply to every verb: install them
62
+ // before any command can open the workspace, mint an id or read the clock.
63
+ try {
64
+ installSpecSeams();
65
+ }
66
+ catch (err) {
67
+ return renderError(err, io, cli.style, flags.mode !== "human");
68
+ }
35
69
  if (!resolveCommand(command)) {
36
70
  return renderError(unknownCommandError(prog, command), io, cli.style, flags.mode !== "human");
37
71
  }