@lorekit/cli 1.0.0 → 1.2.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 CHANGED
@@ -9,30 +9,72 @@ CI included. This CLI wires an agent up to it in two commands.
9
9
 
10
10
  ## Install
11
11
 
12
+ Install it globally — recommended. You get one pinned version, the `lorekit`
13
+ command stays on your `PATH`, and the plugin hooks (which call `lorekit hook`
14
+ on every lifecycle event) fire instantly instead of paying an `npx` resolution
15
+ cost each time:
16
+
17
+ ```bash
18
+ npm install -g @lorekit/cli
19
+ lorekit install
20
+ lorekit doctor
21
+ ```
22
+
23
+ Upgrade later with `npm install -g @lorekit/cli@latest`.
24
+
25
+ Prefer not to install anything? Run it on demand with `npx` — same commands,
26
+ always the latest version, fetched and cached per use:
27
+
12
28
  ```bash
13
29
  npx @lorekit/cli install
14
30
  npx @lorekit/cli doctor
15
31
  ```
16
32
 
17
- Requires Node 18+ (for the built-in `fetch`). No dependencies.
33
+ Requires Node 18+ (for the built-in `fetch`). No dependencies. Works on macOS,
34
+ Linux, and Windows (npm creates the `lorekit` shim on every platform).
18
35
 
19
36
  ## Commands
20
37
 
21
38
  ### `lorekit install`
22
39
 
23
- Scaffolds the `lorekit-memory` skill into `.claude/skills/lorekit-memory/` and
24
- adds (or updates) a `lorekit` server in the project's `.mcp.json`, preserving
25
- any other MCP servers already configured.
40
+ Sets up the full memory loop the same three parts as the Claude plugin,
41
+ without needing a marketplace:
42
+
43
+ 1. **Skill** (`lorekit-memory`) — the model-invoked authoring judgment.
44
+ 2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
45
+ MCP config (preserving any other servers).
46
+ 3. **Hooks** — the *deterministic* layer: lessons injected on every
47
+ `SessionStart`, a nudge on tool failure (`PostToolUseFailure`), and a
48
+ retrospective nudge on `Stop`. These fire the shared `lorekit hook` engine
49
+ and are merged into `settings.json` (existing hooks preserved).
50
+
51
+ It first asks **where** to install:
52
+
53
+ - **project** (default) — `.claude/skills/`, `.mcp.json`, `.claude/settings.json`.
54
+ Scoped to this repo; commit it to share with your team.
55
+ - **global** — `~/.claude/skills/`, `~/.claude.json`, `~/.claude/settings.json`.
56
+ Applies to every project you open. Your token lands in `~/.claude.json`, so
57
+ keep that file private.
26
58
 
27
59
  ```bash
28
60
  lorekit install \
29
- --endpoint https://<project-ref>.supabase.co/functions/v1/mcp \
61
+ --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp \
30
62
  --token lk_rw_your_token
63
+
64
+ lorekit install --global # set it up for every project
31
65
  ```
32
66
 
33
- If run in a TTY without `--endpoint` / `--token`, it prompts for them.
34
- Use `--yes` for non-interactive environments (endpoint required via flag/env).
35
- Use `--force` to overwrite an existing skill copy.
67
+ In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
68
+ Flags: `--project` / `--global` pick the scope non-interactively; `--no-hooks`
69
+ installs the skill + MCP only (memory stays model-invoked); `--yes` runs
70
+ non-interactively (endpoint required via flag/env; scope defaults to project);
71
+ `--force` overwrites an existing skill copy. Re-running is idempotent — the hook
72
+ entries are updated in place, never duplicated.
73
+
74
+ > The hook command uses a global `lorekit` when one is on your `PATH` (fast),
75
+ > otherwise `npx -y @lorekit/cli`. Installing the CLI globally
76
+ > (`npm i -g @lorekit/cli`) is recommended so hooks fire without an npx
77
+ > resolution each time.
36
78
 
37
79
  ### `lorekit doctor`
38
80
 
@@ -46,7 +88,7 @@ Verifies the setup and prints a status report:
46
88
  gitignored
47
89
  - for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not
48
90
  the `<project-ref>` placeholder), the token and its permission tier
49
- (`lk_rw_*` vs `lk_ro_*`), and that the endpoint is reachable
91
+ (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), and that the endpoint is reachable
50
92
  - for `off`: a note that memory is disabled
51
93
  - the git-derived read/write scopes for the current directory
52
94
 
@@ -226,6 +268,8 @@ active deny constraints.
226
268
  | Flag | Meaning |
227
269
  |------|---------|
228
270
  | `-d, --dir <path>` | Target project root (default: cwd) |
271
+ | `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default) |
272
+ | `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`) |
229
273
  | `-e, --endpoint <url>` | LoreKit MCP endpoint |
230
274
  | `-t, --token <token>` | LoreKit token |
231
275
  | `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
@@ -234,6 +278,7 @@ active deny constraints.
234
278
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
235
279
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
236
280
  | `-y, --yes` | Non-interactive / apply; never prompt |
281
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
237
282
  | `--force` | Overwrite existing skill files (`install`) |
238
283
  | `--deep` | Write/read/delete round-trip (`doctor`) |
239
284
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
package/bin/lorekit.mjs CHANGED
@@ -1,14 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // LoreKit CLI — install the shared-memory skill and run health checks.
3
3
  import process from 'node:process';
4
+ import { readFileSync } from 'node:fs';
4
5
  import { parseArgs, log, err, c } from '../src/util.mjs';
5
6
  import { install } from '../src/install.mjs';
7
+ import { uninstall } from '../src/uninstall.mjs';
6
8
  import { doctor } from '../src/doctor.mjs';
7
9
  import { hook } from '../src/hook.mjs';
8
10
  import { migrate } from '../src/migrate.mjs';
9
11
  import { mcpServer } from '../src/mcp-server.mjs';
10
12
 
11
- const VERSION = '1.0.0';
13
+ // Read the version from package.json so it always matches the published
14
+ // package — release-please bumps package.json, and this tracks it for free.
15
+ const VERSION = JSON.parse(
16
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
17
+ ).version;
12
18
 
13
19
  const HELP = `${c.bold('lorekit')} — shared persistent memory for coding agents
14
20
 
@@ -16,8 +22,16 @@ ${c.bold('Usage')}
16
22
  npx @lorekit/cli <command> [options]
17
23
 
18
24
  ${c.bold('Commands')}
19
- install Scaffold the lorekit-memory skill into .claude/skills and
20
- add the LoreKit server to .mcp.json.
25
+ install Scaffold the lorekit-memory skill, wire the LoreKit MCP server,
26
+ and install the deterministic hooks (lessons on SessionStart, a
27
+ nudge on tool failure + at Stop). Prompts to install for this
28
+ project (.claude) or globally for every project (~/.claude);
29
+ --project / --global choose non-interactively, --no-hooks skips
30
+ the hooks (skill stays model-invoked only).
31
+ uninstall Reverse install: remove the lorekit-memory skill, the MCP server
32
+ entry, and the lifecycle hooks for the chosen scope. Surgical —
33
+ other servers, hooks, and settings are left untouched. Prompts
34
+ project vs global; --project / --global choose non-interactively.
21
35
  doctor Verify the skill install, MCP connectivity, token, and scope.
22
36
  migrate Relocate a LoreKit-format local store into the current layout.
23
37
  Dry-run by default; pass --yes to apply. Idempotent.
@@ -31,6 +45,8 @@ ${c.bold('Commands')}
31
45
 
32
46
  ${c.bold('Options')}
33
47
  -d, --dir <path> Target project root (default: current directory)
48
+ --project Install into this project: .claude/skills + .mcp.json (default)
49
+ --global Install for every project: ~/.claude/skills + ~/.claude.json
34
50
  -e, --endpoint <url> LoreKit MCP endpoint
35
51
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
36
52
  --mode <mode> Memory mode: off | local | remote (doctor override)
@@ -40,6 +56,7 @@ ${c.bold('Options')}
40
56
  default routes each entry by scope)
41
57
  --apply Apply the migration (alias of --yes) (migrate)
42
58
  -y, --yes Non-interactive / apply; never prompt
59
+ --no-hooks Skip wiring the lifecycle hooks (install)
43
60
  --force Overwrite existing skill files (install)
44
61
  --deep Do a write→read→delete round-trip (doctor)
45
62
  --adapter <name> Host framework for hook: claude | cursor | codex
@@ -58,6 +75,8 @@ ${c.bold('Environment')}
58
75
 
59
76
  ${c.bold('Examples')}
60
77
  npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
78
+ npx @lorekit/cli install --global # set up memory for every project (~/.claude)
79
+ npx @lorekit/cli uninstall --global # tear that global setup back down
61
80
  npx @lorekit/cli doctor --deep
62
81
  npx @lorekit/cli migrate --from .lore # preview a rename
63
82
  npx @lorekit/cli migrate --from .lore --to project --yes
@@ -67,7 +86,7 @@ async function main() {
67
86
  const argv = process.argv.slice(2);
68
87
  const args = parseArgs(argv, {
69
88
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
70
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version'],
89
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks'],
71
90
  });
72
91
 
73
92
  // `hook` is machine-facing: it must never print help/errors to stdout
@@ -98,6 +117,8 @@ async function main() {
98
117
  switch (command) {
99
118
  case 'install':
100
119
  return install(args);
120
+ case 'uninstall':
121
+ return uninstall(args);
101
122
  case 'doctor':
102
123
  return doctor(args);
103
124
  case 'migrate':
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/mthines/lorekit"
9
+ },
6
10
  "type": "module",
7
11
  "bin": {
8
12
  "lorekit": "bin/lorekit.mjs"
@@ -33,4 +37,4 @@
33
37
  "doctor": "node bin/lorekit.mjs doctor",
34
38
  "test": "node --test test/*.test.mjs"
35
39
  }
36
- }
40
+ }
@@ -114,8 +114,9 @@ Full resolution rules: [references/scope-resolution.md](./references/scope-resol
114
114
  | `memory.read` | Read one lesson by scope + key | read |
115
115
  | `memory.write` | Store or update a lesson (same scope+key updates in place) | read+write |
116
116
 
117
- Write tools need an `lk_rw_*` token; read tools accept `lk_rw_*` or `lk_ro_*`.
118
- A read-only token cannot write if a write fails with an authorization error,
117
+ Write tools need write permission (`lk_rw_*` or `lk_wo_*`); read tools need read
118
+ permission (`lk_rw_*` or `lk_ro_*`). A read-only token cannot write and a
119
+ write-only token cannot read — if a call fails with an authorization error,
119
120
  report it and move on; do not retry.
120
121
 
121
122
  Every lesson this skill writes carries the tag `skill::lorekit-memory` plus a
package/src/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Project layout + .mcp.json read/merge helpers.
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -12,12 +13,169 @@ export function resolveProjectRoot(dir) {
12
13
  return path.resolve(dir || process.cwd());
13
14
  }
14
15
 
16
+ // Atomic config write: serialize to a sibling temp file, then rename over the
17
+ // target. rename(2) is atomic within a filesystem, so a crash / Ctrl-C / ENOSPC
18
+ // mid-write can never leave a half-written (corrupt) file — the original stays
19
+ // intact until the complete new content is swapped in. This matters most for
20
+ // ~/.claude.json, which can be large and holds all of Claude Code's per-project
21
+ // state and OAuth tokens. The temp file inherits the target's permissions when
22
+ // it exists (so we don't widen a locked-down 0600 config to 0644 on replace).
23
+ export function writeFileAtomic(file, data) {
24
+ const dir = path.dirname(file);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
27
+ try {
28
+ fs.writeFileSync(tmp, data);
29
+ try {
30
+ fs.chmodSync(tmp, fs.statSync(file).mode);
31
+ } catch {
32
+ /* target didn't exist — leave the temp file's default perms */
33
+ }
34
+ fs.renameSync(tmp, file);
35
+ } catch (e) {
36
+ try {
37
+ fs.rmSync(tmp, { force: true });
38
+ } catch {
39
+ /* best-effort cleanup of the temp file */
40
+ }
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ // The user's home directory. Honors $HOME / %USERPROFILE% (so it can be
46
+ // redirected in tests) and falls back to the OS lookup.
47
+ export function homeDir() {
48
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
49
+ }
50
+
15
51
  export function mcpJsonPath(root) {
16
52
  return path.join(root, '.mcp.json');
17
53
  }
18
54
 
19
- export function skillInstallDir(root) {
20
- return path.join(root, '.claude', 'skills', SKILL_NAME);
55
+ // Where the MCP server entry is written for a given scope:
56
+ // project → <root>/.mcp.json (Claude Code project config)
57
+ // global → ~/.claude.json (Claude Code user config, all projects)
58
+ export function mcpConfigPath(root, scope = 'project') {
59
+ return scope === 'global' ? path.join(homeDir(), '.claude.json') : mcpJsonPath(root);
60
+ }
61
+
62
+ // Where the skill is scaffolded for a given scope:
63
+ // project → <root>/.claude/skills/… (this repo only)
64
+ // global → ~/.claude/skills/… (personal skills, all projects)
65
+ export function skillInstallDir(root, scope = 'project') {
66
+ const base = scope === 'global' ? homeDir() : root;
67
+ return path.join(base, '.claude', 'skills', SKILL_NAME);
68
+ }
69
+
70
+ // Claude Code settings file that holds the hooks for a given scope:
71
+ // project → <root>/.claude/settings.json
72
+ // global → ~/.claude/settings.json
73
+ export function settingsPath(root, scope = 'project') {
74
+ const base = scope === 'global' ? homeDir() : root;
75
+ return path.join(base, '.claude', 'settings.json');
76
+ }
77
+
78
+ // The lifecycle events the memory loop wires: read lessons on start, nudge on a
79
+ // tool failure, nudge a retrospective at end of turn. Mirrors the plugin's
80
+ // hooks.json so `install` delivers the same deterministic layer.
81
+ export const CLAUDE_HOOK_EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
82
+
83
+ // Matches a hook command that fires the lorekit engine, whether wired as a
84
+ // global `lorekit hook …` or `npx -y @lorekit/cli hook …`. Shared by the
85
+ // upsert (find-or-update) and remove (uninstall) paths so they agree on what
86
+ // counts as "ours".
87
+ export const LOREKIT_HOOK_RE = /(?:@lorekit\/cli|lorekit) hook\b/;
88
+
89
+ // npx stages the package's own bin into an ephemeral cache dir
90
+ // (…/_npx/<hash>/node_modules/.bin) and prepends it to PATH for the lifetime of
91
+ // the `npx @lorekit/cli …` process. That makes `lorekit` *look* globally
92
+ // installed during `install`, so the hooks get wired as bare `lorekit hook …`
93
+ // — but the symlink vanishes when npx exits, and Claude Code then fails every
94
+ // hook with `lorekit: command not found`. Excluding these transient dirs keeps
95
+ // the bare-`lorekit` runner reserved for a genuine global install.
96
+ const isEphemeralNpxDir = (dir) => /[\\/]_npx[\\/]/.test(dir);
97
+
98
+ // Is `bin` resolvable on a *durable* PATH entry? Used to prefer a fast global
99
+ // `lorekit` over `npx` for hook commands. Zero-dep, cross-platform.
100
+ export function onPath(bin) {
101
+ const dirs = (process.env.PATH || '').split(path.delimiter);
102
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
103
+ for (const dir of dirs) {
104
+ if (!dir || isEphemeralNpxDir(dir)) continue;
105
+ for (const ext of exts) {
106
+ try {
107
+ if (fs.existsSync(path.join(dir, bin + ext))) return true;
108
+ } catch {
109
+ /* ignore unreadable PATH entries */
110
+ }
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // The command prefix for hook entries: a global `lorekit` when it's installed
117
+ // (fast — no npx resolution per event), else `npx -y @lorekit/cli`.
118
+ export function resolveHookRunner() {
119
+ return onPath('lorekit') ? 'lorekit' : 'npx -y @lorekit/cli';
120
+ }
121
+
122
+ // Read the lorekit MCP server entry out of an arbitrary config file.
123
+ function readServerFromFile(file) {
124
+ if (!fs.existsSync(file)) return null;
125
+ let config;
126
+ try {
127
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ } catch {
129
+ return null;
130
+ }
131
+ const server = config && config.mcpServers && config.mcpServers.lorekit;
132
+ if (!server) return null;
133
+ const args = Array.isArray(server.args) ? server.args : [];
134
+ const url = args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
135
+ return { server, url: url || null };
136
+ }
137
+
138
+ // Wire the lorekit hook engine into Claude Code settings for the scope,
139
+ // preserving all other settings and any non-lorekit hooks. Idempotent: an
140
+ // existing lorekit hook entry per event is updated in place, never duplicated.
141
+ // `runner` is the command prefix (e.g. 'lorekit' or 'npx -y @lorekit/cli').
142
+ export function upsertClaudeHooks(root, scope, runner) {
143
+ const file = settingsPath(root, scope);
144
+ const config = readJsonIfExists(file) || {};
145
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
146
+
147
+ let added = 0;
148
+ let updated = 0;
149
+ let unchanged = 0;
150
+
151
+ for (const event of CLAUDE_HOOK_EVENTS) {
152
+ const command = `${runner} hook --adapter claude --event ${event} --dir "\${CLAUDE_PROJECT_DIR}"`;
153
+ if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
154
+ const groups = config.hooks[event];
155
+
156
+ let existing = null;
157
+ for (const group of groups) {
158
+ const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
159
+ existing = inner.find(
160
+ (h) => h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command),
161
+ );
162
+ if (existing) break;
163
+ }
164
+
165
+ if (existing) {
166
+ if (existing.command === command) unchanged++;
167
+ else {
168
+ existing.command = command;
169
+ updated++;
170
+ }
171
+ } else {
172
+ groups.push({ hooks: [{ type: 'command', command }] });
173
+ added++;
174
+ }
175
+ }
176
+
177
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
178
+ return { file, added, updated, unchanged };
21
179
  }
22
180
 
23
181
  // Throwing read — used by `install` so a corrupt .mcp.json aborts the write
@@ -44,9 +202,10 @@ export function readMcpConfig(root) {
44
202
  }
45
203
  }
46
204
 
47
- // Merge a lorekit server entry into .mcp.json, preserving any other servers.
48
- export function upsertMcpServer(root, remoteUrl) {
49
- const file = mcpJsonPath(root);
205
+ // Merge a lorekit server entry into the scope's MCP config, preserving any
206
+ // other servers (and, for the global ~/.claude.json, all other user settings).
207
+ export function upsertMcpServer(root, remoteUrl, scope = 'project') {
208
+ const file = mcpConfigPath(root, scope);
50
209
  const config = readJsonIfExists(file) || {};
51
210
  if (!config.mcpServers || typeof config.mcpServers !== 'object') {
52
211
  config.mcpServers = {};
@@ -56,20 +215,80 @@ export function upsertMcpServer(root, remoteUrl) {
56
215
  command: 'npx',
57
216
  args: ['-y', 'mcp-remote', remoteUrl],
58
217
  };
59
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
218
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
60
219
  return { file, existed };
61
220
  }
62
221
 
63
- // Pull the configured lorekit remote URL out of .mcp.json, if present.
64
- // Non-throwing: returns null when the file is absent, invalid, or has no
65
- // lorekit server. Callers that need to distinguish those use readMcpConfig.
222
+ // Pull the configured lorekit remote URL out of the project .mcp.json, if
223
+ // present. Non-throwing: returns null when the file is absent, invalid, or has
224
+ // no lorekit server. Callers that need to distinguish those use readMcpConfig.
66
225
  export function readLorekitServer(root) {
67
- const { config } = readMcpConfig(root);
68
- const server = config && config.mcpServers && config.mcpServers.lorekit;
69
- if (!server) return null;
70
- const args = Array.isArray(server.args) ? server.args : [];
71
- const url = args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
72
- return { server, url: url || null };
226
+ return readServerFromFile(mcpJsonPath(root));
227
+ }
228
+
229
+ // --- uninstall: surgical removal of the three things `install` writes. -------
230
+ // Each helper touches only lorekit's own entries and leaves every other
231
+ // server / hook / setting intact, so uninstalling never damages a shared
232
+ // ~/.claude.json or settings.json.
233
+
234
+ // Delete the scaffolded skill directory. We own the whole
235
+ // .claude/skills/lorekit-memory tree, so a recursive remove is safe.
236
+ export function removeSkill(root, scope = 'project') {
237
+ const dest = skillInstallDir(root, scope);
238
+ const removed = fs.existsSync(dest);
239
+ if (removed) fs.rmSync(dest, { recursive: true, force: true });
240
+ return { dest, removed };
241
+ }
242
+
243
+ // Drop the `lorekit` MCP server entry, preserving any other servers (and, for
244
+ // the global ~/.claude.json, all other user settings). Prunes an emptied
245
+ // mcpServers object. No-op (no write) when there's nothing to remove.
246
+ export function removeMcpServer(root, scope = 'project') {
247
+ const file = mcpConfigPath(root, scope);
248
+ const config = readJsonIfExists(file);
249
+ const hasEntry =
250
+ config &&
251
+ config.mcpServers &&
252
+ typeof config.mcpServers === 'object' &&
253
+ Object.prototype.hasOwnProperty.call(config.mcpServers, 'lorekit');
254
+ if (!hasEntry) return { file, removed: false };
255
+
256
+ delete config.mcpServers.lorekit;
257
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
258
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
259
+ return { file, removed: true };
260
+ }
261
+
262
+ // Strip lorekit hook entries from every event, preserving non-lorekit hooks in
263
+ // the same groups. Prunes groups left with no hooks and events left with no
264
+ // groups. Returns the count removed; only writes when something changed.
265
+ export function removeClaudeHooks(root, scope = 'project') {
266
+ const file = settingsPath(root, scope);
267
+ const config = readJsonIfExists(file);
268
+ if (!config || !config.hooks || typeof config.hooks !== 'object') {
269
+ return { file, removed: 0 };
270
+ }
271
+
272
+ let removed = 0;
273
+ for (const event of Object.keys(config.hooks)) {
274
+ const groups = config.hooks[event];
275
+ if (!Array.isArray(groups)) continue;
276
+ for (const group of groups) {
277
+ if (!group || !Array.isArray(group.hooks)) continue;
278
+ const before = group.hooks.length;
279
+ group.hooks = group.hooks.filter(
280
+ (h) => !(h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command)),
281
+ );
282
+ removed += before - group.hooks.length;
283
+ }
284
+ // Drop groups we emptied, then the event key if it has no groups left.
285
+ config.hooks[event] = groups.filter((g) => g && Array.isArray(g.hooks) && g.hooks.length > 0);
286
+ if (config.hooks[event].length === 0) delete config.hooks[event];
287
+ }
288
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
289
+
290
+ if (removed > 0) writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
291
+ return { file, removed };
73
292
  }
74
293
 
75
294
  // Recursively copy the skill source into the target, skipping files that
@@ -111,21 +330,25 @@ export function tokenKind(token) {
111
330
  if (!token) return 'none';
112
331
  if (token.startsWith('lk_rw_')) return 'read-write';
113
332
  if (token.startsWith('lk_ro_')) return 'read-only';
333
+ if (token.startsWith('lk_wo_')) return 'write-only';
114
334
  return 'unknown';
115
335
  }
116
336
 
117
- // For hooks: resolve the connection from the project's .mcp.json first
118
- // (that is where `lorekit install` wrote the token), then fall back to env.
119
- // `splitEndpoint` is passed in to avoid a circular import with mcp.mjs.
337
+ // For hooks: resolve the connection closest-scope first — the project's
338
+ // .mcp.json (a project install), then the global ~/.claude.json (a global
339
+ // install), then env. `splitEndpoint` is passed in to avoid a circular import
340
+ // with mcp.mjs.
120
341
  export function resolveProjectConnection(root, splitEndpoint) {
121
- const configured = readLorekitServer(root);
122
- if (configured && configured.url) {
123
- const { endpoint, token } = splitEndpoint(configured.url);
124
- if (endpoint && !endpoint.includes('<project-ref>')) {
125
- return {
126
- endpoint,
127
- token: token || process.env.LOREKIT_TOKEN || null,
128
- };
342
+ const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))];
343
+ for (const configured of sources) {
344
+ if (configured && configured.url) {
345
+ const { endpoint, token } = splitEndpoint(configured.url);
346
+ if (endpoint && !endpoint.includes('<project-ref>')) {
347
+ return {
348
+ endpoint,
349
+ token: token || process.env.LOREKIT_TOKEN || null,
350
+ };
351
+ }
129
352
  }
130
353
  }
131
354
  return resolveConnection({});
package/src/doctor.mjs CHANGED
@@ -41,10 +41,16 @@ export async function doctor(args) {
41
41
  `v${process.versions.node}${major < 18 ? ' — need v18+ for fetch' : ''}`,
42
42
  );
43
43
 
44
- // 2. Skill installed.
45
- const skillMd = path.join(skillInstallDir(root), 'SKILL.md');
46
- if (fs.existsSync(skillMd)) {
47
- record('pass', `skill ${SKILL_NAME}`, path.relative(root, skillMd) || skillMd);
44
+ // 2. Skill installed — check BOTH the project and the global (~/.claude)
45
+ // locations. `lorekit install --global` writes the skill under home, not the
46
+ // repo, so a project-only check reports a healthy global install as "not
47
+ // found" (exactly the false FAIL a --global setup would hit).
48
+ const skillMd = [skillInstallDir(root, 'project'), skillInstallDir(root, 'global')]
49
+ .map((dir) => path.join(dir, 'SKILL.md'))
50
+ .find((p) => fs.existsSync(p));
51
+ if (skillMd) {
52
+ const rel = path.relative(root, skillMd);
53
+ record('pass', `skill ${SKILL_NAME}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
48
54
  } else {
49
55
  record('fail', `skill ${SKILL_NAME}`, 'not found — run `lorekit install`');
50
56
  }
@@ -163,7 +169,8 @@ async function checkRemote(control, root, args, record) {
163
169
  const kind = tokenKind(token);
164
170
  if (kind === 'none') record('fail', 'token', 'none configured');
165
171
  else if (kind === 'read-only') record('warn', 'token', 'read-only (lk_ro_*) — reads only, no writes');
166
- else if (kind === 'unknown') record('warn', 'token', 'unrecognized prefix (expected lk_rw_* / lk_ro_*)');
172
+ else if (kind === 'write-only') record('warn', 'token', 'write-only (lk_wo_*) writes only, no reads');
173
+ else if (kind === 'unknown') record('warn', 'token', 'unrecognized prefix (expected lk_rw_* / lk_ro_* / lk_wo_*)');
167
174
  else record('pass', 'token', 'read+write (lk_rw_*)');
168
175
 
169
176
  // Connectivity, through the store.
package/src/install.mjs CHANGED
@@ -10,19 +10,23 @@ import {
10
10
  skillInstallDir,
11
11
  copyDir,
12
12
  upsertMcpServer,
13
+ upsertClaudeHooks,
14
+ resolveHookRunner,
15
+ CLAUDE_HOOK_EVENTS,
13
16
  resolveConnection,
14
17
  tokenKind,
18
+ homeDir,
15
19
  } from './config.mjs';
16
20
  import { buildRemoteUrl } from './mcp.mjs';
17
21
  import { deriveScope } from './scope.mjs';
18
- import { log, err, heading, status, c } from './util.mjs';
22
+ import { log, err, heading, status, select, c } from './util.mjs';
19
23
 
20
24
  function ask(question) {
21
25
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
22
26
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
23
27
  }
24
28
 
25
- const DEFAULT_ENDPOINT_HINT = 'https://<project-ref>.supabase.co/functions/v1/mcp';
29
+ const DEFAULT_ENDPOINT_HINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
26
30
 
27
31
  export async function install(args) {
28
32
  const root = resolveProjectRoot(args.dir);
@@ -31,7 +35,26 @@ export async function install(args) {
31
35
  heading('LoreKit install');
32
36
  log(` project: ${c.dim(root)}`);
33
37
 
34
- // 1. Connection details.
38
+ // 1. Scope: this project, or user-global (every project). --global / --project
39
+ // force it; otherwise prompt when interactive, else default to project.
40
+ let scope = args.global ? 'global' : args.project ? 'project' : null;
41
+ if (!scope) {
42
+ if (nonInteractive) {
43
+ scope = 'project';
44
+ } else {
45
+ scope = await select('Install LoreKit for…', [
46
+ { label: 'This project', value: 'project', hint: 'this repo only (.claude, .mcp.json)' },
47
+ { label: 'All projects (global)', value: 'global', hint: 'every project (~/.claude)' },
48
+ ]);
49
+ }
50
+ }
51
+ log(
52
+ ` install: ${c.dim(
53
+ scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
54
+ )}`,
55
+ );
56
+
57
+ // 2. Connection details.
35
58
  let { endpoint, token } = resolveConnection(args);
36
59
 
37
60
  if (!endpoint) {
@@ -50,46 +73,81 @@ export async function install(args) {
50
73
  token = token || null;
51
74
  }
52
75
 
53
- // 2. Install the skill files.
54
- const dest = skillInstallDir(root);
76
+ // 3. Install the skill files.
77
+ const dest = skillInstallDir(root, scope);
55
78
  const skillExisted = fs.existsSync(path.join(dest, 'SKILL.md'));
56
79
  const written = copyDir(SKILL_SOURCE, dest, { force: Boolean(args.force) });
57
80
 
58
- // 3. Wire .mcp.json.
81
+ // 4. Wire the MCP config for the chosen scope.
59
82
  const remoteUrl = buildRemoteUrl(endpoint, token);
60
- const { file, existed } = upsertMcpServer(root, remoteUrl);
83
+ const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
84
+
85
+ // 4b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
86
+ // Claude plugin adds on top of the skill: lessons injected on every
87
+ // SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
88
+ // firing the shared `lorekit hook` engine, which reads the same config.
89
+ const wireHooks = !args['no-hooks'];
90
+ let hooks = null;
91
+ if (wireHooks) {
92
+ hooks = upsertClaudeHooks(root, scope, resolveHookRunner());
93
+ }
94
+
95
+ // Show global paths relative to ~ (a repo-relative path would be a mess of
96
+ // ../../); project paths stay repo-relative.
97
+ const display = (p) =>
98
+ scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
99
+ const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
61
100
 
62
- // 4. Report.
101
+ // 5. Report.
63
102
  heading('Done');
64
103
  const skillState = !skillExisted
65
104
  ? 'installed'
66
105
  : written > 0
67
106
  ? `updated (${written} file(s) written)`
68
107
  : 'unchanged — pass --force to overwrite';
69
- status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${path.relative(root, dest) || dest}`);
70
- status('pass', '.mcp.json', `${existed ? 'updated' : 'created'} lorekit server → ${path.relative(root, file) || file}`);
108
+ status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${display(dest)}`);
109
+ status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
110
+
111
+ if (!wireHooks) {
112
+ status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but lessons are model-invoked only');
113
+ } else {
114
+ const n = hooks.added + hooks.updated;
115
+ const hookState =
116
+ n === 0
117
+ ? 'unchanged — already wired'
118
+ : `${hooks.added ? `${hooks.added} added` : ''}${hooks.added && hooks.updated ? ', ' : ''}${hooks.updated ? `${hooks.updated} updated` : ''}`;
119
+ status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
120
+ }
71
121
 
72
122
  const kind = tokenKind(token);
73
123
  if (kind === 'none') {
74
124
  status('warn', 'token', 'none configured — reads/writes will fail until a token is set');
75
125
  } else if (kind === 'read-only') {
76
126
  status('warn', 'token', 'read-only (lk_ro_*) — the skill can read lessons but not write them');
127
+ } else if (kind === 'write-only') {
128
+ status('warn', 'token', 'write-only (lk_wo_*) — the skill can write lessons but not read them');
77
129
  } else if (kind === 'unknown') {
78
- status('warn', 'token', 'unrecognized prefix — expected lk_rw_* or lk_ro_*');
130
+ status('warn', 'token', 'unrecognized prefix — expected lk_rw_*, lk_ro_*, or lk_wo_*');
79
131
  } else {
80
132
  status('pass', 'token', 'read+write (lk_rw_*)');
81
133
  }
82
134
 
83
- const scope = deriveScope(root);
84
- if (scope.hasRemote) {
85
- status('info', 'scope', `${scope.repoScope}${scope.branchScope ? ` · ${scope.branchScope}` : ''}`);
135
+ const gitScope = deriveScope(root);
136
+ if (gitScope.hasRemote) {
137
+ status('info', 'scope', `${gitScope.repoScope}${gitScope.branchScope ? ` · ${gitScope.branchScope}` : ''}`);
86
138
  } else {
87
139
  status('warn', 'scope', 'no git remote — lessons will fall back to global');
88
140
  }
89
141
 
90
142
  log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
91
143
  if (token) {
92
- log(` ${c.dim('Note: your token now lives in .mcp.json — keep it out of version control.')}`);
144
+ log(
145
+ ` ${c.dim(
146
+ scope === 'global'
147
+ ? 'Note: your token now lives in ~/.claude.json (used by every project) — keep that file private.'
148
+ : 'Note: your token now lives in .mcp.json — keep it out of version control.',
149
+ )}`,
150
+ );
93
151
  }
94
152
  return 0;
95
153
  }
@@ -30,7 +30,24 @@ export const TOOL_DEFS = [
30
30
  {
31
31
  name: 'memory.write',
32
32
  description: 'Store or update a lesson',
33
- inputSchema: { type: 'object', required: ['scope', 'key', 'value'] },
33
+ inputSchema: {
34
+ type: 'object',
35
+ required: ['scope', 'key', 'value'],
36
+ properties: {
37
+ scope: { type: 'string' },
38
+ key: { type: 'string' },
39
+ value: { type: 'string' },
40
+ tags: { type: 'array', items: { type: 'string' } },
41
+ source_agent: { type: 'string' },
42
+ trigger: { type: 'string' },
43
+ created_at: {
44
+ type: 'string',
45
+ format: 'date-time',
46
+ description:
47
+ 'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
48
+ },
49
+ },
50
+ },
34
51
  },
35
52
  {
36
53
  name: 'memory.read',
@@ -0,0 +1,28 @@
1
+ // Zero-dependency mirror of the created_at validation used by the hosted MCP
2
+ // server (packages/mcp-core/src/created-at.ts). Keeps the local `lorekit mcp`
3
+ // stdio server's memory.write contract identical to the remote one: an optional
4
+ // ISO 8601 creation-date override, rejected when invalid or future-dated.
5
+
6
+ export const CLOCK_SKEW_MS = 60_000;
7
+
8
+ // Validate and normalise an optional created_at override.
9
+ // Returns the ISO 8601 string, or null when no override was supplied.
10
+ // Throws Error on an invalid or future-dated value.
11
+ export function normalizeCreatedAt(input, now = new Date()) {
12
+ if (input === undefined || input === null) return null;
13
+ if (typeof input !== 'string') {
14
+ throw new Error('created_at must be an ISO 8601 date-time string');
15
+ }
16
+ const trimmed = input.trim();
17
+ if (trimmed === '') {
18
+ throw new Error('created_at must be an ISO 8601 date-time string');
19
+ }
20
+ const ms = Date.parse(trimmed);
21
+ if (Number.isNaN(ms)) {
22
+ throw new Error(`created_at is not a valid date-time: ${input}`);
23
+ }
24
+ if (ms > now.getTime() + CLOCK_SKEW_MS) {
25
+ throw new Error('created_at cannot be in the future');
26
+ }
27
+ return new Date(ms).toISOString();
28
+ }
@@ -8,6 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
11
+ import { normalizeCreatedAt } from './created-at.mjs';
11
12
 
12
13
  export function createLocalStore(baseDir) {
13
14
  return new LocalStore(baseDir);
@@ -82,19 +83,34 @@ class LocalStore {
82
83
 
83
84
  // write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
84
85
  // refreshes `updated`; writing an archived key revives it.
85
- async write({ scope, key, value, tags, source_agent, trigger } = {}) {
86
+ //
87
+ // `created_at` is an optional ISO 8601 override for migrating a pre-existing
88
+ // memory (mirrors the hosted memory.write param). It applies only when the
89
+ // entry is first created — on both `created` and `updated`, so a migrated
90
+ // memory is dated by its original time everywhere — and is ignored for an
91
+ // existing key (a creation date never moves). Returns { ok:false, error } on
92
+ // an invalid or future-dated value rather than throwing, matching the store
93
+ // contract's error surfacing.
94
+ async write({ scope, key, value, tags, source_agent, trigger, created_at } = {}) {
95
+ let override;
96
+ try {
97
+ override = normalizeCreatedAt(created_at);
98
+ } catch (e) {
99
+ return { ok: false, error: e.message };
100
+ }
86
101
  const dir = this._dir(scope);
87
102
  fs.mkdirSync(dir, { recursive: true });
88
103
  const now = new Date().toISOString();
89
104
  const existing = this._findByKey(scope, key);
105
+ const created = existing ? existing.entry.created || now : override || now;
90
106
  const entry = {
91
107
  scope,
92
108
  key,
93
109
  tags: Array.isArray(tags) ? tags : [],
94
110
  source_agent: source_agent || null,
95
111
  trigger: trigger || null,
96
- created: existing ? existing.entry.created || now : now,
97
- updated: now,
112
+ created,
113
+ updated: existing ? now : override || now,
98
114
  archived_at: null,
99
115
  value: value == null ? '' : String(value),
100
116
  };
@@ -0,0 +1,114 @@
1
+ // `lorekit uninstall` — reverse `install`: remove the skill, the MCP server
2
+ // entry, and the lifecycle hooks for the chosen scope. Surgical — every other
3
+ // server, hook, and setting is left untouched.
4
+ import path from 'node:path';
5
+ import readline from 'node:readline';
6
+ import process from 'node:process';
7
+ import {
8
+ SKILL_NAME,
9
+ resolveProjectRoot,
10
+ removeSkill,
11
+ removeMcpServer,
12
+ removeClaudeHooks,
13
+ homeDir,
14
+ } from './config.mjs';
15
+ import { log, heading, status, c } from './util.mjs';
16
+
17
+ function ask(question) {
18
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
19
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
20
+ }
21
+
22
+ export async function uninstall(args) {
23
+ const root = resolveProjectRoot(args.dir);
24
+ const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
25
+
26
+ heading('LoreKit uninstall');
27
+ log(` project: ${c.dim(root)}`);
28
+
29
+ // Mirror install's scope selection: --project / --global force it, else
30
+ // prompt when interactive, else default to project.
31
+ let scope = args.global ? 'global' : args.project ? 'project' : null;
32
+ if (!scope) {
33
+ if (nonInteractive) {
34
+ scope = 'project';
35
+ } else {
36
+ const ans = (
37
+ await ask(' Remove from this project or the global install? [project/global] (project): ')
38
+ ).toLowerCase();
39
+ scope = ans.startsWith('g') ? 'global' : 'project';
40
+ }
41
+ }
42
+ log(
43
+ ` scope: ${c.dim(
44
+ scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
45
+ )}`,
46
+ );
47
+
48
+ // Each removal is independent and best-effort: a corrupt or unwritable config
49
+ // for one target must not crash the run or block the others. `removeMcpServer`
50
+ // / `removeClaudeHooks` throw on an unparseable file (the same fail-safe as
51
+ // install — never clobber what we can't understand), so we catch and report it
52
+ // cleanly, leaving that file byte-for-byte untouched. Atomic writes
53
+ // (writeFileAtomic) guarantee a config can't be half-written even on a crash.
54
+ const skill = attempt(() => removeSkill(root, scope));
55
+ const mcp = attempt(() => removeMcpServer(root, scope));
56
+ const hooks = attempt(() => removeClaudeHooks(root, scope));
57
+
58
+ // Global paths shown relative to ~; project paths repo-relative.
59
+ const display = (p) =>
60
+ scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
61
+ const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
62
+
63
+ heading('Done');
64
+ report(skill, `skill ${SKILL_NAME}`, {
65
+ done: (r) => `removed → ${display(r.dest)}`,
66
+ noop: 'not installed — nothing to remove',
67
+ });
68
+ report(mcp, mcpLabel, {
69
+ done: (r) => `lorekit server removed → ${display(r.file)}`,
70
+ noop: 'no lorekit server entry — nothing to remove',
71
+ });
72
+ report(hooks, 'hooks', {
73
+ done: (r) => `${r.removed} removed → ${display(r.file)}`,
74
+ noop: 'no lorekit hooks — nothing to remove',
75
+ });
76
+
77
+ const failed = [skill, mcp, hooks].some((s) => !s.ok);
78
+ const any = (skill.result?.removed || mcp.result?.removed || hooks.result?.removed) && true;
79
+
80
+ if (failed) {
81
+ log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
82
+ return 1;
83
+ }
84
+ if (!any) {
85
+ const other = scope === 'global' ? '--project' : '--global';
86
+ log(`\n ${c.dim(`Nothing found for the ${scope} scope — try ${other}?`)}`);
87
+ } else {
88
+ log(`\n ${c.dim('Removed. Your other MCP servers, hooks, and settings were left untouched.')}`);
89
+ log(` ${c.dim('Note: your token may remain in shell history or env — rotate it if it leaked.')}`);
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ // Run a removal step, converting a throw into a reportable outcome so one bad
95
+ // config can't crash the whole uninstall or leave a stack trace on screen.
96
+ function attempt(fn) {
97
+ try {
98
+ return { ok: true, result: fn() };
99
+ } catch (e) {
100
+ return { ok: false, error: e };
101
+ }
102
+ }
103
+
104
+ // Render one status line for a step: a clean error (file left untouched), a
105
+ // "removed" line, or a "nothing to remove" line.
106
+ function report(step, label, { done, noop }) {
107
+ if (!step.ok) {
108
+ status('fail', label, `left untouched — ${step.error.message}`);
109
+ return;
110
+ }
111
+ const r = step.result;
112
+ const removed = r.removed;
113
+ status(removed ? 'pass' : 'info', label, removed ? done(r) : noop);
114
+ }
package/src/util.mjs CHANGED
@@ -40,6 +40,83 @@ export function status(kind, label, detail) {
40
40
  log(` ${mark} ${label}${tail}`);
41
41
  }
42
42
 
43
+ // Map a raw keypress to a select-list action. Pure so it can be unit-tested
44
+ // without a pseudo-TTY. Supports arrow keys (both the `ESC [` and application
45
+ // `ESC O` cursor modes) and vim-style j/k.
46
+ export function selectAction(key) {
47
+ if (key === '') return 'cancel'; // Ctrl-C
48
+ if (key === '\r' || key === '\n') return 'submit';
49
+ if (key === 'k' || (key.startsWith('') && key.endsWith('A'))) return 'up';
50
+ if (key === 'j' || (key.startsWith('') && key.endsWith('B'))) return 'down';
51
+ return null;
52
+ }
53
+
54
+ // Interactive single-choice list. `options` is [{ label, value, hint? }].
55
+ // Arrow keys / j / k move, Enter selects, Ctrl-C aborts. Falls back to the
56
+ // default option when stdin isn't a TTY (CI / piped input), matching the
57
+ // non-interactive install path. Zero-dependency; renders with raw ANSI.
58
+ export function select(question, options, { defaultIndex = 0 } = {}) {
59
+ const { stdin, stdout } = process;
60
+ let index = Math.max(0, Math.min(defaultIndex, options.length - 1));
61
+
62
+ if (!stdin.isTTY) return Promise.resolve(options[index].value);
63
+
64
+ return new Promise((resolve) => {
65
+ const render = (first) => {
66
+ if (!first) stdout.write(`[${options.length}A`); // cursor up N lines
67
+ for (let i = 0; i < options.length; i++) {
68
+ const active = i === index;
69
+ const pointer = active ? c.cyan('❯') : ' ';
70
+ const label = active ? c.cyan(options[i].label) : options[i].label;
71
+ const hint = options[i].hint ? ` ${c.dim('— ' + options[i].hint)}` : '';
72
+ stdout.write(` ${pointer} ${label}${hint}\n`); // clear line, write
73
+ }
74
+ };
75
+
76
+ log(` ${question}`);
77
+ stdout.write('[?25l'); // hide cursor
78
+ render(true);
79
+
80
+ const prevRaw = Boolean(stdin.isRaw);
81
+ stdin.setRawMode(true);
82
+ stdin.resume();
83
+ stdin.setEncoding('utf8');
84
+
85
+ const cleanup = () => {
86
+ stdin.setRawMode(prevRaw);
87
+ stdin.pause();
88
+ stdin.removeListener('data', onData);
89
+ stdout.write('[?25h'); // show cursor
90
+ };
91
+
92
+ const onData = (key) => {
93
+ switch (selectAction(key)) {
94
+ case 'cancel':
95
+ cleanup();
96
+ stdout.write('\n');
97
+ process.exit(130);
98
+ break;
99
+ case 'up':
100
+ index = (index - 1 + options.length) % options.length;
101
+ render(false);
102
+ break;
103
+ case 'down':
104
+ index = (index + 1) % options.length;
105
+ render(false);
106
+ break;
107
+ case 'submit':
108
+ cleanup();
109
+ resolve(options[index].value);
110
+ break;
111
+ default:
112
+ break;
113
+ }
114
+ };
115
+
116
+ stdin.on('data', onData);
117
+ });
118
+ }
119
+
43
120
  // Minimal flag parser: --key value, --key=value, -k value, and bare --flags.
44
121
  // `aliases` maps short → long; `booleans` lists flags that take no value.
45
122
  export function parseArgs(argv, { aliases = {}, booleans = [] } = {}) {