@lorekit/cli 1.16.0 → 1.17.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/install.mjs +124 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/install.mjs CHANGED
@@ -15,21 +15,55 @@ import {
15
15
  resolveConnection,
16
16
  tokenKind,
17
17
  homeDir,
18
+ mcpConfigPath,
19
+ readJsonIfExists,
18
20
  } from './config.mjs';
19
- import { buildRemoteUrl } from './mcp.mjs';
21
+ import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
20
22
  import { deriveScope } from './scope.mjs';
21
- import { log, err, heading, status, select, c } from './util.mjs';
23
+ import { log, heading, status, select, c } from './util.mjs';
24
+
25
+ // The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
26
+ const LOREKIT_MCP_ENDPOINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
22
27
 
23
28
  function ask(question) {
24
29
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
30
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
26
31
  }
27
32
 
28
- const DEFAULT_ENDPOINT_HINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
33
+ // Detect whether lorekit is already installed for a given scope. Returns an
34
+ // object describing what is present so the caller can give precise feedback.
35
+ function detectInstalled(root, scope) {
36
+ // Check if at least one skill is present.
37
+ const skillsPresent = SKILLS.filter((skill) =>
38
+ fs.existsSync(path.join(skillInstallDir(root, scope, skill.name), 'SKILL.md')),
39
+ );
40
+
41
+ // Check if the MCP server entry exists and extract any configured token.
42
+ // Use a try/catch so a corrupt config file degrades to "not installed"
43
+ // instead of throwing — the user sees a clear error when they actually
44
+ // try to write via upsertMcpServer, which uses the throwing readJsonIfExists.
45
+ const mcpFile = mcpConfigPath(root, scope);
46
+ let mcpConfig = null;
47
+ try { mcpConfig = readJsonIfExists(mcpFile); } catch { /* corrupt — treat as absent */ }
48
+ const mcpServer = mcpConfig && mcpConfig.mcpServers && mcpConfig.mcpServers.lorekit;
49
+ const serverArgs = mcpServer && Array.isArray(mcpServer.args) ? mcpServer.args : [];
50
+ const serverUrl = serverArgs.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
51
+ const existingToken = serverUrl ? splitEndpoint(serverUrl).token : null;
52
+
53
+ return {
54
+ hasSkills: skillsPresent.length > 0,
55
+ skillCount: skillsPresent.length,
56
+ totalSkills: SKILLS.length,
57
+ hasMcp: Boolean(mcpServer),
58
+ existingToken,
59
+ isFullyInstalled: skillsPresent.length === SKILLS.length && Boolean(mcpServer),
60
+ };
61
+ }
29
62
 
30
63
  export async function install(args) {
31
64
  const root = resolveProjectRoot(args.dir);
32
65
  const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
66
+ const force = Boolean(args.force);
33
67
 
34
68
  heading('LoreKit install');
35
69
  log(` project: ${c.dim(root)}`);
@@ -47,44 +81,105 @@ export async function install(args) {
47
81
  ]);
48
82
  }
49
83
  }
84
+
85
+ // 2. Already-installed detection — check both scopes so we can give accurate
86
+ // context ("installed globally but not for this project", etc.).
87
+ const projectState = detectInstalled(root, 'project');
88
+ const globalState = detectInstalled(root, 'global');
89
+ const currentState = scope === 'global' ? globalState : projectState;
90
+
91
+ if (currentState.isFullyInstalled && !force) {
92
+ // Surface a clear, useful already-installed summary.
93
+ log('');
94
+ log(
95
+ ` ${c.green('LoreKit is already installed')} for ${
96
+ scope === 'global'
97
+ ? 'all projects (global)'
98
+ : 'this project'
99
+ }.`,
100
+ );
101
+
102
+ // Cross-scope awareness: tell the user what's installed where.
103
+ if (scope === 'project' && globalState.isFullyInstalled) {
104
+ log(` ${c.dim('Also installed globally — skills and MCP server are active for every project.')}`);
105
+ } else if (scope === 'project' && globalState.hasMcp) {
106
+ log(` ${c.dim('Partially installed globally (MCP server present, but skills may be missing).')}`);
107
+ } else if (scope === 'global' && projectState.isFullyInstalled) {
108
+ log(` ${c.dim('Also installed for this project (.claude, .mcp.json).')}`);
109
+ } else if (scope === 'global' && projectState.hasMcp) {
110
+ log(` ${c.dim('Partially installed for this project (MCP server present, but skills may be missing).')}`);
111
+ }
112
+
113
+ // Surface the configured token state so the user knows what access they have.
114
+ const kind = tokenKind(currentState.existingToken);
115
+ if (kind === 'read-write') {
116
+ log(` ${c.dim('Token: read+write (lk_rw_*)')}`);
117
+ } else if (kind === 'read-only') {
118
+ log(` ${c.dim('Token: read-only (lk_ro_*) — writes will fail until a read+write token is set')}`);
119
+ } else if (kind === 'write-only') {
120
+ log(` ${c.dim('Token: write-only (lk_wo_*) — reads will fail until a read+write token is set')}`);
121
+ } else if (kind === 'unknown') {
122
+ log(` ${c.dim('Token: unrecognized prefix — expected lk_rw_*, lk_ro_*, or lk_wo_*')}`);
123
+ } else {
124
+ log(` ${c.yellow('Token: none configured — reads/writes will fail until a token is set')}`);
125
+ }
126
+
127
+ log('');
128
+ log(` Run ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
129
+ log(` Pass ${c.cyan('--force')} to reinstall and overwrite existing files.`);
130
+ return 0;
131
+ }
132
+
133
+ // Partial install — note what's already there vs what will be added.
134
+ if ((currentState.hasSkills || currentState.hasMcp) && !force) {
135
+ const partialNote =
136
+ currentState.hasSkills && !currentState.hasMcp
137
+ ? `Skills already present (${currentState.skillCount}/${currentState.totalSkills}) — wiring MCP server.`
138
+ : currentState.hasMcp && !currentState.hasSkills
139
+ ? 'MCP server already configured — installing skill files.'
140
+ : `Partially installed (${currentState.skillCount}/${currentState.totalSkills} skills, MCP ${currentState.hasMcp ? 'present' : 'missing'}) — completing setup.`;
141
+ log(`\n ${c.dim(partialNote)}`);
142
+ }
143
+
50
144
  log(
51
145
  ` install: ${c.dim(
52
146
  scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
53
147
  )}`,
54
148
  );
55
149
 
56
- // 2. Connection details.
57
- let { endpoint, token } = resolveConnection(args);
58
-
59
- if (!endpoint) {
60
- if (nonInteractive) {
61
- err(
62
- `\n${c.red('Missing endpoint.')} Pass --endpoint ${DEFAULT_ENDPOINT_HINT} ` +
63
- `or set LOREKIT_MCP_URL.`,
64
- );
65
- return 1;
66
- }
67
- endpoint = await ask(` LoreKit MCP endpoint [${DEFAULT_ENDPOINT_HINT}]: `);
68
- if (!endpoint) endpoint = DEFAULT_ENDPOINT_HINT;
150
+ // 3. Connection details.
151
+ // The endpoint is always the fixed hosted LoreKit URL — no need to ask.
152
+ // The token is reused from the existing config when already present; the
153
+ // user only needs to supply it on a fresh install (or to replace it).
154
+ const fromArgs = resolveConnection(args);
155
+ const endpoint = fromArgs.endpoint || LOREKIT_MCP_ENDPOINT;
156
+
157
+ // Token resolution order: --token flag → env → existing config → prompt.
158
+ let token = fromArgs.token;
159
+ if (!token && currentState.existingToken) {
160
+ // Reuse the token that's already in the config — don't make the user repeat
161
+ // it just because they're running install again.
162
+ token = currentState.existingToken;
163
+ log(` ${c.dim('Token: reusing existing token from config.')}`);
69
164
  }
70
165
  if (!token && !nonInteractive) {
71
166
  token = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
72
167
  token = token || null;
73
168
  }
74
169
 
75
- // 3. Install the skill files — every skill the CLI ships.
170
+ // 4. Install the skill files — every skill the CLI ships.
76
171
  const skillResults = SKILLS.map((skill) => {
77
172
  const dest = skillInstallDir(root, scope, skill.name);
78
173
  const existed = fs.existsSync(path.join(dest, 'SKILL.md'));
79
- const written = copyDir(skill.source, dest, { force: Boolean(args.force) });
174
+ const written = copyDir(skill.source, dest, { force });
80
175
  return { name: skill.name, dest, existed, written };
81
176
  });
82
177
 
83
- // 4. Wire the MCP config for the chosen scope.
178
+ // 5. Wire the MCP config for the chosen scope.
84
179
  const remoteUrl = buildRemoteUrl(endpoint, token);
85
180
  const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
86
181
 
87
- // 4b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
182
+ // 5b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
88
183
  // Claude plugin adds on top of the skill: lessons injected on every
89
184
  // SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
90
185
  // firing the shared `lorekit hook` engine, which reads the same config.
@@ -100,14 +195,14 @@ export async function install(args) {
100
195
  scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
101
196
  const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
102
197
 
103
- // 5. Report.
198
+ // 6. Report.
104
199
  heading('Done');
105
200
  for (const s of skillResults) {
106
201
  const skillState = !s.existed
107
202
  ? 'installed'
108
203
  : s.written > 0
109
204
  ? `updated (${s.written} file(s) written)`
110
- : 'unchanged pass --force to overwrite';
205
+ : 'already up to date';
111
206
  status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
112
207
  }
113
208
  status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
@@ -116,10 +211,11 @@ export async function install(args) {
116
211
  status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but memories are model-invoked only');
117
212
  } else {
118
213
  const n = hooks.added + hooks.updated;
119
- const hookState =
120
- n === 0
121
- ? 'unchanged already wired'
122
- : `${hooks.added ? `${hooks.added} added` : ''}${hooks.added && hooks.updated ? ', ' : ''}${hooks.updated ? `${hooks.updated} updated` : ''}`;
214
+ const hookParts = [
215
+ hooks.added ? `${hooks.added} added` : '',
216
+ hooks.updated ? `${hooks.updated} updated` : '',
217
+ ].filter(Boolean);
218
+ const hookState = n === 0 ? 'already wired' : hookParts.join(', ');
123
219
  status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
124
220
  }
125
221
 
@@ -148,8 +244,8 @@ export async function install(args) {
148
244
  log(
149
245
  ` ${c.dim(
150
246
  scope === 'global'
151
- ? 'Note: your token now lives in ~/.claude.json (used by every project) — keep that file private.'
152
- : 'Note: your token now lives in .mcp.json — keep it out of version control.',
247
+ ? 'Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.'
248
+ : 'Note: your token is stored in .mcp.json — keep it out of version control.',
153
249
  )}`,
154
250
  );
155
251
  }