@mnemahq/cli 0.1.1 → 0.3.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/package.json +5 -2
- package/src/cli.mjs +72 -27
- package/src/client.mjs +128 -0
- package/src/keychain.mjs +12 -1
- package/src/read-commands.mjs +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"vitest": "^3.2.7"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@mnemahq/sdk": "0.3.1"
|
|
34
|
+
},
|
|
32
35
|
"scripts": {
|
|
33
36
|
"build": "node -e \"process.exit(0)\"",
|
|
34
|
-
"typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs",
|
|
37
|
+
"typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs",
|
|
35
38
|
"test": "vitest run"
|
|
36
39
|
}
|
|
37
40
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
15
|
+
import { makeClient, canAuthenticate, renderError } from './client.mjs';
|
|
16
|
+
import {
|
|
17
|
+
cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
|
|
18
|
+
} from './read-commands.mjs';
|
|
15
19
|
import {
|
|
16
20
|
DEFAULT_ORIGIN, DEFAULT_APP_URL, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
|
|
17
21
|
apiFetch, prompt, promptHidden, localSessionsForRepo,
|
|
@@ -71,14 +75,13 @@ function fmtAge(mtimeMs) {
|
|
|
71
75
|
*/
|
|
72
76
|
async function workspaceFromSession(origin) {
|
|
73
77
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return body.workspace_id;
|
|
78
|
+
if (!(await accessToken())) return null;
|
|
79
|
+
// Via the SDK: /api/public/v1/me returns camelCase, and the SDK is the only
|
|
80
|
+
// thing that should know that.
|
|
81
|
+
const me = await makeClient({ origin }).me();
|
|
82
|
+
if (!me?.workspaceId) return null;
|
|
83
|
+
console.log(c.green(`\u2713 Workspace: ${me.workspaceName || me.workspaceId} (from your login)`));
|
|
84
|
+
return me.workspaceId;
|
|
82
85
|
} catch {
|
|
83
86
|
return null;
|
|
84
87
|
}
|
|
@@ -204,15 +207,24 @@ async function cmdSessions(flags) {
|
|
|
204
207
|
console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
|
|
205
208
|
}
|
|
206
209
|
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
211
|
+
console.log(c.dim('\n (run `mnema login` — or add an API key — to see server-side cost + status)'));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
209
214
|
|
|
210
215
|
const repo = canonicalRepo(git.remote);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
let rows = [];
|
|
217
|
+
try {
|
|
218
|
+
rows = await makeClient({ origin, workspaceId })
|
|
219
|
+
.sessions.list({ limit, ...(repo ? { repo } : {}) })
|
|
220
|
+
.all();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// Non-fatal by design: the LOCAL list above is the useful half and has
|
|
223
|
+
// already printed. Degrading here is deliberate, and it says which wall it
|
|
224
|
+
// hit rather than a bare HTTP number.
|
|
225
|
+
console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
216
228
|
console.log(c.bold(`\nServer sessions (${rows.length})`));
|
|
217
229
|
for (const s of rows) {
|
|
218
230
|
const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
|
|
@@ -241,12 +253,15 @@ async function cmdSearch(flags, rest) {
|
|
|
241
253
|
const { origin, workspaceId } = resolveContext(flags);
|
|
242
254
|
const query = rest.join(' ').trim();
|
|
243
255
|
if (!query) { console.error(c.red('Usage: mnema search "<query>"')); process.exit(1); }
|
|
244
|
-
|
|
245
|
-
|
|
256
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
257
|
+
console.error(c.red('Search needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
246
260
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
261
|
+
let results = [];
|
|
262
|
+
try {
|
|
263
|
+
results = await makeClient({ origin, workspaceId }).docs.search(query);
|
|
264
|
+
} catch (e) { renderError(e, { context: 'search' }); }
|
|
250
265
|
if (!results.length) { console.log(c.dim('No results.')); return; }
|
|
251
266
|
console.log(c.bold(`${results.length} result(s) for "${query}"`));
|
|
252
267
|
for (const d of results) {
|
|
@@ -261,12 +276,17 @@ async function cmdPull(flags) {
|
|
|
261
276
|
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
262
277
|
const repo = flags.repo || canonicalRepo(git.remote);
|
|
263
278
|
if (!repo) { console.error(c.red('No git remote to identify this repo. Set one, or pass --repo <url>.')); process.exit(1); }
|
|
264
|
-
|
|
265
|
-
|
|
279
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
280
|
+
console.error(c.red('`mnema pull` needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
266
283
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
284
|
+
let docs = [];
|
|
285
|
+
try {
|
|
286
|
+
// `.context`, not the whole result — `.repo` is the URL the server matched.
|
|
287
|
+
// Reading the wrong one of these two is the bug this rewire found in the SDK.
|
|
288
|
+
docs = (await makeClient({ origin, workspaceId }).repos.context(repo)).context ?? [];
|
|
289
|
+
} catch (e) { renderError(e, { context: 'pull' }); }
|
|
270
290
|
|
|
271
291
|
scaffold(root);
|
|
272
292
|
const s = applyContext(root, docs);
|
|
@@ -308,7 +328,8 @@ async function cmdDoctor(flags) {
|
|
|
308
328
|
|
|
309
329
|
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
310
330
|
if (apiKey) {
|
|
311
|
-
let keyOk = false;
|
|
331
|
+
let keyOk = false;
|
|
332
|
+
try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
|
|
312
333
|
ok('API key valid', keyOk);
|
|
313
334
|
} else {
|
|
314
335
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
@@ -359,10 +380,23 @@ Commands:
|
|
|
359
380
|
doctor Diagnose install, hooks, auth, connectivity
|
|
360
381
|
uninstall Remove hooks and stored secrets
|
|
361
382
|
|
|
383
|
+
Read your workspace:
|
|
384
|
+
tasks List tasks [--status --project --limit]
|
|
385
|
+
next The next task to pick up, with a ready-made branch name
|
|
386
|
+
docs List documents [--limit]
|
|
387
|
+
doc <id> Print one document as markdown
|
|
388
|
+
projects List projects
|
|
389
|
+
briefing What deserves attention — pulse, deltas, findings
|
|
390
|
+
|
|
391
|
+
Ask the knowledge graph (paid feature):
|
|
392
|
+
ask "q" A cited answer, with the confidence it deserves
|
|
393
|
+
graph <a> [b] Neighbourhood of a, or the shortest path from a to b
|
|
394
|
+
|
|
362
395
|
Options:
|
|
363
396
|
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
364
397
|
--origin <url> API origin (default ${DEFAULT_ORIGIN})
|
|
365
|
-
--limit <n> Row limit
|
|
398
|
+
--limit <n> Row limit
|
|
399
|
+
--json Machine-readable output (every read command)
|
|
366
400
|
--yes Non-interactive; skip optional prompts
|
|
367
401
|
--purge uninstall: also delete .mnema/config.json
|
|
368
402
|
--version, --help
|
|
@@ -384,6 +418,17 @@ export async function run(argv) {
|
|
|
384
418
|
case 'search': return cmdSearch(flags, rest);
|
|
385
419
|
case 'doctor': return cmdDoctor(flags);
|
|
386
420
|
case 'uninstall': return cmdUninstall(flags);
|
|
421
|
+
|
|
422
|
+
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
423
|
+
// none of them knows a URL or an envelope key (§A2, t-623).
|
|
424
|
+
case 'tasks': return cmdTasks(flags, resolveContext(flags));
|
|
425
|
+
case 'next': return cmdNext(flags, resolveContext(flags));
|
|
426
|
+
case 'docs': return cmdDocs(flags, resolveContext(flags));
|
|
427
|
+
case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
|
|
428
|
+
case 'projects': return cmdProjects(flags, resolveContext(flags));
|
|
429
|
+
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
430
|
+
case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
|
|
431
|
+
case 'briefing': return cmdBriefing(flags, resolveContext(flags));
|
|
387
432
|
case undefined:
|
|
388
433
|
case 'help': return help();
|
|
389
434
|
default:
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the CLI talks to Mnema — through @mnemahq/sdk (§A2).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHY THE RULE EXISTS, DEMONSTRATED THE HOUR IT WAS FOLLOWED. Rewiring the
|
|
5
|
+
* first command onto the SDK immediately turned up that `repos.context()` had
|
|
6
|
+
* been unwrapping the wrong envelope key since it shipped — it returned the repo
|
|
7
|
+
* URL string instead of the documents. The CLI's own raw fetch read the right key
|
|
8
|
+
* and had been right all along. An SDK its own CLI routes around has no first
|
|
9
|
+
* user, so its mistakes are found by everyone else instead.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ NOT EVERY REQUEST BELONGS HERE. `/install/mnema-hook.mjs` is a static asset,
|
|
12
|
+
* not an API call, and stays on plain fetch in hook-install.mjs. Routing it
|
|
13
|
+
* through a typed API client would imply an envelope and a credential that the
|
|
14
|
+
* endpoint neither sends nor wants.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Mnema } from '@mnemahq/sdk';
|
|
18
|
+
import { getSecret } from './secrets.mjs';
|
|
19
|
+
import { accessToken } from './login.mjs';
|
|
20
|
+
import { c, DEFAULT_APP_URL } from './util.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A client carrying whichever credential this machine actually has.
|
|
24
|
+
*
|
|
25
|
+
* Order is deliberate: an API key is explicit, scoped, and what CI uses, so it
|
|
26
|
+
* wins. The OAuth token from `mnema login` is the human fallback. `getToken` is
|
|
27
|
+
* passed as a FUNCTION rather than a resolved string so the SDK re-reads it per
|
|
28
|
+
* request — that is what makes a refresh mid-command work instead of failing on
|
|
29
|
+
* a token that expired thirty seconds ago.
|
|
30
|
+
*/
|
|
31
|
+
export function makeClient({ origin, workspaceId }) {
|
|
32
|
+
const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null;
|
|
33
|
+
return new Mnema(
|
|
34
|
+
apiKey
|
|
35
|
+
? { apiKey, baseUrl: origin }
|
|
36
|
+
: { getToken: accessToken, baseUrl: origin },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when an explicit API key is configured. Cheap; no network. */
|
|
41
|
+
export function hasApiKey(workspaceId) {
|
|
42
|
+
return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when this machine can authenticate AT ALL — by key or by login.
|
|
47
|
+
*
|
|
48
|
+
* ⭐ THE COMMANDS USED TO DEMAND A KEY, and that was a leftover from before the
|
|
49
|
+
* public API accepted both. `/api/public/v1` has taken an OAuth access token
|
|
50
|
+
* since the login flow shipped, so "Search needs an API key" was refusing work
|
|
51
|
+
* the server would happily have done for someone who had already run
|
|
52
|
+
* `mnema login`. Asking a person to go generate a credential they do not need is
|
|
53
|
+
* the kind of friction that makes a tool feel broken.
|
|
54
|
+
*/
|
|
55
|
+
export async function canAuthenticate(workspaceId) {
|
|
56
|
+
if (hasApiKey(workspaceId)) return true;
|
|
57
|
+
try { return Boolean(await accessToken()); } catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turn a thrown SDK error into something a person can act on, and exit.
|
|
62
|
+
*
|
|
63
|
+
* ⭐ THIS IS THE POINT OF THE REWIRE, not a nicety. Every failure used to print
|
|
64
|
+
* `HTTP 402` and a raw body — which tells you a wall exists and nothing about
|
|
65
|
+
* which one. The hierarchy already knows: whether you need to log in, upgrade,
|
|
66
|
+
* top up, wait, or accept that this server does not have the feature at all.
|
|
67
|
+
* Four of those five have different fixes and the fifth cannot be fixed by
|
|
68
|
+
* paying.
|
|
69
|
+
*
|
|
70
|
+
* Errors are matched by NAME rather than instanceof: the CLI may load a
|
|
71
|
+
* different copy of @mnemahq/core than the SDK did (npm dedupes, pnpm does not
|
|
72
|
+
* always), and a cross-realm instanceof silently falls through to the generic
|
|
73
|
+
* branch — turning a good message back into a bad one for the exact reason this
|
|
74
|
+
* function exists.
|
|
75
|
+
*/
|
|
76
|
+
export function renderError(e, { context = '' } = {}) {
|
|
77
|
+
const name = e?.constructor?.name ?? '';
|
|
78
|
+
const where = context ? `${context}: ` : '';
|
|
79
|
+
|
|
80
|
+
switch (name) {
|
|
81
|
+
case 'AuthError':
|
|
82
|
+
console.error(c.red(`${where}not authenticated.`));
|
|
83
|
+
console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'PlanRequiredError':
|
|
87
|
+
console.error(c.yellow(`${where}your ${e.plan ?? 'current'} plan does not include ${e.feature ?? 'this'}.`));
|
|
88
|
+
console.error(` Needs the ${e.required ?? 'a paid'} plan.`);
|
|
89
|
+
console.error(` ${c.dim(e.upgradeUrl ?? `${DEFAULT_APP_URL}/app/settings/billing`)}`);
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
case 'FeatureUnavailableError':
|
|
93
|
+
// No upgrade link here on purpose. There is nothing to buy: this server's
|
|
94
|
+
// build does not contain the feature, and pointing at billing would sell
|
|
95
|
+
// someone a plan that cannot change their binary.
|
|
96
|
+
console.error(c.yellow(`${where}${e.feature ?? 'that feature'} is not available on this server.`));
|
|
97
|
+
console.error(` ${c.dim(`This is a ${e.edition ?? 'core'} build — the enterprise modules are not installed.`)}`);
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case 'InsufficientCreditError': {
|
|
101
|
+
const need = typeof e.topUpNeededCents === 'number' ? `$${(e.topUpNeededCents / 100).toFixed(2)}` : 'more credit';
|
|
102
|
+
console.error(c.yellow(`${where}not enough credit for ${e.action ?? 'this action'}.`));
|
|
103
|
+
console.error(` Top up ${need}. ${c.dim(`${DEFAULT_APP_URL}/app/settings/wallet`)}`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'RateLimitError': {
|
|
108
|
+
const secs = typeof e.retryAfterMs === 'number' ? Math.ceil(e.retryAfterMs / 1000) : null;
|
|
109
|
+
console.error(c.yellow(`${where}rate limited${secs ? ` — retry in ${secs}s` : ''}.`));
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'ValidationError':
|
|
114
|
+
console.error(c.red(`${where}${e.message}`));
|
|
115
|
+
if (e.fields) for (const [f, msgs] of Object.entries(e.fields)) console.error(` ${f}: ${[].concat(msgs).join(', ')}`);
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'NetworkError':
|
|
119
|
+
console.error(c.red(`${where}could not reach the server.`));
|
|
120
|
+
console.error(` ${c.dim(e.message)}`);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
console.error(c.red(`${where}${e?.message ?? String(e)}`));
|
|
125
|
+
if (e?.status) console.error(` ${c.dim(`HTTP ${e.status}`)}`);
|
|
126
|
+
}
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
package/src/keychain.mjs
CHANGED
|
@@ -181,8 +181,19 @@ export function deleteSecret(account) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/** Where the CLI keeps non-secret login state (origin, workspace, expiry). */
|
|
184
|
+
/**
|
|
185
|
+
* Where the CLI keeps non-secret login state.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ MNEMA_STATE_DIR EXISTS BECAUSE THE TEST SUITE ATE A REAL LOGIN. keychain's
|
|
188
|
+
* own test calls writeState({ origin: 'https://api.example', … }) to check the
|
|
189
|
+
* file round-trips — and with the path hard-wired to homedir() that wrote over
|
|
190
|
+
* the developer's actual ~/.mnema/auth.json. Running `pnpm test` silently
|
|
191
|
+
* repointed a working CLI at a fake host; the next command failed with
|
|
192
|
+
* `getaddrinfo api.example`, which reads like a network fault and is nothing of
|
|
193
|
+
* the kind. Tests must not be able to reach a real home directory.
|
|
194
|
+
*/
|
|
184
195
|
export function statePath() {
|
|
185
|
-
const dir = join(homedir(), '.mnema');
|
|
196
|
+
const dir = process.env.MNEMA_STATE_DIR || join(homedir(), '.mnema');
|
|
186
197
|
mkdirSync(dir, { recursive: true });
|
|
187
198
|
return join(dir, 'auth.json');
|
|
188
199
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read commands — tasks, docs, projects, ask, graph, briefing (t-624).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ EVERY ONE OF THESE IS A WRAPPER, NOT A CLIENT. They exist because t-623 put
|
|
5
|
+
* the CLI on @mnemahq/sdk; each is four lines of SDK call plus formatting, and
|
|
6
|
+
* none of them knows a URL, an envelope key or a header. That is the payoff §A2
|
|
7
|
+
* was arguing for: the surface grew without the transport growing with it.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ THE FORMATTING IS THE PART WITH JUDGEMENT IN IT, and two of these can lie by
|
|
10
|
+
* omission if it is done carelessly:
|
|
11
|
+
*
|
|
12
|
+
* ask the interpreter HEDGES below its confidence threshold. Printing the
|
|
13
|
+
* sentence without the number turns a hedge into a claim, which is
|
|
14
|
+
* worse than printing nothing.
|
|
15
|
+
* briefing a short list has three quite different causes and only one is good
|
|
16
|
+
* news. On a core build six of seven finding families cannot produce
|
|
17
|
+
* anything at all, and `coverage.notice` is the only thing that says so.
|
|
18
|
+
*
|
|
19
|
+
* --json everywhere: a CLI you cannot pipe is half a CLI, and the SDK already
|
|
20
|
+
* returns the shape worth emitting, so it costs one line each.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { makeClient, canAuthenticate, renderError } from './client.mjs';
|
|
24
|
+
import { c } from './util.mjs';
|
|
25
|
+
|
|
26
|
+
/** Machine output is the whole object; humans get the formatted view. */
|
|
27
|
+
function emit(flags, value, render) {
|
|
28
|
+
if (flags.json) { console.log(JSON.stringify(value, null, 2)); return; }
|
|
29
|
+
render();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function guard(workspaceId) {
|
|
33
|
+
if (await canAuthenticate(workspaceId)) return;
|
|
34
|
+
console.error(c.red('Not authenticated — run `mnema login`, or `mnema init` with an API key.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trunc = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : (s ?? ''));
|
|
39
|
+
|
|
40
|
+
// ── tasks ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
export async function cmdTasks(flags, ctx) {
|
|
42
|
+
await guard(ctx.workspaceId);
|
|
43
|
+
const limit = Number(flags.limit) || 20;
|
|
44
|
+
try {
|
|
45
|
+
const rows = await makeClient(ctx).tasks.list({
|
|
46
|
+
limit,
|
|
47
|
+
...(flags.status ? { status: flags.status } : {}),
|
|
48
|
+
...(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)}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
} catch (e) { renderError(e, { context: 'tasks' }); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cmdNext(flags, ctx) {
|
|
62
|
+
await guard(ctx.workspaceId);
|
|
63
|
+
try {
|
|
64
|
+
const t = await makeClient(ctx).tasks.next();
|
|
65
|
+
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.
|
|
71
|
+
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}` : ''}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} catch (e) { renderError(e, { context: 'next' }); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── docs ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
export async function cmdDocs(flags, ctx) {
|
|
81
|
+
await guard(ctx.workspaceId);
|
|
82
|
+
const limit = Number(flags.limit) || 20;
|
|
83
|
+
try {
|
|
84
|
+
const rows = await makeClient(ctx).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
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} catch (e) { renderError(e, { context: 'docs' }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function cmdDoc(flags, ctx, rest) {
|
|
96
|
+
await guard(ctx.workspaceId);
|
|
97
|
+
const id = rest[0];
|
|
98
|
+
if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
|
|
99
|
+
try {
|
|
100
|
+
const d = await makeClient(ctx).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.
|
|
103
|
+
emit(flags, d, () => {
|
|
104
|
+
console.error(c.bold(d.title));
|
|
105
|
+
console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
|
|
106
|
+
if (d.markdown) console.log(d.markdown);
|
|
107
|
+
});
|
|
108
|
+
} catch (e) { renderError(e, { context: 'doc' }); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── projects ──────────────────────────────────────────────────────────────────
|
|
112
|
+
export async function cmdProjects(flags, ctx) {
|
|
113
|
+
await guard(ctx.workspaceId);
|
|
114
|
+
try {
|
|
115
|
+
const rows = await makeClient(ctx).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)}`);
|
|
120
|
+
});
|
|
121
|
+
} catch (e) { renderError(e, { context: 'projects' }); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── graph: ask + traverse ─────────────────────────────────────────────────────
|
|
125
|
+
export async function cmdAsk(flags, ctx, rest) {
|
|
126
|
+
await guard(ctx.workspaceId);
|
|
127
|
+
const q = rest.join(' ').trim();
|
|
128
|
+
if (!q) { console.error(c.red('Usage: mnema ask "<question>"')); process.exit(1); }
|
|
129
|
+
try {
|
|
130
|
+
const a = await makeClient(ctx).graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) });
|
|
131
|
+
emit(flags, a, () => {
|
|
132
|
+
console.log(a.answer);
|
|
133
|
+
// ⚠️ NEVER WITHOUT THE CONFIDENCE. The interpreter deliberately hedges
|
|
134
|
+
// rather than asserts when the graph is thin; a terminal that prints only
|
|
135
|
+
// the sentence promotes a hedge to a fact.
|
|
136
|
+
const pct = Math.round((a.confidence ?? 0) * 100);
|
|
137
|
+
const why = a.confidenceReason ? ` (${a.confidenceReason})` : '';
|
|
138
|
+
const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
|
|
139
|
+
console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
|
|
140
|
+
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)}`));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
} catch (e) { renderError(e, { context: 'ask' }); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function cmdGraph(flags, ctx, rest) {
|
|
149
|
+
await guard(ctx.workspaceId);
|
|
150
|
+
const [from, to] = rest;
|
|
151
|
+
if (!from) { console.error(c.red('Usage: mnema graph <from> [to]')); process.exit(1); }
|
|
152
|
+
try {
|
|
153
|
+
const g = await makeClient(ctx).graph.traverse(from, to);
|
|
154
|
+
emit(flags, g, () => {
|
|
155
|
+
if (to) {
|
|
156
|
+
// connected:false is an ANSWER, not a failure — say so plainly rather
|
|
157
|
+
// 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}]`) : ''}`);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
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 ?? '')}`);
|
|
168
|
+
});
|
|
169
|
+
} catch (e) { renderError(e, { context: 'graph' }); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── briefing ──────────────────────────────────────────────────────────────────
|
|
173
|
+
export async function cmdBriefing(flags, ctx) {
|
|
174
|
+
await guard(ctx.workspaceId);
|
|
175
|
+
try {
|
|
176
|
+
const b = await makeClient(ctx).findings.briefing();
|
|
177
|
+
emit(flags, b, () => {
|
|
178
|
+
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`);
|
|
181
|
+
|
|
182
|
+
// ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
|
|
183
|
+
// causes and only one is good news; printing the findings first invites the
|
|
184
|
+
// 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}`));
|
|
187
|
+
|
|
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
|
+
}
|
|
194
|
+
});
|
|
195
|
+
} catch (e) { renderError(e, { context: 'briefing' }); }
|
|
196
|
+
}
|