@mnemahq/cli 0.4.0 → 0.9.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/README.md +21 -1
- package/package.json +7 -3
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +176 -52
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +135 -70
- package/src/render/layout.mjs +117 -0
- package/src/render/mark-art.mjs +160 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +40 -12
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +127 -0
- package/src/tui/components/header.mjs +66 -0
- package/src/tui/components/list.mjs +91 -0
- package/src/tui/h.mjs +23 -0
- package/src/tui/launch.mjs +61 -0
- package/src/tui/screens/briefing.mjs +74 -0
- package/src/tui/screens/finding.mjs +89 -0
- package/src/tui/screens/node.mjs +106 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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('
|
|
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
|
+
}
|
package/src/read-commands.mjs
CHANGED
|
@@ -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 {
|
|
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,28 +42,34 @@ async function guard(workspaceId) {
|
|
|
35
42
|
process.exit(1);
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
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);
|
|
42
49
|
|
|
43
50
|
// ── tasks ─────────────────────────────────────────────────────────────────────
|
|
44
51
|
export async function cmdTasks(flags, ctx) {
|
|
45
52
|
await guard(ctx.workspaceId);
|
|
46
|
-
const limit =
|
|
53
|
+
const limit = limitOf(flags, 20);
|
|
47
54
|
try {
|
|
48
|
-
const
|
|
55
|
+
const page = await call(ctx, (m) => take(m.tasks.list({
|
|
49
56
|
limit,
|
|
50
57
|
...(flags.status ? { status: flags.status } : {}),
|
|
51
58
|
...(flags.project ? { project: flags.project } : {}),
|
|
52
|
-
})
|
|
53
|
-
emit(flags, rows, () => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
);
|
|
59
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}`);
|
|
60
73
|
});
|
|
61
74
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
|
|
62
75
|
}
|
|
@@ -66,14 +79,26 @@ export async function cmdNext(flags, ctx) {
|
|
|
66
79
|
try {
|
|
67
80
|
const t = await call(ctx, (m) => m.tasks.next());
|
|
68
81
|
emit(flags, t, () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
74
98
|
if (t.publicId) {
|
|
75
|
-
const slug = t.title
|
|
76
|
-
|
|
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}` : ''}`);
|
|
77
102
|
}
|
|
78
103
|
});
|
|
79
104
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'next' }); }
|
|
@@ -82,30 +107,59 @@ export async function cmdNext(flags, ctx) {
|
|
|
82
107
|
// ── docs ──────────────────────────────────────────────────────────────────────
|
|
83
108
|
export async function cmdDocs(flags, ctx) {
|
|
84
109
|
await guard(ctx.workspaceId);
|
|
85
|
-
const limit =
|
|
110
|
+
const limit = limitOf(flags, 20);
|
|
86
111
|
try {
|
|
87
|
-
const
|
|
88
|
-
emit(flags, rows, () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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}`);
|
|
94
121
|
});
|
|
95
122
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'docs' }); }
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
export async function cmdDoc(flags, ctx, rest) {
|
|
99
126
|
await guard(ctx.workspaceId);
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
156
|
try {
|
|
103
157
|
const d = await call(ctx, (m) => m.docs.get(id));
|
|
104
|
-
//
|
|
105
|
-
// 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.
|
|
106
160
|
emit(flags, d, () => {
|
|
107
|
-
console.error(c.bold(d.title));
|
|
108
|
-
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)}`));
|
|
109
163
|
if (d.markdown) console.log(d.markdown);
|
|
110
164
|
});
|
|
111
165
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
|
|
@@ -114,12 +168,17 @@ export async function cmdDoc(flags, ctx, rest) {
|
|
|
114
168
|
// ── projects ──────────────────────────────────────────────────────────────────
|
|
115
169
|
export async function cmdProjects(flags, ctx) {
|
|
116
170
|
await guard(ctx.workspaceId);
|
|
171
|
+
const limit = limitOf(flags, 50);
|
|
117
172
|
try {
|
|
118
|
-
const
|
|
119
|
-
emit(flags, rows, () => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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}`);
|
|
123
182
|
});
|
|
124
183
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'projects' }); }
|
|
125
184
|
}
|
|
@@ -141,16 +200,12 @@ export async function cmdAsk(flags, ctx, rest) {
|
|
|
141
200
|
const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
|
|
142
201
|
console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
|
|
143
202
|
if (a.sources?.length) {
|
|
144
|
-
// ⚠️ THE HEADER USED TO LIE. It printed "sources (7):" and then listed
|
|
145
|
-
// five, silently. Either show them all or say how many are hidden —
|
|
146
|
-
// a count that does not match the rows under it teaches the reader to
|
|
147
|
-
// distrust every other number in the output.
|
|
148
203
|
const SHOWN = 5;
|
|
149
204
|
const shown = a.sources.slice(0, SHOWN);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 "…"');
|
|
154
209
|
}
|
|
155
210
|
});
|
|
156
211
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
|
|
@@ -164,22 +219,27 @@ export async function cmdGraph(flags, ctx, rest) {
|
|
|
164
219
|
const g = await call(ctx, (m) => m.graph.traverse(from, to));
|
|
165
220
|
emit(flags, g, () => {
|
|
166
221
|
if (to) {
|
|
167
|
-
// connected:false
|
|
222
|
+
// ⚠️ connected:false IS AN ANSWER, not a failure — say so plainly rather
|
|
168
223
|
// than printing an empty list and letting it read as an error.
|
|
169
|
-
if (!g.connected)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const n = g.nodes.find((x) => x.id === id);
|
|
173
|
-
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.');
|
|
174
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
|
+
}));
|
|
175
233
|
return;
|
|
176
234
|
}
|
|
177
|
-
console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
|
|
178
|
-
// ⚠️ SAME LIE AS `ask`: the header reported the true count and the body
|
|
179
|
-
// stopped at 25 without a word.
|
|
180
235
|
const CAP = 25;
|
|
181
|
-
|
|
182
|
-
if (g.nodes.length
|
|
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}"`);
|
|
183
243
|
});
|
|
184
244
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
|
|
185
245
|
}
|
|
@@ -191,21 +251,26 @@ export async function cmdBriefing(flags, ctx) {
|
|
|
191
251
|
const b = await call(ctx, (m) => m.findings.briefing());
|
|
192
252
|
emit(flags, b, () => {
|
|
193
253
|
const p = b.pulse;
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
]);
|
|
196
260
|
|
|
197
261
|
// ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
|
|
198
262
|
// causes and only one is good news; printing the findings first invites the
|
|
199
263
|
// reader to conclude "all clear" before reaching the reason it is short.
|
|
200
|
-
if (b.neverComputed) console.log(c.yellow(
|
|
201
|
-
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}`));
|
|
202
266
|
|
|
203
|
-
console.log(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
})));
|
|
209
274
|
});
|
|
210
275
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
|
|
211
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 };
|