@lorekit/cli 1.17.1 → 1.19.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/bin/lorekit.mjs +47 -3
- package/package.json +1 -1
- package/src/adapters/claude.mjs +12 -0
- package/src/core/lessons.mjs +24 -1
- package/src/diff.mjs +2 -2
- package/src/doctor.mjs +61 -0
- package/src/hook.mjs +21 -0
- package/src/lessons-view.mjs +14 -1
- package/src/lint.mjs +1 -1
- package/src/show.mjs +22 -4
- package/src/tree.mjs +1 -1
- package/src/write.mjs +214 -0
package/bin/lorekit.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { doctor } from '../src/doctor.mjs';
|
|
|
9
9
|
import { list } from '../src/list.mjs';
|
|
10
10
|
import { search } from '../src/search.mjs';
|
|
11
11
|
import { show } from '../src/show.mjs';
|
|
12
|
+
import { write } from '../src/write.mjs';
|
|
12
13
|
import { stats } from '../src/stats.mjs';
|
|
13
14
|
import { scopes } from '../src/scopes.mjs';
|
|
14
15
|
import { diff } from '../src/diff.mjs';
|
|
@@ -53,7 +54,13 @@ ${c.bold('Commands')}
|
|
|
53
54
|
rendered in the same Offline/Remote split. --json, --scope <s>.
|
|
54
55
|
show Inspect one memory in full: its complete value, scope, key, updated
|
|
55
56
|
date, tags, and which store(s) it lives in (noting any divergence
|
|
56
|
-
when it is in both).
|
|
57
|
+
when it is in both). Accepts show <scope> <key> or the combined
|
|
58
|
+
show <scope::key> shorthand (copy-paste directly from list output).
|
|
59
|
+
--json.
|
|
60
|
+
write Create or update a memory. Accepts the same <scope::key> shorthand.
|
|
61
|
+
Value is a positional, --value flag, or piped stdin. Writes to the
|
|
62
|
+
remote store when configured, falling back to local. --local /
|
|
63
|
+
--remote to force.
|
|
57
64
|
stats Count the applicable memories per scope and per store (offline vs
|
|
58
65
|
remote), with per-store and grand totals, in the same Offline/
|
|
59
66
|
Remote split. --json, --scope <s>.
|
|
@@ -246,6 +253,39 @@ ${c.bold('Examples')}
|
|
|
246
253
|
npx @lorekit/cli search sandbox
|
|
247
254
|
npx @lorekit/cli grep "flaky test" --json
|
|
248
255
|
npx @lorekit/cli search migration --scope global
|
|
256
|
+
`,
|
|
257
|
+
write: `${c.bold('lorekit write')} — create or update a memory from the CLI
|
|
258
|
+
|
|
259
|
+
${c.bold('Usage')}
|
|
260
|
+
npx @lorekit/cli write <scope> <key> <value> [options]
|
|
261
|
+
npx @lorekit/cli write <scope::key> <value> [options]
|
|
262
|
+
echo "value" | npx @lorekit/cli write <scope> <key> [options]
|
|
263
|
+
|
|
264
|
+
Creates or updates a memory (upsert — overwrites if the key exists). Value can
|
|
265
|
+
be a positional, --value, or piped stdin. Writes to the remote store when
|
|
266
|
+
configured, falling back to local.
|
|
267
|
+
|
|
268
|
+
${c.bold('Options')}
|
|
269
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
270
|
+
--value <text> Memory value (alternative to positional / stdin)
|
|
271
|
+
--tags <a,b,c> Comma-separated tags (default: none)
|
|
272
|
+
--source-agent <n> Source agent name to record (default: none)
|
|
273
|
+
--trigger <slug> Trigger context slug (default: none)
|
|
274
|
+
--ttl-days <n> Days until auto-expiry 1–365 (remote only)
|
|
275
|
+
--org <slug> Write to this org's scope (remote only)
|
|
276
|
+
--remote Force write to the remote store
|
|
277
|
+
--local Force write to the local offline store
|
|
278
|
+
--json Machine-readable output
|
|
279
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
280
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
281
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
282
|
+
|
|
283
|
+
${c.bold('Examples')}
|
|
284
|
+
npx @lorekit/cli write global my-key "Always prefer guard clauses"
|
|
285
|
+
npx @lorekit/cli write global::my-key "Always prefer guard clauses"
|
|
286
|
+
cat notes.md | npx @lorekit/cli write global my-key --tags "style,aw"
|
|
287
|
+
npx @lorekit/cli write global my-key "body" --local
|
|
288
|
+
npx @lorekit/cli write global my-key "body" --ttl-days 30 --remote
|
|
249
289
|
`,
|
|
250
290
|
show: `${c.bold('lorekit show')} — inspect one memory in full
|
|
251
291
|
|
|
@@ -266,6 +306,7 @@ ${c.bold('Options')}
|
|
|
266
306
|
|
|
267
307
|
${c.bold('Examples')}
|
|
268
308
|
npx @lorekit/cli show global prefer-guard-clauses
|
|
309
|
+
npx @lorekit/cli show global::prefer-guard-clauses
|
|
269
310
|
npx @lorekit/cli show project::widget build-flags --json
|
|
270
311
|
`,
|
|
271
312
|
stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
|
|
@@ -470,6 +511,7 @@ const KNOWN_FLAGS = [
|
|
|
470
511
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
471
512
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
472
513
|
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
514
|
+
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
|
|
473
515
|
];
|
|
474
516
|
|
|
475
517
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
@@ -477,7 +519,7 @@ const KNOWN_FLAGS = [
|
|
|
477
519
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
478
520
|
const HUMAN_COMMANDS = new Set([
|
|
479
521
|
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
|
|
480
|
-
'diff', 'tree', 'lint', 'dedupe', 'migrate',
|
|
522
|
+
'diff', 'tree', 'lint', 'dedupe', 'migrate', 'write',
|
|
481
523
|
]);
|
|
482
524
|
|
|
483
525
|
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
@@ -494,7 +536,7 @@ async function main() {
|
|
|
494
536
|
const argv = process.argv.slice(2);
|
|
495
537
|
const args = parseArgs(argv, {
|
|
496
538
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
497
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json'],
|
|
539
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local'],
|
|
498
540
|
known: KNOWN_FLAGS,
|
|
499
541
|
});
|
|
500
542
|
|
|
@@ -571,6 +613,8 @@ async function main() {
|
|
|
571
613
|
return traceCommand('dedupe', args, VERSION, () => dedupe(args));
|
|
572
614
|
case 'migrate':
|
|
573
615
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
616
|
+
case 'write':
|
|
617
|
+
return traceCommand('write', args, VERSION, () => write(args));
|
|
574
618
|
default:
|
|
575
619
|
err(`${c.red('Unknown command:')} ${command}\n`);
|
|
576
620
|
log(HELP);
|
package/package.json
CHANGED
package/src/adapters/claude.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export const claude = {
|
|
|
9
9
|
case 'SessionStart':
|
|
10
10
|
return 'read';
|
|
11
11
|
case 'PostToolUse':
|
|
12
|
+
return 'confirm';
|
|
12
13
|
case 'PostToolUseFailure':
|
|
13
14
|
return 'failure';
|
|
14
15
|
case 'Stop':
|
|
@@ -23,11 +24,22 @@ export const claude = {
|
|
|
23
24
|
return event === 'PostToolUseFailure';
|
|
24
25
|
},
|
|
25
26
|
|
|
27
|
+
// Returns true when the PostToolUse event was a successful lorekit memory
|
|
28
|
+
// write. Claude Code reports MCP tool names as
|
|
29
|
+
// "mcp__<server-label>__memory_write" (underscores) — we match the suffix
|
|
30
|
+
// so any server label works. A successful write response always contains
|
|
31
|
+
// a string `id` field returned by the memory_write RPC.
|
|
32
|
+
isLoreWrite(toolName, toolResponse) {
|
|
33
|
+
if (!toolName || !String(toolName).endsWith('memory_write')) return false;
|
|
34
|
+
return toolResponse != null && typeof toolResponse === 'object' && typeof toolResponse.id === 'string';
|
|
35
|
+
},
|
|
36
|
+
|
|
26
37
|
parse(input) {
|
|
27
38
|
return {
|
|
28
39
|
cwd: input.cwd || null,
|
|
29
40
|
sessionId: input.session_id || null,
|
|
30
41
|
toolName: input.tool_name || 'tool',
|
|
42
|
+
toolInput: input.tool_input || null,
|
|
31
43
|
toolResponse: input.tool_response || null,
|
|
32
44
|
event: input.hook_event_name || null,
|
|
33
45
|
};
|
package/src/core/lessons.mjs
CHANGED
|
@@ -174,6 +174,14 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
|
|
|
174
174
|
return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
|
|
178
|
+
// Exported so tests can assert the URL shape without re-deriving the encoding.
|
|
179
|
+
export function loreUrl(writeScope) {
|
|
180
|
+
const base = 'https://lorekit.io/lore';
|
|
181
|
+
if (!writeScope || writeScope === 'global') return base;
|
|
182
|
+
return `${base}?scope=${encodeURIComponent(writeScope)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
177
185
|
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
178
186
|
// `control` is the resolved control object (optional) — carries tagsDefault and
|
|
179
187
|
// scopeDefaults when the repo/user config defines them.
|
|
@@ -182,13 +190,28 @@ export function retrospectiveNudge(scope, control) {
|
|
|
182
190
|
const hint = tagsHint(writeScope, control);
|
|
183
191
|
const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
|
|
184
192
|
? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
|
|
193
|
+
const url = loreUrl(writeScope);
|
|
185
194
|
return (
|
|
186
195
|
`LoreKit: hit any friction worth remembering — a stuck loop, a repeated ` +
|
|
187
196
|
`failure, a gotcha, a wrong assumption? If so, memory.write to ${writeScope} ` +
|
|
188
|
-
`as an observation; else skip.${hint}${instruction}`
|
|
197
|
+
`as an observation; else skip.${hint}${instruction}\n` +
|
|
198
|
+
`View lore: ${url}`
|
|
189
199
|
);
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
// Terse confirmation emitted via PostToolUse when a memory.write succeeded.
|
|
203
|
+
// `key` is the lesson key from the tool response (may be null when the response
|
|
204
|
+
// shape doesn't surface it). Includes a deep link to the scope's Lore Explorer
|
|
205
|
+
// page so the user can verify immediately.
|
|
206
|
+
export function writeConfirmation(scope, key) {
|
|
207
|
+
const writeScope = scope.repoScope || 'global';
|
|
208
|
+
const keyPart = key ? ` · ${key}` : '';
|
|
209
|
+
const url = key
|
|
210
|
+
? `${loreUrl(writeScope)}&q=${encodeURIComponent(key)}`
|
|
211
|
+
: loreUrl(writeScope);
|
|
212
|
+
return `LoreKit: memory saved to ${writeScope}${keyPart}\nView: ${url}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
// The nudge emitted when a tool failure is detected.
|
|
193
216
|
// `control` is the resolved control object (optional) — carries tagsDefault and
|
|
194
217
|
// scopeDefaults when the repo/user config defines them.
|
package/src/diff.mjs
CHANGED
|
@@ -132,7 +132,7 @@ function renderSet(title, subtitle, groups, pick) {
|
|
|
132
132
|
log(` ${c.bold(g.scope)}`);
|
|
133
133
|
for (const e of pick(g)) {
|
|
134
134
|
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
135
|
-
log(` ${c.cyan('•')} ${e.key}${when}`);
|
|
135
|
+
log(` ${c.cyan('•')} ${g.scope}::${e.key}${when}`);
|
|
136
136
|
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
137
137
|
}
|
|
138
138
|
}
|
|
@@ -148,7 +148,7 @@ function renderConflicts(groups) {
|
|
|
148
148
|
for (const g of present) {
|
|
149
149
|
log(` ${c.bold(g.scope)}`);
|
|
150
150
|
for (const conflict of g.conflicting) {
|
|
151
|
-
log(` ${c.yellow('•')} ${conflict.key}`);
|
|
151
|
+
log(` ${c.yellow('•')} ${g.scope}::${conflict.key}`);
|
|
152
152
|
log(` ${c.dim('offline')} ${preview(conflict.local.value)}`);
|
|
153
153
|
log(` ${c.dim('remote ')} ${preview(conflict.remote.value)}`);
|
|
154
154
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
skillInstallDir,
|
|
11
|
+
settingsPath,
|
|
12
|
+
CLAUDE_HOOK_EVENTS,
|
|
13
|
+
LOREKIT_HOOK_RE,
|
|
11
14
|
readLorekitServer,
|
|
12
15
|
readMcpConfig,
|
|
13
16
|
tokenKind,
|
|
@@ -63,6 +66,22 @@ export async function doctor(args) {
|
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// 2.5. Duplicate-hook detection — warn when the same lorekit hook event is
|
|
70
|
+
// wired in both the project settings and the global settings. This causes
|
|
71
|
+
// Claude Code to fire the hook twice per event, producing doubled terminal
|
|
72
|
+
// output. Common after running `lorekit install` once with --project and
|
|
73
|
+
// once with --global (or via the marketplace plugin on top of a CLI install).
|
|
74
|
+
const dupeEvents = detectDuplicateHooks(root);
|
|
75
|
+
if (dupeEvents.length > 0) {
|
|
76
|
+
record(
|
|
77
|
+
'warn',
|
|
78
|
+
'hooks duplicate',
|
|
79
|
+
`${dupeEvents.join(', ')} registered in BOTH project and global settings — ` +
|
|
80
|
+
`Claude Code fires them twice. Remove one scope: ` +
|
|
81
|
+
`run \`lorekit uninstall --project\` or \`lorekit uninstall --global\`.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
// 3. Resolved control model — which mode, and who decided it.
|
|
67
86
|
const control = loadControl(root, { env: withOverrides(args) });
|
|
68
87
|
record('info', 'memory mode', `${control.mode} ${c.dim('— decided by ' + control.decidedBy)}`);
|
|
@@ -290,6 +309,48 @@ async function deepCheckLocal(store, scope, record) {
|
|
|
290
309
|
await store.delete({ scope: writeScope, key, force: true });
|
|
291
310
|
}
|
|
292
311
|
|
|
312
|
+
// Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
|
|
313
|
+
// BOTH the project settings file (.claude/settings.json) and the global one
|
|
314
|
+
// (~/.claude/settings.json). An empty array means no duplicates — healthy.
|
|
315
|
+
function detectDuplicateHooks(root) {
|
|
316
|
+
const dupes = [];
|
|
317
|
+
const projectFile = settingsPath(root, 'project');
|
|
318
|
+
const globalFile = settingsPath(root, 'global');
|
|
319
|
+
|
|
320
|
+
let projectHooks = {};
|
|
321
|
+
let globalHooks = {};
|
|
322
|
+
try {
|
|
323
|
+
const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
|
324
|
+
if (cfg && typeof cfg.hooks === 'object') projectHooks = cfg.hooks;
|
|
325
|
+
} catch { /* absent or unparseable — treat as empty */ }
|
|
326
|
+
try {
|
|
327
|
+
const cfg = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
|
|
328
|
+
if (cfg && typeof cfg.hooks === 'object') globalHooks = cfg.hooks;
|
|
329
|
+
} catch { /* absent or unparseable — treat as empty */ }
|
|
330
|
+
|
|
331
|
+
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
332
|
+
const hasInProject = hooksForEvent(projectHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
333
|
+
const hasInGlobal = hooksForEvent(globalHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
334
|
+
if (hasInProject && hasInGlobal) dupes.push(event);
|
|
335
|
+
}
|
|
336
|
+
return dupes;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Extract the flat list of hook command strings for one event from a hooks
|
|
340
|
+
// object. Handles the nested-group shape Claude Code uses:
|
|
341
|
+
// { [event]: [ { hooks: [ { type, command } ] } ] }
|
|
342
|
+
function hooksForEvent(hooksObj, event) {
|
|
343
|
+
const groups = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
|
|
344
|
+
const commands = [];
|
|
345
|
+
for (const group of groups) {
|
|
346
|
+
const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
347
|
+
for (const h of inner) {
|
|
348
|
+
if (h && typeof h.command === 'string') commands.push(h.command);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return commands;
|
|
352
|
+
}
|
|
353
|
+
|
|
293
354
|
function gitTracked(root, dir) {
|
|
294
355
|
// Heuristic: is the store dir ignored by git? If `git check-ignore` names it,
|
|
295
356
|
// it is private; otherwise it will be committed (team-shared).
|
package/src/hook.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
failureQuery,
|
|
16
16
|
relevantLessons,
|
|
17
17
|
formatRelevantLessons,
|
|
18
|
+
writeConfirmation,
|
|
18
19
|
} from './core/lessons.mjs';
|
|
19
20
|
import { isFailure } from './core/failure.mjs';
|
|
20
21
|
import { firstTimeThisSession } from './core/state.mjs';
|
|
@@ -109,6 +110,26 @@ async function run(args) {
|
|
|
109
110
|
return 0;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
if (intent === 'confirm') {
|
|
114
|
+
// Fire only when a lorekit memory write actually succeeded — the adapter's
|
|
115
|
+
// isLoreWrite() inspects the tool name and the response shape. Any error
|
|
116
|
+
// is swallowed (exit 0 — never block the host).
|
|
117
|
+
try {
|
|
118
|
+
if (adapter.isLoreWrite && adapter.isLoreWrite(parsed.toolName, parsed.toolResponse)) {
|
|
119
|
+
// The lesson key comes from the tool INPUT (what the agent sent), not
|
|
120
|
+
// the response (which only carries id + created_at). toolInput is
|
|
121
|
+
// populated by the adapter's parse() from the raw hook stdin.
|
|
122
|
+
const key = (parsed.toolInput && typeof parsed.toolInput.key === 'string')
|
|
123
|
+
? parsed.toolInput.key
|
|
124
|
+
: null;
|
|
125
|
+
emit(writeConfirmation(scope, key));
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// best-effort — never break the host
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
if (intent === 'failure') {
|
|
113
134
|
const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
|
|
114
135
|
if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
|
package/src/lessons-view.mjs
CHANGED
|
@@ -16,6 +16,19 @@ import { log, heading, status, c } from './util.mjs';
|
|
|
16
16
|
import { resolvePrecedence, matchesQuery } from './lessons-pure.mjs';
|
|
17
17
|
export { resolvePrecedence, matchesQuery };
|
|
18
18
|
|
|
19
|
+
// Parse a combined `scope::key` string into { scope, key }, or return null when
|
|
20
|
+
// the input contains no `::`. Uses the FIRST occurrence of `::` as the separator
|
|
21
|
+
// so nested scopes like `branch::owner/repo::feat/x` are handled correctly.
|
|
22
|
+
// Exported so `show`, `write`, and any future commands share one implementation.
|
|
23
|
+
export function parseScopeKey(s) {
|
|
24
|
+
const idx = s.indexOf('::');
|
|
25
|
+
if (idx === -1) return null;
|
|
26
|
+
const scope = s.slice(0, idx).trim();
|
|
27
|
+
const key = s.slice(idx + 2).trim();
|
|
28
|
+
if (!scope || !key) return null;
|
|
29
|
+
return { scope, key };
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
// The scopes that apply to the current directory, most-specific → broadest:
|
|
20
33
|
// project, branch, repo, global. De-duplicated (a repo with no branch scope,
|
|
21
34
|
// or a project whose name collides, never lists a scope twice). Pure — takes an
|
|
@@ -443,7 +456,7 @@ export function renderSection(header, section) {
|
|
|
443
456
|
}
|
|
444
457
|
for (const e of g.entries) {
|
|
445
458
|
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
446
|
-
log(` ${c.cyan('•')} ${e.key}${when}`);
|
|
459
|
+
log(` ${c.cyan('•')} ${g.scope}::${e.key}${when}`);
|
|
447
460
|
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
448
461
|
}
|
|
449
462
|
}
|
package/src/lint.mjs
CHANGED
|
@@ -117,7 +117,7 @@ function renderLintSection(header, section) {
|
|
|
117
117
|
continue;
|
|
118
118
|
}
|
|
119
119
|
for (const f of g.findings) {
|
|
120
|
-
log(` ${c.yellow('•')} ${f.key} ${c.dim(`[${f.rule}]`)} ${f.message}`);
|
|
120
|
+
log(` ${c.yellow('•')} ${g.scope}::${f.key} ${c.dim(`[${f.rule}]`)} ${f.message}`);
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
}
|
package/src/show.mjs
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// stores — possibly with different values — both are shown and the divergence is
|
|
5
5
|
// flagged.
|
|
6
6
|
//
|
|
7
|
+
// Two positional shapes are accepted:
|
|
8
|
+
// show <scope> <key> — classic two-positional form
|
|
9
|
+
// show <scope::key> — combined shorthand (the same format `list` prints,
|
|
10
|
+
// so you can copy-paste a key directly from list output)
|
|
11
|
+
//
|
|
7
12
|
// Uses each store's real `read({scope, key})` method (both stores expose it),
|
|
8
13
|
// not a filtered `list` — a single-record lookup is what `read` is for, and it
|
|
9
14
|
// already hides archived entries. Graceful by design (mirrors `list`/`search`):
|
|
@@ -13,7 +18,7 @@ import process from 'node:process';
|
|
|
13
18
|
import { resolveProjectRoot } from './config.mjs';
|
|
14
19
|
import { resolveDenies } from './control.mjs';
|
|
15
20
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
|
-
import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
|
|
21
|
+
import { normalizeEntry, shortDate, describeError, recordsDiverge, parseScopeKey } from './lessons-view.mjs';
|
|
17
22
|
import { log, err, heading, status, c } from './util.mjs';
|
|
18
23
|
|
|
19
24
|
// Read one scope::key from a store, normalizing the result into a small,
|
|
@@ -40,11 +45,24 @@ export async function show(args) {
|
|
|
40
45
|
const env = { ...process.env };
|
|
41
46
|
if (args.store) env.LOREKIT_STORE = args.store;
|
|
42
47
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
// Two positional shapes are accepted:
|
|
49
|
+
// show <scope> <key> — classic two-positional form (backward-compatible)
|
|
50
|
+
// show <scope::key> — combined shorthand mirroring `list` output format
|
|
51
|
+
let scope, key;
|
|
52
|
+
const first = typeof args._[1] === 'string' ? args._[1] : '';
|
|
53
|
+
const parsed = parseScopeKey(first);
|
|
54
|
+
if (parsed) {
|
|
55
|
+
// Combined scope::key — e.g. `show global::claude-mcp-registration-can-hang`
|
|
56
|
+
scope = parsed.scope;
|
|
57
|
+
key = parsed.key;
|
|
58
|
+
} else {
|
|
59
|
+
// Classic two-positional form — e.g. `show global claude-mcp-registration-can-hang`
|
|
60
|
+
scope = first;
|
|
61
|
+
key = typeof args._[2] === 'string' ? args._[2] : '';
|
|
62
|
+
}
|
|
46
63
|
if (!scope || !key) {
|
|
47
64
|
err(`${c.red('Usage:')} lorekit show <scope> <key> [--json]`);
|
|
65
|
+
err(` lorekit show <scope::key> [--json]`);
|
|
48
66
|
err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
|
|
49
67
|
return 1;
|
|
50
68
|
}
|
package/src/tree.mjs
CHANGED
|
@@ -121,7 +121,7 @@ function renderTreeSection(header, section) {
|
|
|
121
121
|
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
122
122
|
const mark = e.winning ? c.green('✓') : c.yellow('↳');
|
|
123
123
|
const tag = e.winning ? '' : ` ${c.dim(`shadowed by ${e.shadowedBy}`)}`;
|
|
124
|
-
log(` ${mark} ${e.key}${tag}${when}`);
|
|
124
|
+
log(` ${mark} ${g.scope}::${e.key}${tag}${when}`);
|
|
125
125
|
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
126
126
|
}
|
|
127
127
|
}
|
package/src/write.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// `lorekit write <scope> <key> [value]` — create or update a memory from the CLI.
|
|
2
|
+
//
|
|
3
|
+
// Two positional shapes are accepted:
|
|
4
|
+
// write <scope> <key> [value] — classic two-positional form
|
|
5
|
+
// write <scope::key> [value] — combined shorthand (the same format `list`
|
|
6
|
+
// prints, so you can copy-paste a key directly)
|
|
7
|
+
//
|
|
8
|
+
// When no value is supplied as a positional or via --value, the command reads
|
|
9
|
+
// the full stdin (useful for piping). In all cases the value is required — an
|
|
10
|
+
// empty string produces a usage error (prefer `lorekit delete` to remove a key).
|
|
11
|
+
//
|
|
12
|
+
// Optional write-metadata flags mirror the hosted `memory.write` parameters:
|
|
13
|
+
// --tags <a,b,c> Comma-separated tag list (default: no tags)
|
|
14
|
+
// --source-agent <name> Which agent recorded this lesson (default: none)
|
|
15
|
+
// --trigger <slug> What prompted the write (default: none)
|
|
16
|
+
// --ttl-days <n> Days until the memory auto-expires (1–365)
|
|
17
|
+
// --org <slug> Write to this org (remote only)
|
|
18
|
+
//
|
|
19
|
+
// Store targeting (default: remote if configured, else local):
|
|
20
|
+
// --remote Force write to the remote store
|
|
21
|
+
// --local Force write to the local offline store
|
|
22
|
+
//
|
|
23
|
+
// The command writes to ONE store at a time (unlike the read commands that show
|
|
24
|
+
// both). Dual-write would silently create a divergence on subsequent `diff`.
|
|
25
|
+
//
|
|
26
|
+
// Output: a confirmation line (human) or a JSON object (--json) with the
|
|
27
|
+
// resolved scope, key, store written, and a boolean `inserted` (true = created,
|
|
28
|
+
// false = updated) when the remote reports it.
|
|
29
|
+
import process from 'node:process';
|
|
30
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
31
|
+
import { resolveDenies } from './control.mjs';
|
|
32
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
33
|
+
import { log, err, heading, status, c } from './util.mjs';
|
|
34
|
+
import { parseScopeKey } from './lessons-view.mjs';
|
|
35
|
+
|
|
36
|
+
// Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
|
|
37
|
+
function readStdin() {
|
|
38
|
+
if (process.stdin.isTTY) return '';
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
process.stdin.on('data', (d) => chunks.push(d));
|
|
42
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trimEnd()));
|
|
43
|
+
process.stdin.resume();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function write(args) {
|
|
48
|
+
const root = resolveProjectRoot(args.dir);
|
|
49
|
+
const env = { ...process.env };
|
|
50
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
51
|
+
|
|
52
|
+
// ── Parse positionals: two forms ──────────────────────────────────────────
|
|
53
|
+
// Form A: `write scope key [value]` → _[1]=scope, _[2]=key, _[3]=value?
|
|
54
|
+
// Form B: `write scope::key [value]` → _[1]='scope::key', _[2]=value?
|
|
55
|
+
let scope, key, positionalValue;
|
|
56
|
+
|
|
57
|
+
const first = typeof args._[1] === 'string' ? args._[1] : '';
|
|
58
|
+
const parsed = parseScopeKey(first);
|
|
59
|
+
|
|
60
|
+
if (parsed) {
|
|
61
|
+
// Form B: combined scope::key
|
|
62
|
+
scope = parsed.scope;
|
|
63
|
+
key = parsed.key;
|
|
64
|
+
positionalValue = typeof args._[2] === 'string' ? args._[2] : undefined;
|
|
65
|
+
} else {
|
|
66
|
+
// Form A: separate scope and key
|
|
67
|
+
scope = first;
|
|
68
|
+
key = typeof args._[2] === 'string' ? args._[2] : '';
|
|
69
|
+
positionalValue = typeof args._[3] === 'string' ? args._[3] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!scope || !key) {
|
|
73
|
+
err(`${c.red('Usage:')} lorekit write <scope> <key> [value] [options]`);
|
|
74
|
+
err(` lorekit write <scope::key> [value] [options]`);
|
|
75
|
+
err(`Both a scope and a key are required. Run ${c.cyan('lorekit write --help')} for options.`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Resolve value: flag → positional → stdin ───────────────────────────────
|
|
80
|
+
let value;
|
|
81
|
+
if (typeof args.value === 'string') {
|
|
82
|
+
value = args.value;
|
|
83
|
+
} else if (positionalValue !== undefined) {
|
|
84
|
+
value = positionalValue;
|
|
85
|
+
} else {
|
|
86
|
+
value = await readStdin();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!value) {
|
|
90
|
+
err(`${c.red('Error:')} a non-empty value is required`);
|
|
91
|
+
err(`Pipe a value via stdin, pass it as a positional, or use --value <text>.`);
|
|
92
|
+
err(`Run ${c.cyan('lorekit write --help')} for options.`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Parse optional metadata flags ─────────────────────────────────────────
|
|
97
|
+
const tags = args.tags ? String(args.tags).split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
98
|
+
const sourceAgent = typeof args['source-agent'] === 'string' ? args['source-agent'] : undefined;
|
|
99
|
+
const trigger = typeof args.trigger === 'string' ? args.trigger : undefined;
|
|
100
|
+
const ttlDays = args['ttl-days'] ? Number(args['ttl-days']) : undefined;
|
|
101
|
+
const orgSlug = typeof args.org === 'string' ? args.org : undefined;
|
|
102
|
+
|
|
103
|
+
// ── Resolve deny constraints ───────────────────────────────────────────────
|
|
104
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
105
|
+
|
|
106
|
+
// ── Resolve stores and pick the target ────────────────────────────────────
|
|
107
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
108
|
+
env,
|
|
109
|
+
endpoint: args.endpoint,
|
|
110
|
+
token: args.token,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const forceRemote = Boolean(args.remote);
|
|
114
|
+
const forceLocal = Boolean(args.local);
|
|
115
|
+
const remoteUsable = !remoteDenied && remote.usable();
|
|
116
|
+
|
|
117
|
+
if (forceRemote && forceLocal) {
|
|
118
|
+
err(`${c.red('Error:')} --remote and --local are mutually exclusive`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let targetStore, storeName;
|
|
123
|
+
|
|
124
|
+
if (forceRemote) {
|
|
125
|
+
if (remoteDenied) {
|
|
126
|
+
err(`${c.red('Error:')} remote store is disabled by deny constraint (${remoteDenied.source})`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
if (!remote.usable()) {
|
|
130
|
+
err(`${c.red('Error:')} remote store is not configured — ${remoteUnavailableReason(connection)}`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
targetStore = remote;
|
|
134
|
+
storeName = 'remote';
|
|
135
|
+
} else if (forceLocal) {
|
|
136
|
+
if (localDenied) {
|
|
137
|
+
err(`${c.red('Error:')} local store is disabled by deny constraint (${localDenied.source})`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
targetStore = local;
|
|
141
|
+
storeName = 'local';
|
|
142
|
+
} else if (remoteUsable) {
|
|
143
|
+
targetStore = remote;
|
|
144
|
+
storeName = 'remote';
|
|
145
|
+
} else if (!localDenied) {
|
|
146
|
+
targetStore = local;
|
|
147
|
+
storeName = 'local';
|
|
148
|
+
} else {
|
|
149
|
+
err(`${c.red('Error:')} no writable store available`);
|
|
150
|
+
err(`Remote: ${remoteUnavailableReason(connection)}`);
|
|
151
|
+
if (localDenied) err(`Local: disabled by deny constraint (${localDenied.source})`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Write ──────────────────────────────────────────────────────────────────
|
|
156
|
+
const writeArgs = {
|
|
157
|
+
scope,
|
|
158
|
+
key,
|
|
159
|
+
value: String(value),
|
|
160
|
+
...(tags.length ? { tags } : {}),
|
|
161
|
+
...(sourceAgent ? { source_agent: sourceAgent } : {}),
|
|
162
|
+
...(trigger ? { trigger } : {}),
|
|
163
|
+
...(ttlDays ? { ttl_days: ttlDays } : {}),
|
|
164
|
+
...(orgSlug ? { org: orgSlug } : {}),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
let result;
|
|
168
|
+
try {
|
|
169
|
+
result = await targetStore.write(writeArgs);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
err(`${c.red('Error:')} ${(e && e.message) || String(e)}`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!result || result.ok === false) {
|
|
176
|
+
const detail = result && result.error ? result.error : 'unknown error';
|
|
177
|
+
err(`${c.red('Error writing to')} ${storeName} store: ${detail}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `inserted` is additive from the remote (memory_write RPC 00011). Local store
|
|
182
|
+
// returns { ok, entry } with no explicit field; treat absence as unknown.
|
|
183
|
+
const inserted = result.inserted ?? null;
|
|
184
|
+
|
|
185
|
+
if (args.json) {
|
|
186
|
+
log(JSON.stringify({
|
|
187
|
+
scope,
|
|
188
|
+
key,
|
|
189
|
+
store: storeName,
|
|
190
|
+
inserted,
|
|
191
|
+
value: String(value),
|
|
192
|
+
tags,
|
|
193
|
+
source_agent: sourceAgent || null,
|
|
194
|
+
trigger: trigger || null,
|
|
195
|
+
}, null, 2));
|
|
196
|
+
} else {
|
|
197
|
+
const verb = inserted === true ? 'Created' : inserted === false ? 'Updated' : 'Written';
|
|
198
|
+
heading('LoreKit memory written');
|
|
199
|
+
log(` ${c.dim('store')} ${storeName}`);
|
|
200
|
+
log(` ${c.dim('scope')} ${scope}`);
|
|
201
|
+
log(` ${c.dim('key')} ${key}`);
|
|
202
|
+
if (tags.length) log(` ${c.dim('tags')} ${tags.join(', ')}`);
|
|
203
|
+
status('pass', verb, `${scope}::${key}`);
|
|
204
|
+
log('');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
exitCode: 0,
|
|
209
|
+
'lorekit.cli.write.store': storeName,
|
|
210
|
+
'lorekit.cli.write.inserted': inserted,
|
|
211
|
+
'lorekit.cli.write.has_tags': tags.length > 0,
|
|
212
|
+
'lorekit.cli.write.has_ttl': Boolean(ttlDays),
|
|
213
|
+
};
|
|
214
|
+
}
|