@mnemahq/cli 0.3.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/login.mjs CHANGED
@@ -13,7 +13,10 @@
13
13
  * then told the login "timed out". They didn't time out. They said no.
14
14
  */
15
15
 
16
- import { c, DEFAULT_ORIGIN } from './util.mjs';
16
+ import { c, mark, DEFAULT_ORIGIN } from './util.mjs';
17
+ import { spinner } from './render/wait.mjs';
18
+ import { heading, entries } from './render/layout.mjs';
19
+ import { openInBrowser } from './browser.mjs';
17
20
  import {
18
21
  backendName, clearState, deleteSecret, getSecret, readState, setSecret, writeState, NoKeychainError,
19
22
  } from './keychain.mjs';
@@ -61,52 +64,81 @@ export async function cmdLogin(flags = {}) {
61
64
  expires_in, interval,
62
65
  } = start.json;
63
66
 
67
+ // ⭐ ACTUALLY OPEN IT. `mnema --help` has promised "opens a browser" since 0.1.0
68
+ // and the CLI has never once done so — it printed the URL and waited. Opening is
69
+ // attempted BEFORE printing, so the "we opened it" line is only claimed when it
70
+ // is true; the URL is printed either way, because a headless box, an SSH session
71
+ // and a locked-down desktop all legitimately have nothing to open with.
72
+ const opened = await openInBrowser(verification_uri_complete || verification_uri, origin);
73
+
64
74
  console.log('');
65
- console.log(` Open ${c.cyan(verification_uri)}`);
66
- console.log(` Code ${c.bold(user_code)}`);
67
- console.log('');
68
- console.log(c.dim(` Or go straight there: ${verification_uri_complete}`));
69
- console.log(c.dim(` The code expires in ${Math.round((expires_in ?? 600) / 60)} minutes.`));
75
+ heading('Sign in');
76
+ // ⚠️ MEASURED, NOT HAND-PADDED. Writing `'Open '` with a trailing space to line
77
+ // up under `Code` is the same hand-counted gutter t-630 removed from status,
78
+ // doctor and pull — and it silently breaks the moment either word changes.
79
+ entries([
80
+ { label: 'code', value: user_code, style: c.bold },
81
+ { label: opened ? 'opened' : 'open', value: verification_uri, style: c.cyan },
82
+ ...(opened ? [] : [{ label: '', value: verification_uri_complete, dim: true }]),
83
+ { label: 'expires', value: `in ${Math.round((expires_in ?? 600) / 60)} minutes`, dim: true },
84
+ ]);
70
85
  console.log('');
71
- process.stdout.write(c.dim(' Waiting for approval…'));
72
86
 
73
87
  const deadline = Date.now() + (expires_in ?? 600) * 1000;
74
88
  let waitMs = (interval ?? 5) * 1000;
89
+ // ⚠️ ONE LINE THAT REDRAWS ITSELF, not a dot per poll. The old version wrote '.'
90
+ // every interval — up to 120 of them — which wraps and scrolls the user_code off
91
+ // the top of the screen. The code is the one thing they have to read.
92
+ const spin = spinner('Waiting for approval in your browser…');
75
93
 
76
- for (;;) {
77
- if (Date.now() > deadline) {
78
- console.log('');
79
- throw new Error('The code expired before it was approved. Run `mnema login` again.');
80
- }
81
- await sleep(waitMs);
82
-
83
- const poll = await post(origin, '/oauth/token', {
84
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
85
- device_code,
86
- client_id: CLIENT_ID,
87
- });
88
-
89
- if (poll.status === 200 && poll.json?.access_token) {
90
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
91
- return finish(origin, poll.json, store);
92
- }
93
-
94
- const err = poll.json?.error;
95
-
96
- if (err === 'authorization_pending') { process.stdout.write(c.dim('.')); continue; }
97
-
98
- if (err === 'slow_down') {
99
- // The server raised the interval and told us the new one. Honour it —
100
- // ignoring slow_down is how a CLI gets a client id rate-limited.
101
- waitMs = ((poll.json.interval ?? (waitMs / 1000) + 5)) * 1000;
102
- continue;
94
+ try {
95
+ for (;;) {
96
+ if (Date.now() > deadline) {
97
+ spin.stop();
98
+ throw new Error('The code expired before it was approved. Run `mnema login` again.');
99
+ }
100
+
101
+ const poll = await post(origin, '/oauth/token', {
102
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
103
+ device_code,
104
+ client_id: CLIENT_ID,
105
+ });
106
+
107
+ if (poll.status === 200 && poll.json?.access_token) {
108
+ spin.stop();
109
+ return finish(origin, poll.json, store);
110
+ }
111
+
112
+ const err = poll.json?.error;
113
+
114
+ if (err === 'authorization_pending') {
115
+ // ⚠️ SLEEP AFTER THE CHECK, not before it. The old loop slept first, so
116
+ // someone who approved instantly still sat through a full interval
117
+ // watching nothing happen.
118
+ await sleep(waitMs);
119
+ continue;
120
+ }
121
+
122
+ if (err === 'slow_down') {
123
+ // The server raised the interval and told us the new one. Honour it —
124
+ // ignoring slow_down is how a CLI gets a client id rate-limited — but SAY
125
+ // so, because obeying it silently just makes the wait mysteriously longer.
126
+ waitMs = ((poll.json.interval ?? (waitMs / 1000) + 5)) * 1000;
127
+ spin.update(`Waiting for approval — the server asked us to slow to ${Math.round(waitMs / 1000)}s…`);
128
+ await sleep(waitMs);
129
+ continue;
130
+ }
131
+
132
+ // ⭐ Everything below is terminal, and each says what actually happened.
133
+ spin.stop();
134
+ if (err === 'access_denied') throw new Error('The login was denied in the browser.');
135
+ if (err === 'expired_token') throw new Error('The code expired before it was approved. Run `mnema login` again.');
136
+ throw new Error(`Login failed: ${poll.json?.error_description || err || `HTTP ${poll.status}`}`);
103
137
  }
104
-
105
- // Everything below is terminal, and each says what actually happened.
106
- console.log('');
107
- if (err === 'access_denied') throw new Error('The login was denied in the browser.');
108
- if (err === 'expired_token') throw new Error('The code expired before it was approved. Run `mnema login` again.');
109
- throw new Error(`Login failed: ${poll.json?.error_description || err || `HTTP ${poll.status}`}`);
138
+ } finally {
139
+ // Belt and braces: a throw from post() must not leave a timer running and the
140
+ // user's cursor parked mid-line.
141
+ spin.stop();
110
142
  }
111
143
  }
112
144
 
@@ -134,11 +166,15 @@ async function finish(origin, tokens, store) {
134
166
  logged_in_at: new Date().toISOString(),
135
167
  });
136
168
 
137
- console.log(c.green(' Logged in.'));
138
- if (who?.email) console.log(` Account : ${who.email}`);
139
- if (who?.workspace_id) console.log(` Workspace : ${who.workspace_id}`);
140
- if (who?.plan) console.log(` Plan : ${who.plan}`);
141
- console.log(` Tokens : ${c.dim(store)}`);
169
+ // Hand-counted gutters ('Account :' is 10, 'Workspace :' is 10, 'Plan :'
170
+ // is 11) were the same defect t-630 removed everywhere else. One table.
171
+ heading('Signed in');
172
+ entries([
173
+ ...(who?.email ? [{ label: 'account', value: who.email }] : []),
174
+ ...(who?.workspace_id ? [{ label: 'workspace', value: who.workspace_id }] : []),
175
+ ...(who?.plan ? [{ label: 'plan', value: who.plan }] : []),
176
+ { label: 'tokens', value: store, dim: true },
177
+ ]);
142
178
  console.log('');
143
179
  return 0;
144
180
  }
@@ -185,6 +221,6 @@ export async function cmdLogout() {
185
221
  deleteSecret(`${ACCOUNT}:access`);
186
222
  deleteSecret(`${ACCOUNT}:refresh`);
187
223
  clearState();
188
- console.log(c.green('Logged out.') + c.dim(' Tokens removed from ' + backendName() + '.'));
224
+ console.log(`${mark.ok()} ${c.green('Logged out.')}${c.dim(` Tokens removed from ${backendName()}.`)}`);
189
225
  return 0;
190
226
  }
package/src/paging.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stop reading when the caller has enough (t-630).
3
+ *
4
+ * ⭐ `--limit` DID NOT LIMIT ANYTHING. Every read command called `.all()`, and
5
+ * `paginate()` follows `next_cursor` to exhaustion — `limit` is only the page-SIZE
6
+ * query parameter. So a smaller `--limit` made the CLI do strictly more work:
7
+ *
8
+ * mnema docs --limit 5
9
+ * /api/public/v1/docs?limit=5
10
+ * /api/public/v1/docs?limit=5&cursor=59e921a0-…
11
+ * /api/public/v1/docs?limit=5&cursor=92360dfa-…
12
+ * …and on, and on
13
+ *
14
+ * Measured against prod: it blew past a 10-request cap and was still walking the
15
+ * cursor chain. `mnema docs --limit 5` never returned — it was the command that
16
+ * hung this branch's own baseline capture — and had it finished it would have
17
+ * printed EVERY document, because the header counts the rows it collected.
18
+ *
19
+ * ⚠️ SO THE PRINTED COUNT CHANGES with this fix, and that is the point: it used to
20
+ * be "everything you have", now it is "what you asked for".
21
+ *
22
+ * `Paginated<T>` is an AsyncIterable, so stopping is simply a `break` — the
23
+ * generator is closed and the next page is never requested.
24
+ */
25
+
26
+ /**
27
+ * Read at most `limit` items.
28
+ *
29
+ * Returns `more` so callers can say that the list was cut. A row count that
30
+ * silently means "there might be others" is the same lie `ask` and `graph` used to
31
+ * tell with their headers, and it is worth exactly as little.
32
+ */
33
+ export async function take(paginated, limit) {
34
+ // ⚠️ `>= 0`, NOT `> 0`. With `> 0` a limit of ZERO fell through to Infinity and
35
+ // fetched the entire workspace — the exact bug this module exists to fix, hiding
36
+ // inside the fix, and it made the `max === 0` guard below unreachable. An
37
+ // explicit 0 means zero rows; only undefined, NaN or a negative is "unbounded".
38
+ const max = Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
39
+ const rows = [];
40
+ if (max === 0) return { rows, more: false };
41
+
42
+ for await (const item of paginated) {
43
+ rows.push(item);
44
+ // Read ONE past the limit rather than stopping exactly on it: that extra item
45
+ // is the only way to distinguish "exactly this many exist" from "there are
46
+ // more", and getting it wrong means the footer either lies or never appears.
47
+ if (rows.length > max) return { rows: rows.slice(0, max), more: true };
48
+ }
49
+ return { rows, more: false };
50
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The read commands — tasks, docs, projects, ask, graph, briefing (t-624).
2
+ * The read commands — tasks, docs, projects, ask, graph, briefing (t-624, t-630).
3
3
  *
4
4
  * ⭐ EVERY ONE OF THESE IS A WRAPPER, NOT A CLIENT. They exist because t-623 put
5
5
  * the CLI on @mnemahq/sdk; each is four lines of SDK call plus formatting, and
@@ -16,12 +16,19 @@
16
16
  * news. On a core build six of seven finding families cannot produce
17
17
  * anything at all, and `coverage.notice` is the only thing that says so.
18
18
  *
19
+ * t-630 moved every heading, row and empty state onto render/layout.mjs so the
20
+ * grammar is one module's job rather than eleven call sites', and put `--limit`
21
+ * through take() so it stops the pagination instead of decorating it.
22
+ *
19
23
  * --json everywhere: a CLI you cannot pipe is half a CLI, and the SDK already
20
24
  * returns the shape worth emitting, so it costs one line each.
21
25
  */
22
26
 
23
27
  import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
24
- import { c } from './util.mjs';
28
+ import { take } from './paging.mjs';
29
+ import { c, truncate, link, mark } from './util.mjs';
30
+ import { heading, empty, rows, more, copyable } from './render/layout.mjs';
31
+ import { pick } from './render/pick.mjs';
25
32
 
26
33
  /** Machine output is the whole object; humans get the formatted view. */
27
34
  function emit(flags, value, render) {
@@ -35,25 +42,34 @@ async function guard(workspaceId) {
35
42
  process.exit(1);
36
43
  }
37
44
 
38
- const trunc = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : (s ?? ''));
45
+ const limitOf = (flags, fallback) => Number(flags.limit) || fallback;
46
+
47
+ /** The short id a human actually reads back to you. */
48
+ const shortId = (row) => row.publicId || String(row.id).slice(0, 8);
39
49
 
40
50
  // ── tasks ─────────────────────────────────────────────────────────────────────
41
51
  export async function cmdTasks(flags, ctx) {
42
52
  await guard(ctx.workspaceId);
43
- const limit = Number(flags.limit) || 20;
53
+ const limit = limitOf(flags, 20);
44
54
  try {
45
- const rows = await call(ctx, (m) => m.tasks.list({
55
+ const page = await call(ctx, (m) => take(m.tasks.list({
46
56
  limit,
47
57
  ...(flags.status ? { status: flags.status } : {}),
48
58
  ...(flags.project ? { project: flags.project } : {}),
49
- }).all());
50
- emit(flags, rows, () => {
51
- if (!rows.length) return console.log(c.dim('No tasks match.'));
52
- console.log(c.bold(`${rows.length} task(s)`));
53
- for (const t of rows) {
54
- const id = (t.publicId || t.id.slice(0, 8)).padEnd(8);
55
- console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
59
+ }), limit));
60
+ emit(flags, page.rows, () => {
61
+ heading('Tasks', page.rows.length);
62
+ if (!page.rows.length) {
63
+ return empty(
64
+ flags.status || flags.project ? 'No tasks match those filters.' : 'No tasks yet.',
65
+ flags.status || flags.project ? 'Drop the filters to see the whole board.' : undefined,
66
+ );
56
67
  }
68
+ rows(page.rows.map((t) => ({
69
+ cells: [shortId(t), String(t.status), String(t.priority), t.title ?? ''],
70
+ style: [c.cyan, undefined, c.dim, undefined],
71
+ })));
72
+ more(page.more, `mnema tasks --limit ${limit * 2}`);
57
73
  });
58
74
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
59
75
  }
@@ -63,14 +79,26 @@ export async function cmdNext(flags, ctx) {
63
79
  try {
64
80
  const t = await call(ctx, (m) => m.tasks.next());
65
81
  emit(flags, t, () => {
66
- if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
67
- console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
68
- if (t.description) console.log(c.dim(trunc(t.description.replace(/\s+/g, ' '), 300)));
69
- // The branch name is the load-bearing part of this project's dev loop, so
70
- // the CLI hands it over ready to paste rather than making it be derived.
82
+ heading('Next');
83
+ if (!t) return empty('Nothing queued — the board is clear.');
84
+ rows([{ cells: [shortId(t), t.title ?? ''], style: [c.cyan, undefined] }]);
85
+ if (t.description) {
86
+ console.log(c.dim(` ${truncate(t.description.replace(/\s+/g, ' '), 300)}`));
87
+ }
88
+ // A real URL, so make it clickable where the terminal supports it. link()
89
+ // degrades to "label (url)" when colour is off, rather than emitting an OSC 8
90
+ // sequence into someone's pipe.
91
+ if (t.githubPrUrl) console.log(` ${c.dim('pr:')} ${link(t.githubPrUrl, t.githubPrUrl)}`);
92
+ // ⚠️ THE BRANCH NAME IS THE LOAD-BEARING PART OF THIS PROJECT'S DEV LOOP —
93
+ // it is the only signal that needs no cooperation from anything else, and
94
+ // from it the hook links the session, the PR links the task and the board
95
+ // moves on its own. So it is printed PLAIN: no colour, no padding, no
96
+ // truncation, because a padded string carries invisible trailing whitespace
97
+ // into the clipboard and an escape sequence carries worse.
71
98
  if (t.publicId) {
72
- const slug = t.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
73
- console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
99
+ const slug = String(t.title ?? '').toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
101
+ copyable('branch:', `git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
74
102
  }
75
103
  });
76
104
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'next' }); }
@@ -79,30 +107,59 @@ export async function cmdNext(flags, ctx) {
79
107
  // ── docs ──────────────────────────────────────────────────────────────────────
80
108
  export async function cmdDocs(flags, ctx) {
81
109
  await guard(ctx.workspaceId);
82
- const limit = Number(flags.limit) || 20;
110
+ const limit = limitOf(flags, 20);
83
111
  try {
84
- const rows = await call(ctx, (m) => m.docs.list({ limit }).all());
85
- emit(flags, rows, () => {
86
- if (!rows.length) return console.log(c.dim('No docs.'));
87
- console.log(c.bold(`${rows.length} doc(s)`));
88
- for (const d of rows) {
89
- console.log(` ${c.dim(d.id.slice(0, 8))} ${trunc(d.title, 60).padEnd(60)} ${c.dim(String(d.updatedAt).slice(0, 10))}`);
90
- }
112
+ const page = await call(ctx, (m) => take(m.docs.list({ limit }), limit));
113
+ emit(flags, page.rows, () => {
114
+ heading('Documents', page.rows.length);
115
+ if (!page.rows.length) return empty('No documents yet.', 'Docs you write in the app show up here.');
116
+ rows(page.rows.map((d) => ({
117
+ cells: [String(d.id).slice(0, 8), String(d.updatedAt).slice(0, 10), d.title ?? ''],
118
+ style: [c.dim, c.dim, undefined],
119
+ })));
120
+ more(page.more, `mnema docs --limit ${limit * 2}`);
91
121
  });
92
122
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'docs' }); }
93
123
  }
94
124
 
95
125
  export async function cmdDoc(flags, ctx, rest) {
96
126
  await guard(ctx.workspaceId);
97
- const id = rest[0];
98
- if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
127
+ let id = rest[0];
128
+ // OFFER, DO NOT DEMAND. With no id this printed a usage line, so the real
129
+ // workflow was: run `mnema docs`, read a truncated title, select eight hex
130
+ // characters with the mouse, paste them back. The list command already knows
131
+ // every id; asking the user to ferry one across is work the tool can do.
132
+ //
133
+ // ⚠️ Only when a human is actually there. pick() returns null on a non-TTY (and
134
+ // --json means a script is reading), so the usage error remains the answer for
135
+ // every non-interactive caller — no hanging on a stdin that will never speak.
136
+ // ⚠️ CHECK THE TERMINAL BEFORE THE NETWORK. Testing interactivity only inside
137
+ // pick() meant a piped `mnema doc` fetched fifty documents and then threw them
138
+ // away — a round trip, and a rate-limit slot, spent on a picker nobody can see.
139
+ const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
140
+ if (!id && !flags.json && interactive) {
141
+ const page = await call(ctx, (m) => take(m.docs.list({ limit: 50 }), 50)).catch(() => null);
142
+ if (page?.rows.length) {
143
+ const chosen = await pick(
144
+ page.rows.map((d) => ({ label: `${d.title ?? '(untitled)'} ${String(d.updatedAt).slice(0, 10)}` })),
145
+ { title: 'Which document?' },
146
+ );
147
+ if (chosen === null) return;
148
+ id = page.rows[chosen].id;
149
+ }
150
+ }
151
+ if (!id) {
152
+ console.error(c.red('Usage: mnema doc <id>'));
153
+ console.error(c.dim(' Run it without an id in a terminal to pick from a list.'));
154
+ process.exit(1);
155
+ }
99
156
  try {
100
157
  const d = await call(ctx, (m) => m.docs.get(id));
101
- // Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
102
- // the heading is not silently baked into the file.
158
+ // ⚠️ MARKDOWN TO STDOUT, EVERYTHING ELSE TO STDERR, so `mnema doc <id> > f.md`
159
+ // gets the document alone and the heading is not silently baked into the file.
103
160
  emit(flags, d, () => {
104
- console.error(c.bold(d.title));
105
- console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
161
+ console.error(c.bold(d.title ?? '(untitled)'));
162
+ console.error(c.dim(`${d.path ?? ''} · updated ${String(d.updatedAt).slice(0, 10)}`));
106
163
  if (d.markdown) console.log(d.markdown);
107
164
  });
108
165
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
@@ -111,12 +168,17 @@ export async function cmdDoc(flags, ctx, rest) {
111
168
  // ── projects ──────────────────────────────────────────────────────────────────
112
169
  export async function cmdProjects(flags, ctx) {
113
170
  await guard(ctx.workspaceId);
171
+ const limit = limitOf(flags, 50);
114
172
  try {
115
- const rows = await call(ctx, (m) => m.projects.list().all());
116
- emit(flags, rows, () => {
117
- if (!rows.length) return console.log(c.dim('No projects.'));
118
- console.log(c.bold(`${rows.length} project(s)`));
119
- for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
173
+ const page = await call(ctx, (m) => take(m.projects.list(), limit));
174
+ emit(flags, page.rows, () => {
175
+ heading('Projects', page.rows.length);
176
+ if (!page.rows.length) return empty('No projects yet.');
177
+ rows(page.rows.map((p) => ({
178
+ cells: [String(p.id).slice(0, 8), p.name ?? ''],
179
+ style: [c.dim, undefined],
180
+ })));
181
+ more(page.more, `mnema projects --limit ${limit * 2}`);
120
182
  });
121
183
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'projects' }); }
122
184
  }
@@ -138,8 +200,12 @@ export async function cmdAsk(flags, ctx, rest) {
138
200
  const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
139
201
  console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
140
202
  if (a.sources?.length) {
141
- console.log(c.dim(`\nsources (${a.sources.length}):`));
142
- for (const s of a.sources.slice(0, 5)) console.log(c.dim(` ${trunc(s.title, 70)}`));
203
+ const SHOWN = 5;
204
+ const shown = a.sources.slice(0, SHOWN);
205
+ console.log('');
206
+ heading('Sources', a.sources.length);
207
+ rows(shown.map((s) => ({ cells: [truncate(s.title ?? '', 70)], style: [c.dim] })));
208
+ more(a.sources.length - shown.length, 'mnema ask --json "…"');
143
209
  }
144
210
  });
145
211
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
@@ -153,18 +219,27 @@ export async function cmdGraph(flags, ctx, rest) {
153
219
  const g = await call(ctx, (m) => m.graph.traverse(from, to));
154
220
  emit(flags, g, () => {
155
221
  if (to) {
156
- // connected:false is an ANSWER, not a failure — say so plainly rather
222
+ // ⚠️ connected:false IS AN ANSWER, not a failure — say so plainly rather
157
223
  // than printing an empty list and letting it read as an error.
158
- if (!g.connected) return console.log(c.yellow(`No route between "${from}" and "${to}".`));
159
- console.log(c.bold(`${g.hopCount} hop(s) from "${from}" to "${to}"`));
160
- for (const id of g.path) {
161
- const n = g.nodes.find((x) => x.id === id);
162
- console.log(` ${n ? trunc(n.label, 60) : id}${n?.type ? c.dim(` [${n.type}]`) : ''}`);
224
+ if (!g.connected) {
225
+ heading('Path');
226
+ return empty(`No route between "${from}" and "${to}".`, 'They are in the graph but nothing links them.');
163
227
  }
228
+ heading('Path', `${g.hopCount} hop${g.hopCount === 1 ? '' : 's'}`);
229
+ rows(g.path.map((id) => {
230
+ const n = g.nodes.find((x) => x.id === id);
231
+ return { cells: [n?.type ?? '', n ? truncate(n.label, 60) : id], style: [c.dim, undefined] };
232
+ }));
164
233
  return;
165
234
  }
166
- console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
167
- for (const n of g.nodes.slice(0, 25)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
235
+ const CAP = 25;
236
+ heading(`Around "${from}"`, `${g.nodes.length} node${g.nodes.length === 1 ? '' : 's'} · ${g.edges.length} edge${g.edges.length === 1 ? '' : 's'}`);
237
+ if (!g.nodes.length) return empty(`Nothing is connected to "${from}" yet.`);
238
+ rows(g.nodes.slice(0, CAP).map((n) => ({
239
+ cells: [n.type ?? '', truncate(n.label, 60)],
240
+ style: [c.dim, undefined],
241
+ })));
242
+ more(g.nodes.length - CAP, `mnema graph --json "${from}"`);
168
243
  });
169
244
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
170
245
  }
@@ -176,21 +251,26 @@ export async function cmdBriefing(flags, ctx) {
176
251
  const b = await call(ctx, (m) => m.findings.briefing());
177
252
  emit(flags, b, () => {
178
253
  const p = b.pulse;
179
- console.log(c.bold('Pulse'));
180
- console.log(` ${p.activeProjects} active project(s) · ${p.problemsInActiveProjects} problem(s) · $${p.cost7dUsd} last 7d`);
254
+ heading('Pulse');
255
+ rows([
256
+ { cells: ['projects', String(p.activeProjects)], style: [c.dim, undefined] },
257
+ { cells: ['problems', String(p.problemsInActiveProjects)], style: [c.dim, p.problemsInActiveProjects > 0 ? c.yellow : undefined] },
258
+ { cells: ['spend 7d', `$${p.cost7dUsd}`], style: [c.dim, undefined] },
259
+ ]);
181
260
 
182
261
  // ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
183
262
  // causes and only one is good news; printing the findings first invites the
184
263
  // reader to conclude "all clear" before reaching the reason it is short.
185
- if (b.neverComputed) console.log(c.yellow('\n The findings engine has never run for this workspace.'));
186
- if (b.coverage?.degraded) console.log(c.yellow(`\n ${b.coverage.notice}`));
264
+ if (b.neverComputed) console.log(c.yellow(`\n ${mark.warn()} The findings engine has never run for this workspace.`));
265
+ if (b.coverage?.degraded) console.log(c.yellow(`\n ${mark.warn()} ${b.coverage.notice}`));
187
266
 
188
- console.log(c.bold(`\nFindings (${b.findings.length})`));
189
- if (!b.findings.length) console.log(c.dim(' nothing surfaced'));
190
- for (const f of b.findings) {
191
- const count = f.grouped ? c.dim(` ×${f.count}`) : '';
192
- console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
193
- }
267
+ console.log('');
268
+ heading('Findings', b.findings.length);
269
+ if (!b.findings.length) return empty('Nothing surfaced.');
270
+ rows(b.findings.map((f) => ({
271
+ cells: [String(f.kind), `${truncate(f.headline, 90)}${f.grouped ? ` ×${f.count}` : ''}`],
272
+ style: [c.dim, undefined],
273
+ })));
194
274
  });
195
275
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
196
276
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * One output grammar for the whole CLI (t-630).
3
+ *
4
+ * ⭐ THE CLI READ AS FOUR DIFFERENT PROGRAMS. Not because any one line was wrong,
5
+ * but because there were four heading grammars in eleven call sites:
6
+ *
7
+ * Mnema status product-prefixed
8
+ * Server sessions (7) noun + parenthesised count
9
+ * 12 task(s) count-first, with the "(s)" tell
10
+ * Pulse bare noun
11
+ *
12
+ * and three empty-state voices ("No docs.", "none found", "nothing surfaced"), and
13
+ * eleven hand-picked padEnd widths that had nothing to agree with. t-628 built the
14
+ * primitives; this module spends them, and it exists so the grammar is enforced by
15
+ * a function signature rather than by everyone remembering.
16
+ *
17
+ * The grammar, in one line each:
18
+ *
19
+ * heading Title Case noun, left. Count or context, dim, right.
20
+ * rows a table. Columns measured from content, last column absorbs.
21
+ * empty one dim sentence saying what is absent, then what to do.
22
+ * footer never a bare number. If something was cut, the command to see it.
23
+ *
24
+ * ⚠️ AND ONE RULE THAT IS NOT COSMETIC: anything the user is meant to COPY —
25
+ * the branch name from `next`, a conflict path from `pull` — is printed plain.
26
+ * No colour, no padding, no truncation. A padded string carries invisible trailing
27
+ * whitespace into their clipboard and an escape sequence carries worse.
28
+ */
29
+
30
+ import { c, section, table, truncate, width, mark, glyph } from './theme.mjs';
31
+
32
+ /** `Tasks 12` */
33
+ export function heading(title, meta) {
34
+ console.log(section(title, meta == null ? undefined : String(meta)));
35
+ }
36
+
37
+ /**
38
+ * The one empty state.
39
+ *
40
+ * `hint` is where the CLI earns its keep: "No documents yet." is a dead end,
41
+ * "No documents yet. / Docs you create in the app appear here." is an answer.
42
+ */
43
+ export function empty(sentence, hint) {
44
+ console.log(c.dim(` ${sentence}`));
45
+ if (hint) console.log(c.dim(` ${hint}`));
46
+ }
47
+
48
+ /** Aligned rows. `rows` is `[{ cells, style? }]` — see theme.table. */
49
+ export function rows(list, opts) {
50
+ for (const line of table(list, opts)) console.log(line);
51
+ }
52
+
53
+ /**
54
+ * What was left out, and the exact command that shows it.
55
+ *
56
+ * ⚠️ THIS IS THE ANTI-LIE. Three places used to print a header count and then a
57
+ * shorter body with no word about it: `ask` said "sources (7)" and listed five,
58
+ * `graph` capped at 25 silently, `search` cut previews mid-sentence. A count that
59
+ * disagrees with the rows beneath it teaches the reader to distrust every other
60
+ * number in the output.
61
+ */
62
+ export function more(hiddenOrTrue, command) {
63
+ if (!hiddenOrTrue) return;
64
+ const what = typeof hiddenOrTrue === 'number' ? `${hiddenOrTrue} more` : 'more available';
65
+ console.log(c.dim(` … ${what} — ${command}`));
66
+ }
67
+
68
+ /** A line the user is expected to copy. Plain, always. */
69
+ export function copyable(label, text) {
70
+ console.log(`\n ${c.dim(label)} ${text}`);
71
+ }
72
+
73
+ /**
74
+ * `[glyph] label value` — ONE primitive for every label/value block.
75
+ *
76
+ * ⚠️ ONE CALL, ONE TABLE, and that matters more than it looks. My first pass at
77
+ * `status` used three separate helpers, so the three groups measured their label
78
+ * columns independently and printed at three different widths — reproducing, in
79
+ * miniature, the exact defect this PR exists to remove. Anything meant to line up
80
+ * has to be measured together, which means it has to be ONE call.
81
+ *
82
+ * `state` is: true → ✓, false → ✗, 'warn' → !, null/undefined → no glyph at all.
83
+ * A blank glyph rather than a dot, because a bullet in front of "workspace" is
84
+ * decoration pretending to be information.
85
+ *
86
+ * ⚠️ PLAIN GLYPH + STYLE FUNCTION, never mark.ok(). table() strips escapes in
87
+ * order to measure a cell, so a pre-coloured glyph goes in coloured and comes out
88
+ * plain — which is what happened the first time I wrote the doctor list, and it
89
+ * rendered with colourless ticks. Pad first, colour last, always.
90
+ */
91
+ export function entries(list) {
92
+ const g = (state) => {
93
+ if (state === true) return [glyph.ok, c.green];
94
+ if (state === false) return [glyph.bad, c.red];
95
+ if (state === 'warn') return [glyph.warn, c.yellow];
96
+ return ['', undefined];
97
+ };
98
+ rows(list.map((e) => {
99
+ const [mk, mkStyle] = g(e.state);
100
+ return {
101
+ cells: [mk, e.label, e.value ?? ''],
102
+ style: [mkStyle, c.dim, e.dim ? c.dim : e.style],
103
+ };
104
+ }));
105
+ }
106
+
107
+ /** Check list — every row carries a pass/fail glyph. */
108
+ export function checks(list) {
109
+ entries(list.map((ch) => ({ label: ch.label, value: ch.note ?? '', state: ch.pass ?? false, dim: true })));
110
+ }
111
+
112
+ /** Plain `label value` rows, no glyph column content. */
113
+ export function fields(pairs) {
114
+ entries(pairs.map(([label, value, style]) => ({ label, value, state: null, style })));
115
+ }
116
+
117
+ export { c, truncate, width, mark };