memoir-cli 3.6.1 → 3.8.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 +10 -3
- package/bin/memoir.js +114 -0
- package/package.json +13 -3
- package/src/commands/auto-refresh.js +31 -0
- package/src/commands/autopush.js +57 -0
- package/src/commands/doctor.js +4 -14
- package/src/commands/hooks.js +171 -0
- package/src/commands/push.js +149 -0
- package/src/commands/restore.js +36 -0
- package/src/commands/session.js +188 -0
- package/src/commands/why.js +50 -0
- package/src/mcp.js +175 -0
- package/src/session/inject.js +117 -0
- package/src/session/render.js +114 -0
- package/src/session/state.js +296 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -16
- package/CONTRIBUTING.md +0 -47
- package/demo.svg +0 -201
- package/server.json +0 -20
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
npx memoir-cli
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
One command. No install, no config, no API keys. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and
|
|
18
|
+
One command. No install, no config, no API keys. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and 6 more tools.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -44,7 +44,7 @@ npx memoir-cli
|
|
|
44
44
|
|
|
45
45
|
That's it. memoir detects your AI tools, configures MCP, and activates memory. No global install needed.
|
|
46
46
|
|
|
47
|
-
Your AI gets
|
|
47
|
+
Your AI gets 14 memory tools:
|
|
48
48
|
|
|
49
49
|
| MCP Tool | What it does |
|
|
50
50
|
|----------|-------------|
|
|
@@ -55,6 +55,13 @@ Your AI gets 7 memory tools:
|
|
|
55
55
|
| `memoir_consolidate` | Analyze memories for duplicates, staleness, and bloat |
|
|
56
56
|
| `memoir_status` | See which AI tools are detected |
|
|
57
57
|
| `memoir_profiles` | Switch between work/personal |
|
|
58
|
+
| `memoir_set_goal` | Set the current session goal (pinned into CLAUDE.md) |
|
|
59
|
+
| `memoir_add_next` | Add a next action to the current session |
|
|
60
|
+
| `memoir_complete_next` | Mark a next action as done |
|
|
61
|
+
| `memoir_note` | Record a decision with its rationale |
|
|
62
|
+
| `memoir_ask` | Capture an open question for later |
|
|
63
|
+
| `memoir_session` | Show goals, next actions, decisions, and recent sessions |
|
|
64
|
+
| `memoir_why` | Look up why a past decision was made |
|
|
58
65
|
|
|
59
66
|
## Why memoir
|
|
60
67
|
|
|
@@ -62,7 +69,7 @@ Your AI forgets everything between sessions. You re-explain your codebase, your
|
|
|
62
69
|
|
|
63
70
|
memoir fixes this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too. Sync AI memory between tools, back it up to the cloud, restore it on any machine. And when your memories pile up, `memoir consolidate` cleans house — finds duplicates, flags stale context, and optionally uses AI to merge and prune.
|
|
64
71
|
|
|
65
|
-
**
|
|
72
|
+
**11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev.
|
|
66
73
|
|
|
67
74
|
## Sync across machines
|
|
68
75
|
|
package/bin/memoir.js
CHANGED
|
@@ -22,6 +22,19 @@ import { projectsListCommand, projectsTodoCommand } from '../src/commands/projec
|
|
|
22
22
|
import { upgradeCommand } from '../src/commands/upgrade.js';
|
|
23
23
|
import { activateCommand, deactivateCommand } from '../src/commands/activate.js';
|
|
24
24
|
import { consolidateCommand } from '../src/commands/consolidate.js';
|
|
25
|
+
import {
|
|
26
|
+
goalCommand,
|
|
27
|
+
nextCommand,
|
|
28
|
+
doneCommand,
|
|
29
|
+
noteCommand,
|
|
30
|
+
askCommand,
|
|
31
|
+
sessionShowCommand,
|
|
32
|
+
sessionClearCommand,
|
|
33
|
+
} from '../src/commands/session.js';
|
|
34
|
+
import { autopushCommand } from '../src/commands/autopush.js';
|
|
35
|
+
import { whyCommand } from '../src/commands/why.js';
|
|
36
|
+
import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
|
|
37
|
+
import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
|
|
25
38
|
import { createRequire } from 'module';
|
|
26
39
|
|
|
27
40
|
const require = createRequire(import.meta.url);
|
|
@@ -99,6 +112,7 @@ program
|
|
|
99
112
|
.description('Back up your AI memory to the cloud')
|
|
100
113
|
.option('--only <tools>', 'Only sync specific tools (comma-separated)')
|
|
101
114
|
.option('-p, --profile <name>', 'Use a specific profile')
|
|
115
|
+
.option('--redact', 'Strip detected secrets from synced files before they are backed up')
|
|
102
116
|
.action(async (options) => {
|
|
103
117
|
try {
|
|
104
118
|
await pushCommand(options);
|
|
@@ -153,6 +167,106 @@ program
|
|
|
153
167
|
}
|
|
154
168
|
});
|
|
155
169
|
|
|
170
|
+
// ── Session continuity ──────────────────────────────────────────
|
|
171
|
+
program
|
|
172
|
+
.command('goal <text...>')
|
|
173
|
+
.description('Set your current goal (pinned into CLAUDE.md, syncs across machines)')
|
|
174
|
+
.action(async (text) => {
|
|
175
|
+
try { await goalCommand(text.join(' ')); }
|
|
176
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
program
|
|
180
|
+
.command('next <text...>')
|
|
181
|
+
.description('Add a next action')
|
|
182
|
+
.action(async (text) => {
|
|
183
|
+
try { await nextCommand(text.join(' ')); }
|
|
184
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
program
|
|
188
|
+
.command('done <text...>')
|
|
189
|
+
.description('Mark a next action complete (substring match)')
|
|
190
|
+
.action(async (text) => {
|
|
191
|
+
try { await doneCommand(text.join(' ')); }
|
|
192
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
program
|
|
196
|
+
.command('note <text...>')
|
|
197
|
+
.description('Record a decision with rationale (--why) and rejected alternative (--rejected)')
|
|
198
|
+
.option('--why <rationale>', 'Why this decision was made')
|
|
199
|
+
.option('--rejected <alternative>', 'The alternative you considered and rejected')
|
|
200
|
+
.action(async (text, options) => {
|
|
201
|
+
try { await noteCommand(text.join(' '), options); }
|
|
202
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
program
|
|
206
|
+
.command('ask <text...>')
|
|
207
|
+
.description('Capture an open question for later')
|
|
208
|
+
.action(async (text) => {
|
|
209
|
+
try { await askCommand(text.join(' ')); }
|
|
210
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
program
|
|
214
|
+
.command('why [query...]')
|
|
215
|
+
.description('Look up decisions by keyword — returns what was decided + why + what was rejected')
|
|
216
|
+
.action(async (query) => {
|
|
217
|
+
try { await whyCommand((query || []).join(' ')); }
|
|
218
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
program
|
|
222
|
+
.command('session')
|
|
223
|
+
.description('Show the current session state')
|
|
224
|
+
.action(async () => {
|
|
225
|
+
try { await sessionShowCommand(); }
|
|
226
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command('session-clear')
|
|
231
|
+
.description('Clear the current session (history retained)')
|
|
232
|
+
.action(async () => {
|
|
233
|
+
try { await sessionClearCommand(); }
|
|
234
|
+
catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── Hooks + auto-sync (called by Claude Code hooks or the user) ─────
|
|
238
|
+
program
|
|
239
|
+
.command('autopush')
|
|
240
|
+
.description('Debounced auto-push — called by the Claude Code Stop hook')
|
|
241
|
+
.option('--debounce <seconds>', 'Minimum seconds between auto-pushes', '30')
|
|
242
|
+
.option('-v, --verbose', 'Print debounce state')
|
|
243
|
+
.action(async (options) => {
|
|
244
|
+
try { await autopushCommand(options); }
|
|
245
|
+
catch (err) { if (options.verbose) console.error(chalk.red(err.message)); /* silent by default */ }
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
program
|
|
249
|
+
.command('auto-refresh')
|
|
250
|
+
.description('Re-render the pinned session block — called by the SessionStart hook')
|
|
251
|
+
.option('-v, --verbose', 'Print what changed')
|
|
252
|
+
.action(async (options) => {
|
|
253
|
+
try { await autoRefreshCommand(options); }
|
|
254
|
+
catch (err) { if (options.verbose) console.error(chalk.red(err.message)); }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
program
|
|
258
|
+
.command('hooks')
|
|
259
|
+
.description('Manage Claude Code hooks (install | uninstall | status)')
|
|
260
|
+
.argument('[subcommand]', 'install, uninstall, or status', 'status')
|
|
261
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
262
|
+
.action(async (subcommand, options) => {
|
|
263
|
+
try {
|
|
264
|
+
if (subcommand === 'install') await hooksInstallCommand(options);
|
|
265
|
+
else if (subcommand === 'uninstall') await hooksUninstallCommand(options);
|
|
266
|
+
else await hooksStatusCommand();
|
|
267
|
+
} catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
|
|
268
|
+
});
|
|
269
|
+
|
|
156
270
|
program
|
|
157
271
|
.command('doctor')
|
|
158
272
|
.alias('diagnose')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"mcpName": "io.github.camgitt/memoir",
|
|
5
5
|
"description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferences — across tools and machines.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -9,9 +9,18 @@
|
|
|
9
9
|
"memoir": "bin/memoir.js",
|
|
10
10
|
"memoir-mcp": "src/mcp.js"
|
|
11
11
|
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
12
21
|
"repository": {
|
|
13
22
|
"type": "git",
|
|
14
|
-
"url": "https://github.com/camgitt/memoir.git"
|
|
23
|
+
"url": "git+https://github.com/camgitt/memoir.git"
|
|
15
24
|
},
|
|
16
25
|
"homepage": "https://memoir.sh",
|
|
17
26
|
"bugs": {
|
|
@@ -19,8 +28,9 @@
|
|
|
19
28
|
},
|
|
20
29
|
"scripts": {
|
|
21
30
|
"start": "node bin/memoir.js",
|
|
22
|
-
"test": "node
|
|
31
|
+
"test": "node run-tests.mjs",
|
|
23
32
|
"test:legacy": "bash test-local.sh",
|
|
33
|
+
"prepublishOnly": "npm test",
|
|
24
34
|
"postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
|
|
25
35
|
},
|
|
26
36
|
"keywords": [
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Auto-refresh — called by the Claude Code SessionStart hook.
|
|
2
|
+
// Reads current session.json (local) and re-injects the pinned block into
|
|
3
|
+
// CLAUDE.md. Instant, no network, no side effects beyond that file.
|
|
4
|
+
//
|
|
5
|
+
// For cross-machine pull-on-start, that's a separate concern — handled by
|
|
6
|
+
// periodic auto-restore or explicit `memoir restore`. This hook only ensures
|
|
7
|
+
// the pinned block matches the current local session state.
|
|
8
|
+
|
|
9
|
+
import { readSession } from '../session/state.js';
|
|
10
|
+
import { renderSession } from '../session/render.js';
|
|
11
|
+
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
12
|
+
|
|
13
|
+
export async function autoRefreshCommand(options = {}) {
|
|
14
|
+
const verbose = !!options.verbose;
|
|
15
|
+
try {
|
|
16
|
+
const state = await readSession();
|
|
17
|
+
const rendered = renderSession(state);
|
|
18
|
+
const targets = detectAvailableTargets();
|
|
19
|
+
for (const [tool, target] of Object.entries(targets)) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await injectInto(target, rendered);
|
|
22
|
+
if (verbose) console.log(`memoir auto-refresh: ${res.replaced ? 'updated' : 'created'} ${tool} → ${res.path}`);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (verbose) console.error(`memoir auto-refresh: ${tool} failed: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (verbose) console.error(`memoir auto-refresh: ${err.message}`);
|
|
29
|
+
// Never fail the hook — session start must proceed.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Auto-push command — called by the Claude Code Stop hook after every response.
|
|
2
|
+
//
|
|
3
|
+
// Rules of engagement:
|
|
4
|
+
// - Debounced: won't run more than once per DEBOUNCE_SECONDS.
|
|
5
|
+
// - Non-blocking: detaches a background subprocess and exits immediately
|
|
6
|
+
// so Claude's response pipeline is never held up.
|
|
7
|
+
// - Silent: no stdout unless --verbose is passed.
|
|
8
|
+
//
|
|
9
|
+
// The actual push work happens in a fully detached child — the hook process
|
|
10
|
+
// itself just records a timestamp and returns.
|
|
11
|
+
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
|
|
17
|
+
const home = os.homedir();
|
|
18
|
+
const STAMP_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp');
|
|
19
|
+
const DEBOUNCE_SECONDS_DEFAULT = 30;
|
|
20
|
+
|
|
21
|
+
export async function autopushCommand(options = {}) {
|
|
22
|
+
const debounce = parseInt(options.debounce || DEBOUNCE_SECONDS_DEFAULT, 10);
|
|
23
|
+
const verbose = !!options.verbose;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await fs.ensureDir(path.dirname(STAMP_FILE));
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
let last = 0;
|
|
31
|
+
try {
|
|
32
|
+
const raw = await fs.readFile(STAMP_FILE, 'utf8');
|
|
33
|
+
last = parseInt(raw.trim(), 10) || 0;
|
|
34
|
+
} catch {}
|
|
35
|
+
|
|
36
|
+
const elapsed = (now - last) / 1000;
|
|
37
|
+
if (last && elapsed < debounce) {
|
|
38
|
+
if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Stamp BEFORE spawning so rapid repeat calls don't all race through.
|
|
43
|
+
try {
|
|
44
|
+
await fs.writeFile(STAMP_FILE, String(now));
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
// Detach a background push. Parent exits immediately so Claude isn't blocked.
|
|
48
|
+
const memoirBin = process.argv[1]; // path to this same memoir CLI
|
|
49
|
+
const child = spawn(process.execPath, [memoirBin, 'push'], {
|
|
50
|
+
detached: true,
|
|
51
|
+
stdio: verbose ? 'inherit' : 'ignore',
|
|
52
|
+
env: { ...process.env, MEMOIR_AUTOPUSH: '1' },
|
|
53
|
+
});
|
|
54
|
+
child.unref();
|
|
55
|
+
|
|
56
|
+
if (verbose) console.log('memoir autopush: triggered (background)');
|
|
57
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -8,15 +8,7 @@ import os from 'os';
|
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
9
|
import { getConfig } from '../config.js';
|
|
10
10
|
import { adapters } from '../adapters/index.js';
|
|
11
|
-
|
|
12
|
-
const SECRET_PATTERNS = [
|
|
13
|
-
{ pattern: /sk-[a-zA-Z0-9]{20,}/, label: 'OpenAI/Stripe secret key' },
|
|
14
|
-
{ pattern: /key-[a-zA-Z0-9]{20,}/, label: 'API key' },
|
|
15
|
-
{ pattern: /ghp_[a-zA-Z0-9]{36,}/, label: 'GitHub personal access token' },
|
|
16
|
-
{ pattern: /gho_[a-zA-Z0-9]{36,}/, label: 'GitHub OAuth token' },
|
|
17
|
-
{ pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' },
|
|
18
|
-
{ pattern: /Bearer\s+[a-zA-Z0-9._\-]{20,}/, label: 'Bearer token' },
|
|
19
|
-
];
|
|
11
|
+
import { scanForSecrets as scanTextForSecrets } from '../security/scanner.js';
|
|
20
12
|
|
|
21
13
|
const SENSITIVE_FILENAMES = ['.env', 'credentials', 'token.json'];
|
|
22
14
|
|
|
@@ -61,11 +53,9 @@ async function scanForSecrets(files) {
|
|
|
61
53
|
// Skip files larger than 1MB
|
|
62
54
|
if (stat.size > 1024 * 1024) continue;
|
|
63
55
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
56
|
+
const { found } = scanTextForSecrets(content);
|
|
57
|
+
if (found.length > 0) {
|
|
58
|
+
warnings.push({ file: filePath, reason: found[0].label });
|
|
69
59
|
}
|
|
70
60
|
} catch {
|
|
71
61
|
// Skip unreadable files
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Install / uninstall / status for Claude Code hooks.
|
|
2
|
+
//
|
|
3
|
+
// Hooks are configured in ~/.claude/settings.json under the `hooks` object:
|
|
4
|
+
// {
|
|
5
|
+
// "hooks": {
|
|
6
|
+
// "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }],
|
|
7
|
+
// "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }]
|
|
8
|
+
// }
|
|
9
|
+
// }
|
|
10
|
+
//
|
|
11
|
+
// We merge into any existing hooks the user has — never clobber. Our entries
|
|
12
|
+
// are identified by a marker in the command string so we can find and remove
|
|
13
|
+
// them on uninstall.
|
|
14
|
+
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import boxen from 'boxen';
|
|
20
|
+
import inquirer from 'inquirer';
|
|
21
|
+
|
|
22
|
+
const home = os.homedir();
|
|
23
|
+
const CLAUDE_SETTINGS = path.join(home, '.claude', 'settings.json');
|
|
24
|
+
const MARKER = 'memoir'; // any command containing this is ours
|
|
25
|
+
|
|
26
|
+
const OUR_HOOKS = {
|
|
27
|
+
Stop: {
|
|
28
|
+
type: 'command',
|
|
29
|
+
command: 'memoir autopush --debounce 30',
|
|
30
|
+
},
|
|
31
|
+
SessionStart: {
|
|
32
|
+
type: 'command',
|
|
33
|
+
command: 'memoir auto-refresh',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function readSettings() {
|
|
38
|
+
if (!await fs.pathExists(CLAUDE_SETTINGS)) return {};
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(await fs.readFile(CLAUDE_SETTINGS, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function writeSettings(settings) {
|
|
47
|
+
await fs.ensureDir(path.dirname(CLAUDE_SETTINGS));
|
|
48
|
+
const tmp = `${CLAUDE_SETTINGS}.tmp-${process.pid}`;
|
|
49
|
+
await fs.writeFile(tmp, JSON.stringify(settings, null, 2));
|
|
50
|
+
await fs.move(tmp, CLAUDE_SETTINGS, { overwrite: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findOurEntry(hookList) {
|
|
54
|
+
// hookList is array of { matcher, hooks: [{type, command}] }
|
|
55
|
+
return (hookList || []).findIndex(entry =>
|
|
56
|
+
(entry.hooks || []).some(h => h.type === 'command' && (h.command || '').includes(MARKER))
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureOurHook(settings, eventName, hook) {
|
|
61
|
+
settings.hooks = settings.hooks || {};
|
|
62
|
+
settings.hooks[eventName] = settings.hooks[eventName] || [];
|
|
63
|
+
const list = settings.hooks[eventName];
|
|
64
|
+
|
|
65
|
+
const existing = findOurEntry(list);
|
|
66
|
+
if (existing >= 0) {
|
|
67
|
+
// Replace (idempotent — same shape every time)
|
|
68
|
+
list[existing] = { matcher: '', hooks: [hook] };
|
|
69
|
+
} else {
|
|
70
|
+
list.push({ matcher: '', hooks: [hook] });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeOurHook(settings, eventName) {
|
|
75
|
+
if (!settings?.hooks?.[eventName]) return false;
|
|
76
|
+
const list = settings.hooks[eventName];
|
|
77
|
+
const idx = findOurEntry(list);
|
|
78
|
+
if (idx < 0) return false;
|
|
79
|
+
list.splice(idx, 1);
|
|
80
|
+
if (list.length === 0) delete settings.hooks[eventName];
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function hooksInstallCommand(options = {}) {
|
|
85
|
+
const settings = await readSettings();
|
|
86
|
+
const fresh = JSON.parse(JSON.stringify(settings)); // snapshot for diff
|
|
87
|
+
|
|
88
|
+
ensureOurHook(fresh, 'Stop', OUR_HOOKS.Stop);
|
|
89
|
+
ensureOurHook(fresh, 'SessionStart', OUR_HOOKS.SessionStart);
|
|
90
|
+
|
|
91
|
+
const isNoop = JSON.stringify(settings) === JSON.stringify(fresh);
|
|
92
|
+
if (isNoop) {
|
|
93
|
+
console.log('\n' + chalk.green(' ✓ Hooks already installed — nothing to do.\n'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Show what will change
|
|
98
|
+
console.log('\n' + boxen(
|
|
99
|
+
chalk.cyan('memoir will add these hooks to ') + chalk.white.bold('~/.claude/settings.json') + chalk.cyan(':') + '\n\n' +
|
|
100
|
+
chalk.gray(' Stop: ') + chalk.white(OUR_HOOKS.Stop.command) + '\n' +
|
|
101
|
+
chalk.gray(' fires after every response; auto-pushes (debounced 30s)') + '\n\n' +
|
|
102
|
+
chalk.gray(' SessionStart: ') + chalk.white(OUR_HOOKS.SessionStart.command) + '\n' +
|
|
103
|
+
chalk.gray(' fires at session open; refreshes pinned block from session.json') + '\n\n' +
|
|
104
|
+
chalk.gray(' Your existing settings (including other hooks) will be preserved.'),
|
|
105
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
106
|
+
) + '\n');
|
|
107
|
+
|
|
108
|
+
if (!options.yes) {
|
|
109
|
+
const { confirm } = await inquirer.prompt([{
|
|
110
|
+
type: 'confirm',
|
|
111
|
+
name: 'confirm',
|
|
112
|
+
message: 'Install hooks?',
|
|
113
|
+
default: true,
|
|
114
|
+
}]);
|
|
115
|
+
if (!confirm) {
|
|
116
|
+
console.log(chalk.yellow('\n Cancelled.\n'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await writeSettings(fresh);
|
|
122
|
+
console.log('\n' + chalk.green(' ✓ Hooks installed. Restart Claude Code to activate.\n'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function hooksUninstallCommand(options = {}) {
|
|
126
|
+
const settings = await readSettings();
|
|
127
|
+
const fresh = JSON.parse(JSON.stringify(settings));
|
|
128
|
+
const removedStop = removeOurHook(fresh, 'Stop');
|
|
129
|
+
const removedStart = removeOurHook(fresh, 'SessionStart');
|
|
130
|
+
|
|
131
|
+
if (!removedStop && !removedStart) {
|
|
132
|
+
console.log('\n' + chalk.gray(' No memoir hooks installed.\n'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!options.yes) {
|
|
137
|
+
const { confirm } = await inquirer.prompt([{
|
|
138
|
+
type: 'confirm',
|
|
139
|
+
name: 'confirm',
|
|
140
|
+
message: 'Remove memoir hooks from ~/.claude/settings.json?',
|
|
141
|
+
default: true,
|
|
142
|
+
}]);
|
|
143
|
+
if (!confirm) {
|
|
144
|
+
console.log(chalk.yellow('\n Cancelled.\n'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await writeSettings(fresh);
|
|
150
|
+
console.log('\n' + chalk.green(' ✓ memoir hooks removed.\n'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function hooksStatusCommand() {
|
|
154
|
+
const settings = await readSettings();
|
|
155
|
+
const hasStop = (settings.hooks?.Stop || []).some(entry =>
|
|
156
|
+
(entry.hooks || []).some(h => (h.command || '').includes(MARKER))
|
|
157
|
+
);
|
|
158
|
+
const hasStart = (settings.hooks?.SessionStart || []).some(entry =>
|
|
159
|
+
(entry.hooks || []).some(h => (h.command || '').includes(MARKER))
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const mark = (b) => b ? chalk.green(' ✓ ') : chalk.gray(' ✗ ');
|
|
163
|
+
console.log('\n' + boxen(
|
|
164
|
+
chalk.cyan.bold('memoir hooks status') + '\n\n' +
|
|
165
|
+
mark(hasStop) + chalk.white('Stop hook') + chalk.gray(' (auto-push)') + '\n' +
|
|
166
|
+
mark(hasStart) + chalk.white('SessionStart hook') + chalk.gray(' (auto-refresh)') + '\n\n' +
|
|
167
|
+
chalk.gray(' Settings: ') + chalk.white(CLAUDE_SETTINGS) + '\n' +
|
|
168
|
+
chalk.gray(' Run ') + chalk.cyan('memoir hooks install') + chalk.gray(' to add missing hooks.'),
|
|
169
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
170
|
+
) + '\n');
|
|
171
|
+
}
|