@mnemahq/cli 0.1.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/LICENSE.md +83 -0
- package/README.md +63 -0
- package/bin/mnema.mjs +7 -0
- package/package.json +37 -0
- package/src/artifacts.mjs +148 -0
- package/src/cli.mjs +354 -0
- package/src/hook-install.mjs +111 -0
- package/src/keychain.mjs +207 -0
- package/src/login.mjs +190 -0
- package/src/secrets.mjs +132 -0
- package/src/util.mjs +176 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnema CLI command dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* mnema init link this repo to a workspace + install session capture
|
|
5
|
+
* mnema status is it connected, what was captured, last session
|
|
6
|
+
* mnema sessions recent sessions for this repo (local + server)
|
|
7
|
+
* mnema sweep opt-in backfill of past local sessions
|
|
8
|
+
* mnema search search your workspace from the terminal
|
|
9
|
+
* mnema doctor diagnose install, hooks, auth, connectivity
|
|
10
|
+
* mnema uninstall cleanly reverse everything
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { cmdLogin, cmdLogout } from './login.mjs';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_ORIGIN, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
|
|
17
|
+
apiFetch, prompt, promptHidden, localSessionsForRepo,
|
|
18
|
+
} from './util.mjs';
|
|
19
|
+
import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from './secrets.mjs';
|
|
20
|
+
import {
|
|
21
|
+
installHook, uninstallHook, hookInstalled, hookConfigPath, defaultDeveloperId, sweepScriptPath,
|
|
22
|
+
} from './hook-install.mjs';
|
|
23
|
+
import { applyContext, scaffold } from './artifacts.mjs';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
const VERSION = '0.1.0';
|
|
27
|
+
|
|
28
|
+
function parseFlags(argv) {
|
|
29
|
+
const flags = {}; const rest = [];
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a.startsWith('--')) {
|
|
33
|
+
const key = a.slice(2);
|
|
34
|
+
if (argv[i + 1] && !argv[i + 1].startsWith('--')) { flags[key] = argv[++i]; }
|
|
35
|
+
else flags[key] = true;
|
|
36
|
+
} else rest.push(a);
|
|
37
|
+
}
|
|
38
|
+
return { flags, rest };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveContext(flags) {
|
|
42
|
+
const git = gitInfo();
|
|
43
|
+
const root = git.root || process.cwd();
|
|
44
|
+
const cfg = readConfig(root) || {};
|
|
45
|
+
const origin = flags.origin || cfg.apiOrigin || DEFAULT_ORIGIN;
|
|
46
|
+
const workspaceId = flags.workspace || cfg.workspaceId || process.env.MNEMA_WORKSPACE_ID || null;
|
|
47
|
+
return { git, root, cfg, origin, workspaceId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fmtAge(mtimeMs) {
|
|
51
|
+
const s = Math.floor((Date.now() - mtimeMs) / 1000);
|
|
52
|
+
if (s < 60) return `${s}s ago`;
|
|
53
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
54
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
55
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── init ───────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async function cmdInit(flags) {
|
|
61
|
+
const git = gitInfo();
|
|
62
|
+
const root = git.root || process.cwd();
|
|
63
|
+
if (!git.root) console.log(c.yellow('! Not inside a git repository — session git context will be limited.'));
|
|
64
|
+
|
|
65
|
+
const origin = flags.origin || process.env.MNEMA_API_ORIGIN || DEFAULT_ORIGIN;
|
|
66
|
+
const existing = readConfig(root);
|
|
67
|
+
|
|
68
|
+
let workspaceId = flags.workspace || process.env.MNEMA_WORKSPACE_ID || existing?.workspaceId;
|
|
69
|
+
if (!workspaceId) workspaceId = await prompt('Workspace id: ');
|
|
70
|
+
if (!workspaceId) { console.error(c.red('A workspace id is required.')); process.exit(1); }
|
|
71
|
+
|
|
72
|
+
let hookToken = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
73
|
+
if (!hookToken) hookToken = await promptHidden('Hook token (Settings → Developer): ');
|
|
74
|
+
if (!hookToken) { console.error(c.red('A hook token is required.')); process.exit(1); }
|
|
75
|
+
|
|
76
|
+
let apiKey = process.env.MNEMA_API_KEY || getSecret(workspaceId, 'api-key') || '';
|
|
77
|
+
if (!apiKey && !flags.yes) apiKey = await promptHidden('API key for search/sessions (optional, Enter to skip): ');
|
|
78
|
+
|
|
79
|
+
// Store secrets in the OS keychain (or 0600 fallback).
|
|
80
|
+
setSecret(workspaceId, 'hook-token', hookToken);
|
|
81
|
+
if (apiKey) setSecret(workspaceId, 'api-key', apiKey);
|
|
82
|
+
if (usingFallback()) console.log(c.yellow(`! No OS keychain found — secrets stored in ${backendName()}.`));
|
|
83
|
+
|
|
84
|
+
process.stdout.write('Installing capture hook… ');
|
|
85
|
+
try {
|
|
86
|
+
await installHook({ origin, workspaceId, hookToken, developerId: defaultDeveloperId() });
|
|
87
|
+
console.log(c.green('done'));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.log(c.red('failed'));
|
|
90
|
+
console.error(` ${e.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Knowledge extraction is OPT-IN and sends session transcripts for LLM processing
|
|
95
|
+
// — an explicit, separate consent (a paid graph feature).
|
|
96
|
+
let knowledge = existing?.knowledge === true;
|
|
97
|
+
if (flags.knowledge === true) knowledge = true;
|
|
98
|
+
else if (flags.knowledge === 'false' || flags['no-knowledge']) knowledge = false;
|
|
99
|
+
else if (!flags.yes && process.stdin.isTTY) {
|
|
100
|
+
console.log(c.dim('\n Session → knowledge extraction (optional, paid graph feature): on each session end,'));
|
|
101
|
+
console.log(c.dim(' Mnema sends the session transcript (your prompts + assistant text, never file'));
|
|
102
|
+
console.log(c.dim(' contents) to extract durable decisions/gotchas into your graph. Off by default.'));
|
|
103
|
+
const a = await prompt(' Enable session→knowledge extraction? [y/N] ');
|
|
104
|
+
knowledge = /^y(es)?$/i.test(a);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const repoUrl = canonicalRepo(git.remote);
|
|
108
|
+
writeConfig(root, {
|
|
109
|
+
workspaceId,
|
|
110
|
+
apiOrigin: origin,
|
|
111
|
+
repo: repoUrl ? { canonicalUrl: repoUrl } : undefined,
|
|
112
|
+
knowledge,
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
scaffold(root); // .mnema/NOTABILITY.md + .gitignore + context/
|
|
116
|
+
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(c.green('✓ Mnema connected.'));
|
|
119
|
+
console.log(` Workspace : ${workspaceId}`);
|
|
120
|
+
console.log(` Repo : ${repoUrl || c.dim('(no git remote)')}`);
|
|
121
|
+
console.log(` Secrets : ${backendName()}`);
|
|
122
|
+
console.log(` Config : .mnema/config.json ${c.dim('(safe to commit — no secrets)')}`);
|
|
123
|
+
console.log(` Knowledge : ${knowledge ? c.green('on — sessions extracted to your graph') : c.dim('off (enable later: mnema init --knowledge)')}`);
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(c.dim(' Start a Claude Code session — it will appear under Sessions with its cost.'));
|
|
126
|
+
console.log(c.dim(' Backfill past sessions: mnema sweep · Check anytime: mnema status'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── status ───────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async function cmdStatus(flags) {
|
|
132
|
+
const { git, root, cfg, origin, workspaceId } = resolveContext(flags);
|
|
133
|
+
console.log(c.bold('Mnema status'));
|
|
134
|
+
console.log(` Workspace : ${workspaceId || c.red('not linked — run `mnema init`')}`);
|
|
135
|
+
console.log(` Origin : ${origin}`);
|
|
136
|
+
console.log(` Repo : ${canonicalRepo(git.remote) || c.dim('(no git remote)')}`);
|
|
137
|
+
console.log(` Hook : ${hookInstalled() ? c.green('installed') : c.red('not installed')}`);
|
|
138
|
+
|
|
139
|
+
process.stdout.write(' API : ');
|
|
140
|
+
try {
|
|
141
|
+
const r = await apiFetch(origin, '/install/mnema-hook.mjs');
|
|
142
|
+
console.log(r.ok ? c.green('reachable') : c.yellow(`HTTP ${r.status}`));
|
|
143
|
+
} catch { console.log(c.red('unreachable')); }
|
|
144
|
+
|
|
145
|
+
const local = localSessionsForRepo(git.root, 1);
|
|
146
|
+
if (local.length) {
|
|
147
|
+
console.log(` Last local session: ${local[0].sessionId.slice(0, 8)}… ${c.dim(fmtAge(local[0].mtimeMs))}`);
|
|
148
|
+
} else {
|
|
149
|
+
console.log(` Last local session: ${c.dim('none found')}`);
|
|
150
|
+
}
|
|
151
|
+
void cfg;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── sessions ───────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async function cmdSessions(flags) {
|
|
157
|
+
const { git, origin, workspaceId } = resolveContext(flags);
|
|
158
|
+
const limit = Number(flags.limit) || 10;
|
|
159
|
+
|
|
160
|
+
const local = localSessionsForRepo(git.root, limit);
|
|
161
|
+
console.log(c.bold(`Local sessions for this repo (${local.length})`));
|
|
162
|
+
if (!local.length) console.log(c.dim(' none found under ~/.claude/projects'));
|
|
163
|
+
for (const s of local) {
|
|
164
|
+
console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
168
|
+
if (!apiKey) { console.log(c.dim('\n (add an API key via `mnema init` to see server-side cost + status)')); return; }
|
|
169
|
+
|
|
170
|
+
const repo = canonicalRepo(git.remote);
|
|
171
|
+
const q = new URLSearchParams({ limit: String(limit) });
|
|
172
|
+
if (repo) q.set('repo', repo);
|
|
173
|
+
const r = await apiFetch(origin, `/api/public/v1/sessions?${q}`, { token: apiKey });
|
|
174
|
+
if (!r.ok) { console.log(c.yellow(`\n server sessions unavailable (HTTP ${r.status})`)); return; }
|
|
175
|
+
const rows = r.json?.data?.sessions ?? [];
|
|
176
|
+
console.log(c.bold(`\nServer sessions (${rows.length})`));
|
|
177
|
+
for (const s of rows) {
|
|
178
|
+
const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
|
|
179
|
+
console.log(` ${(s.developerId || '?').padEnd(16)} ${String(s.status).padEnd(10)} ${cost.padStart(10)} ${c.dim(s.model || '')}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── sweep ───────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function cmdSweep() {
|
|
186
|
+
const script = sweepScriptPath();
|
|
187
|
+
if (!existsSync(script)) {
|
|
188
|
+
console.error(c.red('Sweep script not found — run `mnema init` first.'));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
execFileSync(process.execPath, [script], { stdio: 'inherit' });
|
|
193
|
+
} catch (e) {
|
|
194
|
+
process.exit(e.status || 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── search ───────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function cmdSearch(flags, rest) {
|
|
201
|
+
const { origin, workspaceId } = resolveContext(flags);
|
|
202
|
+
const query = rest.join(' ').trim();
|
|
203
|
+
if (!query) { console.error(c.red('Usage: mnema search "<query>"')); process.exit(1); }
|
|
204
|
+
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : (process.env.MNEMA_API_KEY || null);
|
|
205
|
+
if (!apiKey) { console.error(c.red('Search needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
|
|
206
|
+
|
|
207
|
+
const r = await apiFetch(origin, `/api/public/v1/docs/search?q=${encodeURIComponent(query)}`, { token: apiKey });
|
|
208
|
+
if (!r.ok) { console.error(c.red(`Search failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
|
|
209
|
+
const results = r.json?.data?.results ?? [];
|
|
210
|
+
if (!results.length) { console.log(c.dim('No results.')); return; }
|
|
211
|
+
console.log(c.bold(`${results.length} result(s) for "${query}"`));
|
|
212
|
+
for (const d of results) {
|
|
213
|
+
console.log(` ${c.bold(d.title || d.path || d.id)}`);
|
|
214
|
+
if (d.preview) console.log(` ${c.dim(String(d.preview).replace(/\s+/g, ' ').slice(0, 140))}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── pull ───────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async function cmdPull(flags) {
|
|
221
|
+
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
222
|
+
const repo = flags.repo || canonicalRepo(git.remote);
|
|
223
|
+
if (!repo) { console.error(c.red('No git remote to identify this repo. Set one, or pass --repo <url>.')); process.exit(1); }
|
|
224
|
+
const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY;
|
|
225
|
+
if (!apiKey) { console.error(c.red('`mnema pull` needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
|
|
226
|
+
|
|
227
|
+
const r = await apiFetch(origin, `/api/public/v1/repo-context?repo=${encodeURIComponent(repo)}`, { token: apiKey });
|
|
228
|
+
if (!r.ok) { console.error(c.red(`Pull failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
|
|
229
|
+
const docs = r.json?.data?.context ?? [];
|
|
230
|
+
|
|
231
|
+
scaffold(root);
|
|
232
|
+
const s = applyContext(root, docs);
|
|
233
|
+
|
|
234
|
+
const line = (label, arr, color) => { if (arr.length) console.log(` ${color(label)} ${arr.length}`); };
|
|
235
|
+
console.log(c.bold(`Synced .mnema/context from ${repo}`));
|
|
236
|
+
if (!docs.length) console.log(c.dim(' no docs are bound to this repo yet (add a project with this repo URL).'));
|
|
237
|
+
line('written ', s.written, c.green);
|
|
238
|
+
line('updated ', s.updated, c.green);
|
|
239
|
+
line('up-to-date', s.upToDate, c.dim);
|
|
240
|
+
line('kept local', s.kept, c.yellow);
|
|
241
|
+
line('orphaned ', s.orphaned, c.dim);
|
|
242
|
+
if (s.conflicts.length) {
|
|
243
|
+
console.log(c.red(` conflicts ${s.conflicts.length} — server version written beside your file as *.remote.md:`));
|
|
244
|
+
for (const rel of s.conflicts) console.log(` ${rel} ${c.dim('vs')} ${rel.replace(/\.md$/, '.remote.md')}`);
|
|
245
|
+
}
|
|
246
|
+
console.log(c.dim('\n .mnema/context is committed and readable offline. Edit NOTABILITY.md to tune capture.'));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── doctor ───────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async function cmdDoctor(flags) {
|
|
252
|
+
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
253
|
+
const checks = [];
|
|
254
|
+
const ok = (label, pass, note) => checks.push({ label, pass, note });
|
|
255
|
+
|
|
256
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
257
|
+
ok('Node >= 18', nodeMajor >= 18, `v${process.versions.node}`);
|
|
258
|
+
let gitOk = false; try { execFileSync('git', ['--version'], { stdio: 'ignore' }); gitOk = true; } catch { /* */ }
|
|
259
|
+
ok('git available', gitOk);
|
|
260
|
+
ok('inside a git repo', !!git.root, git.root || 'no');
|
|
261
|
+
ok('workspace linked', !!workspaceId, workspaceId || '.mnema/config.json missing');
|
|
262
|
+
ok('hook token stored', !!(workspaceId && getSecret(workspaceId, 'hook-token')), `store: ${backendName()}`);
|
|
263
|
+
ok('capture hook installed', hookInstalled());
|
|
264
|
+
|
|
265
|
+
process.stdout.write(' … checking connectivity\r');
|
|
266
|
+
let apiReach = false; try { apiReach = (await apiFetch(origin, '/install/mnema-hook.mjs')).ok; } catch { /* */ }
|
|
267
|
+
ok('API reachable', apiReach, origin);
|
|
268
|
+
|
|
269
|
+
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
270
|
+
if (apiKey) {
|
|
271
|
+
let keyOk = false; try { keyOk = (await apiFetch(origin, '/api/public/v1/docs?limit=1', { token: apiKey })).ok; } catch { /* */ }
|
|
272
|
+
ok('API key valid', keyOk);
|
|
273
|
+
} else {
|
|
274
|
+
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(c.bold('Mnema doctor '));
|
|
278
|
+
for (const ch of checks) {
|
|
279
|
+
const mark = ch.pass ? c.green('✓') : c.red('✗');
|
|
280
|
+
console.log(` ${mark} ${ch.label.padEnd(26)} ${ch.note ? c.dim(ch.note) : ''}`);
|
|
281
|
+
}
|
|
282
|
+
const failed = checks.filter((ch) => !ch.pass && ch.label !== 'API key stored');
|
|
283
|
+
if (failed.length) { console.log(c.yellow(`\n ${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)); process.exit(1); }
|
|
284
|
+
console.log(c.green('\n All good.'));
|
|
285
|
+
void root;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── uninstall ───────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
async function cmdUninstall(flags) {
|
|
291
|
+
const { root, workspaceId } = resolveContext(flags);
|
|
292
|
+
uninstallHook();
|
|
293
|
+
if (workspaceId) deleteSecrets(workspaceId);
|
|
294
|
+
let purge = flags.purge === true;
|
|
295
|
+
if (!purge && process.stdin.isTTY) {
|
|
296
|
+
const a = await prompt('Also remove .mnema/config.json from this repo? [y/N] ');
|
|
297
|
+
purge = /^y(es)?$/i.test(a);
|
|
298
|
+
}
|
|
299
|
+
if (purge) removeConfigDir(root);
|
|
300
|
+
console.log(c.green('✓ Mnema hooks and secrets removed.') + (purge ? ' .mnema/ deleted.' : ` ${c.dim('.mnema/config.json kept.')}`));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── help ───────────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function help() {
|
|
306
|
+
console.log(`mnema ${VERSION} — connect a repo to your Mnema workspace
|
|
307
|
+
|
|
308
|
+
Usage: mnema <command> [options]
|
|
309
|
+
|
|
310
|
+
Commands:
|
|
311
|
+
login Sign in (opens a browser; tokens go to your OS keychain)
|
|
312
|
+
logout Remove stored credentials
|
|
313
|
+
init Link this repo to a workspace and install session capture
|
|
314
|
+
status Show connection, hook, and last session
|
|
315
|
+
sessions List recent sessions for this repo (local + server)
|
|
316
|
+
sweep Backfill past local sessions (opt-in)
|
|
317
|
+
pull Export repo-bound docs into .mnema/context (server-is-truth)
|
|
318
|
+
search "q" Search your workspace from the terminal
|
|
319
|
+
doctor Diagnose install, hooks, auth, connectivity
|
|
320
|
+
uninstall Remove hooks and stored secrets
|
|
321
|
+
|
|
322
|
+
Options:
|
|
323
|
+
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
324
|
+
--origin <url> API origin (default ${DEFAULT_ORIGIN})
|
|
325
|
+
--limit <n> Row limit for sessions
|
|
326
|
+
--yes Non-interactive; skip optional prompts
|
|
327
|
+
--purge uninstall: also delete .mnema/config.json
|
|
328
|
+
--version, --help
|
|
329
|
+
`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function run(argv) {
|
|
333
|
+
const { flags, rest } = parseFlags(argv);
|
|
334
|
+
if (flags.version) { console.log(VERSION); return; }
|
|
335
|
+
const cmd = rest.shift();
|
|
336
|
+
switch (cmd) {
|
|
337
|
+
case 'login': return cmdLogin(flags);
|
|
338
|
+
case 'logout': return cmdLogout();
|
|
339
|
+
case 'init': return cmdInit(flags);
|
|
340
|
+
case 'status': return cmdStatus(flags);
|
|
341
|
+
case 'sessions': return cmdSessions(flags);
|
|
342
|
+
case 'sweep': return cmdSweep();
|
|
343
|
+
case 'pull': return cmdPull(flags);
|
|
344
|
+
case 'search': return cmdSearch(flags, rest);
|
|
345
|
+
case 'doctor': return cmdDoctor(flags);
|
|
346
|
+
case 'uninstall': return cmdUninstall(flags);
|
|
347
|
+
case undefined:
|
|
348
|
+
case 'help': return help();
|
|
349
|
+
default:
|
|
350
|
+
console.error(c.red(`Unknown command: ${cmd}`));
|
|
351
|
+
help();
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs / removes the Mnema Claude Code capture hook — the same result as the
|
|
3
|
+
* server's /install/claude-hooks.sh, but in pure Node so the CLI needs no bash or
|
|
4
|
+
* curl and works cross-platform. Idempotent and safe to re-run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { homedir, userInfo, hostname } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, chmodSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
const HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'Stop', 'PostToolUse', 'PostToolUseFailure'];
|
|
12
|
+
|
|
13
|
+
export function claudeDir() { return join(homedir(), '.claude'); }
|
|
14
|
+
export function hooksDir() { return join(claudeDir(), 'hooks'); }
|
|
15
|
+
export function settingsPath() { return join(claudeDir(), 'settings.json'); }
|
|
16
|
+
export function hookScriptPath() { return join(hooksDir(), 'mnema-hook.mjs'); }
|
|
17
|
+
export function sweepScriptPath() { return join(hooksDir(), 'mnema-sweep.mjs'); }
|
|
18
|
+
export function hookConfigPath() { return join(hooksDir(), 'mnema.config.json'); }
|
|
19
|
+
|
|
20
|
+
export function defaultDeveloperId() {
|
|
21
|
+
let user = 'dev';
|
|
22
|
+
try { user = userInfo().username || 'dev'; } catch { /* ignore */ }
|
|
23
|
+
return `${user}@${hostname()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function fetchText(url) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
31
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
|
|
32
|
+
return await res.text();
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMnema(h) {
|
|
39
|
+
return h && h.command && String(h.command).includes('mnema-hook');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readSettings() {
|
|
43
|
+
try { return JSON.parse(readFileSync(settingsPath(), 'utf8')); } catch { return {}; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stripMnema(settings) {
|
|
47
|
+
if (!settings.hooks) return settings;
|
|
48
|
+
for (const ev of Object.keys(settings.hooks)) {
|
|
49
|
+
const arr = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
50
|
+
const cleaned = arr
|
|
51
|
+
.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !isMnema(h)) }))
|
|
52
|
+
.filter((g) => (g.hooks || []).length > 0);
|
|
53
|
+
if (cleaned.length) settings.hooks[ev] = cleaned;
|
|
54
|
+
else delete settings.hooks[ev];
|
|
55
|
+
}
|
|
56
|
+
return settings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeSettings(settings) {
|
|
60
|
+
mkdirSync(dirname(settingsPath()), { recursive: true });
|
|
61
|
+
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hookInstalled() {
|
|
65
|
+
if (!existsSync(hookScriptPath())) return false;
|
|
66
|
+
const s = readSettings();
|
|
67
|
+
if (!s.hooks) return false;
|
|
68
|
+
return Object.values(s.hooks).some(
|
|
69
|
+
(groups) => Array.isArray(groups) && groups.some((g) => (g.hooks || []).some(isMnema)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Idempotent install: fetch assets, write config (0600), register the hook. */
|
|
74
|
+
export async function installHook({ origin, workspaceId, hookToken, developerId }) {
|
|
75
|
+
const dir = hooksDir();
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
|
|
78
|
+
const [hookJs, sweepJs] = await Promise.all([
|
|
79
|
+
fetchText(`${origin}/install/mnema-hook.mjs`),
|
|
80
|
+
fetchText(`${origin}/install/mnema-sweep.mjs`),
|
|
81
|
+
]);
|
|
82
|
+
writeFileSync(hookScriptPath(), hookJs);
|
|
83
|
+
writeFileSync(sweepScriptPath(), sweepJs);
|
|
84
|
+
|
|
85
|
+
writeFileSync(
|
|
86
|
+
hookConfigPath(),
|
|
87
|
+
JSON.stringify({ origin, token: hookToken, workspaceId, developerId: developerId || defaultDeveloperId() }, null, 2),
|
|
88
|
+
{ mode: 0o600 },
|
|
89
|
+
);
|
|
90
|
+
try { chmodSync(hookConfigPath(), 0o600); } catch { /* best effort */ }
|
|
91
|
+
writeFileSync(join(dir, 'mnema-activated-at'), String(Math.floor(Date.now() / 1000)));
|
|
92
|
+
|
|
93
|
+
// Register the command hook, idempotently (strip any prior mnema entries first).
|
|
94
|
+
const settings = stripMnema(readSettings());
|
|
95
|
+
settings.hooks = settings.hooks || {};
|
|
96
|
+
const cmd = `node "${hookScriptPath()}"`;
|
|
97
|
+
for (const ev of HOOK_EVENTS) {
|
|
98
|
+
const arr = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
99
|
+
arr.push({ hooks: [{ type: 'command', command: cmd }] });
|
|
100
|
+
settings.hooks[ev] = arr;
|
|
101
|
+
}
|
|
102
|
+
writeSettings(settings);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reverse installHook: unregister + remove files. */
|
|
106
|
+
export function uninstallHook() {
|
|
107
|
+
writeSettings(stripMnema(readSettings()));
|
|
108
|
+
for (const p of [hookScriptPath(), sweepScriptPath(), hookConfigPath(), join(hooksDir(), 'mnema-activated-at')]) {
|
|
109
|
+
if (existsSync(p)) { try { rmSync(p); } catch { /* ignore */ } }
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/keychain.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS credential storage for the CLI (§C1).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ NEVER A PLAINTEXT TOKEN IN A DOTFILE. A refresh token in `~/.mnemarc` is
|
|
5
|
+
* readable by every process the user runs, survives in backups, and gets committed
|
|
6
|
+
* by accident. Each platform already has a place for this and we use it:
|
|
7
|
+
*
|
|
8
|
+
* macOS security(1) → login keychain
|
|
9
|
+
* Linux secret-tool(1) → libsecret / GNOME Keyring
|
|
10
|
+
* Windows PowerShell + DPAPI → per-user encrypted blob
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ WINDOWS IS THE ODD ONE, and honestly so. Windows Credential Manager's own
|
|
13
|
+
* store (cmdkey) cannot return a secret to a script — it is designed for the OS to
|
|
14
|
+
* consume, not the caller. So we use DPAPI (`ProtectedData.Protect`, CurrentUser
|
|
15
|
+
* scope), which is the same protection Credential Manager itself relies on: the
|
|
16
|
+
* ciphertext is bound to the Windows account and is useless if copied elsewhere.
|
|
17
|
+
* The file is opaque; it is NOT a plaintext dotfile.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ HEADLESS FALLBACK IS EXPLICIT, NOT SILENT. On a CI box with no keyring, we
|
|
20
|
+
* do NOT quietly write plaintext — that is how a "secure" store becomes a file
|
|
21
|
+
* nobody audits. We refuse, and tell the caller to use MNEMA_API_KEY instead.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
|
|
26
|
+
import { homedir, platform } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const SERVICE = 'mnema';
|
|
30
|
+
|
|
31
|
+
function run(cmd, args, input) {
|
|
32
|
+
return execFileSync(cmd, args, {
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
input,
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
timeout: 10_000,
|
|
37
|
+
}).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function has(cmd) {
|
|
41
|
+
try {
|
|
42
|
+
run(platform() === 'win32' ? 'where' : 'which', [cmd]);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── macOS ──────────────────────────────────────────────────────────────────────
|
|
50
|
+
const macos = {
|
|
51
|
+
available: () => platform() === 'darwin' && has('security'),
|
|
52
|
+
get(account) {
|
|
53
|
+
try {
|
|
54
|
+
// -w prints only the password. A missing item exits non-zero, which is a
|
|
55
|
+
// "not logged in", not an error worth surfacing.
|
|
56
|
+
return run('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
set(account, secret) {
|
|
62
|
+
// -U updates in place; without it a second login throws "item already exists".
|
|
63
|
+
run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w', secret]);
|
|
64
|
+
},
|
|
65
|
+
del(account) {
|
|
66
|
+
try { run('security', ['delete-generic-password', '-s', SERVICE, '-a', account]); } catch { /* absent */ }
|
|
67
|
+
},
|
|
68
|
+
name: 'macOS Keychain',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── Linux ──────────────────────────────────────────────────────────────────────
|
|
72
|
+
const linux = {
|
|
73
|
+
available: () => platform() === 'linux' && has('secret-tool'),
|
|
74
|
+
get(account) {
|
|
75
|
+
try {
|
|
76
|
+
const out = run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
|
|
77
|
+
return out || null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
set(account, secret) {
|
|
83
|
+
// secret-tool reads the secret from stdin, so it never appears in `ps`.
|
|
84
|
+
run('secret-tool', ['store', '--label=Mnema CLI', 'service', SERVICE, 'account', account], `${secret}\n`);
|
|
85
|
+
},
|
|
86
|
+
del(account) {
|
|
87
|
+
try { run('secret-tool', ['clear', 'service', SERVICE, 'account', account]); } catch { /* absent */ }
|
|
88
|
+
},
|
|
89
|
+
name: 'libsecret (GNOME Keyring)',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── Windows ────────────────────────────────────────────────────────────────────
|
|
93
|
+
function dpapiPath(account) {
|
|
94
|
+
const dir = join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'mnema');
|
|
95
|
+
return join(dir, `${account.replace(/[^a-zA-Z0-9._-]/g, '_')}.dpapi`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const windows = {
|
|
99
|
+
available: () => platform() === 'win32' && has('powershell'),
|
|
100
|
+
get(account) {
|
|
101
|
+
const file = dpapiPath(account);
|
|
102
|
+
if (!existsSync(file)) return null;
|
|
103
|
+
try {
|
|
104
|
+
const b64 = readFileSync(file, 'utf8').trim();
|
|
105
|
+
if (!b64) return null;
|
|
106
|
+
const script = `
|
|
107
|
+
Add-Type -AssemblyName System.Security;
|
|
108
|
+
$b = [Convert]::FromBase64String('${b64}');
|
|
109
|
+
$p = [Security.Cryptography.ProtectedData]::Unprotect($b, $null, 'CurrentUser');
|
|
110
|
+
[Text.Encoding]::UTF8.GetString($p)
|
|
111
|
+
`;
|
|
112
|
+
return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]) || null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
set(account, secret) {
|
|
118
|
+
const file = dpapiPath(account);
|
|
119
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
120
|
+
const script = `
|
|
121
|
+
Add-Type -AssemblyName System.Security;
|
|
122
|
+
$s = [Console]::In.ReadToEnd();
|
|
123
|
+
$b = [Text.Encoding]::UTF8.GetBytes($s.Trim());
|
|
124
|
+
$p = [Security.Cryptography.ProtectedData]::Protect($b, $null, 'CurrentUser');
|
|
125
|
+
[Convert]::ToBase64String($p)
|
|
126
|
+
`;
|
|
127
|
+
const b64 = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], secret);
|
|
128
|
+
writeFileSync(file, b64, { mode: 0o600 });
|
|
129
|
+
},
|
|
130
|
+
del(account) {
|
|
131
|
+
try { unlinkSync(dpapiPath(account)); } catch { /* absent */ }
|
|
132
|
+
},
|
|
133
|
+
name: 'Windows DPAPI (per-user)',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const BACKENDS = [macos, linux, windows];
|
|
137
|
+
|
|
138
|
+
/** The store for this machine, or null when none is usable. */
|
|
139
|
+
export function backend() {
|
|
140
|
+
return BACKENDS.find((b) => b.available()) ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function backendName() {
|
|
144
|
+
return backend()?.name ?? 'none available';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class NoKeychainError extends Error {
|
|
148
|
+
constructor() {
|
|
149
|
+
super(
|
|
150
|
+
'No OS credential store is available on this machine.\n'
|
|
151
|
+
+ ' macOS : the `security` command is missing\n'
|
|
152
|
+
+ ' Linux : install libsecret-tools (Debian/Ubuntu: apt install libsecret-tools)\n'
|
|
153
|
+
+ ' Windows : PowerShell is not on PATH\n\n'
|
|
154
|
+
+ 'For CI and headless machines, set MNEMA_API_KEY instead of logging in.\n'
|
|
155
|
+
+ 'Mnema will NOT write a token to a plaintext file as a fallback.',
|
|
156
|
+
);
|
|
157
|
+
this.name = 'NoKeychainError';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function getSecret(account) {
|
|
162
|
+
const b = backend();
|
|
163
|
+
if (!b) return null;
|
|
164
|
+
return b.get(account);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function setSecret(account, secret) {
|
|
168
|
+
const b = backend();
|
|
169
|
+
if (!b) throw new NoKeychainError();
|
|
170
|
+
b.set(account, secret);
|
|
171
|
+
// Prove it round-trips NOW, not on next launch. A store that silently accepts a
|
|
172
|
+
// write and returns nothing later is worse than one that refuses up front.
|
|
173
|
+
const check = b.get(account);
|
|
174
|
+
if (check !== secret) {
|
|
175
|
+
throw new Error(`${b.name} accepted the credential but did not return it. Refusing to claim you are logged in.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function deleteSecret(account) {
|
|
180
|
+
backend()?.del(account);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Where the CLI keeps non-secret login state (origin, workspace, expiry). */
|
|
184
|
+
export function statePath() {
|
|
185
|
+
const dir = join(homedir(), '.mnema');
|
|
186
|
+
mkdirSync(dir, { recursive: true });
|
|
187
|
+
return join(dir, 'auth.json');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function readState() {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(readFileSync(statePath(), 'utf8'));
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** ⚠️ Non-secret ONLY. Tokens go to the keychain; this file must stay boring. */
|
|
199
|
+
export function writeState(state) {
|
|
200
|
+
const p = statePath();
|
|
201
|
+
writeFileSync(p, JSON.stringify(state, null, 2));
|
|
202
|
+
try { chmodSync(p, 0o600); } catch { /* best effort on Windows */ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function clearState() {
|
|
206
|
+
try { unlinkSync(statePath()); } catch { /* absent */ }
|
|
207
|
+
}
|