@lorekit/cli 1.0.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = [] } = {}) {