@lorekit/cli 1.18.0 → 1.20.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 +53 -3
- package/package.json +1 -1
- package/src/bootstrap.mjs +141 -0
- package/src/diff.mjs +2 -2
- package/src/doctor.mjs +37 -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';
|
|
@@ -17,6 +18,7 @@ import { lint } from '../src/lint.mjs';
|
|
|
17
18
|
import { dedupe } from '../src/dedupe.mjs';
|
|
18
19
|
import { hook } from '../src/hook.mjs';
|
|
19
20
|
import { migrate } from '../src/migrate.mjs';
|
|
21
|
+
import { bootstrap } from '../src/bootstrap.mjs';
|
|
20
22
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
21
23
|
import { traceCommand } from '../src/telemetry.mjs';
|
|
22
24
|
import { loadDotEnv } from '../src/dotenv.mjs';
|
|
@@ -53,7 +55,13 @@ ${c.bold('Commands')}
|
|
|
53
55
|
rendered in the same Offline/Remote split. --json, --scope <s>.
|
|
54
56
|
show Inspect one memory in full: its complete value, scope, key, updated
|
|
55
57
|
date, tags, and which store(s) it lives in (noting any divergence
|
|
56
|
-
when it is in both).
|
|
58
|
+
when it is in both). Accepts show <scope> <key> or the combined
|
|
59
|
+
show <scope::key> shorthand (copy-paste directly from list output).
|
|
60
|
+
--json.
|
|
61
|
+
write Create or update a memory. Accepts the same <scope::key> shorthand.
|
|
62
|
+
Value is a positional, --value flag, or piped stdin. Writes to the
|
|
63
|
+
remote store when configured, falling back to local. --local /
|
|
64
|
+
--remote to force.
|
|
57
65
|
stats Count the applicable memories per scope and per store (offline vs
|
|
58
66
|
remote), with per-store and grand totals, in the same Offline/
|
|
59
67
|
Remote split. --json, --scope <s>.
|
|
@@ -73,6 +81,9 @@ ${c.bold('Commands')}
|
|
|
73
81
|
dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
|
|
74
82
|
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
75
83
|
store. --json, --scope <s>, --threshold <0..1>.
|
|
84
|
+
bootstrap Apply the BYOD schema to a user-supplied Supabase database.
|
|
85
|
+
Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
|
|
86
|
+
See docs/byod.md for setup instructions.
|
|
76
87
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
77
88
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
78
89
|
hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
|
|
@@ -246,6 +257,39 @@ ${c.bold('Examples')}
|
|
|
246
257
|
npx @lorekit/cli search sandbox
|
|
247
258
|
npx @lorekit/cli grep "flaky test" --json
|
|
248
259
|
npx @lorekit/cli search migration --scope global
|
|
260
|
+
`,
|
|
261
|
+
write: `${c.bold('lorekit write')} — create or update a memory from the CLI
|
|
262
|
+
|
|
263
|
+
${c.bold('Usage')}
|
|
264
|
+
npx @lorekit/cli write <scope> <key> <value> [options]
|
|
265
|
+
npx @lorekit/cli write <scope::key> <value> [options]
|
|
266
|
+
echo "value" | npx @lorekit/cli write <scope> <key> [options]
|
|
267
|
+
|
|
268
|
+
Creates or updates a memory (upsert — overwrites if the key exists). Value can
|
|
269
|
+
be a positional, --value, or piped stdin. Writes to the remote store when
|
|
270
|
+
configured, falling back to local.
|
|
271
|
+
|
|
272
|
+
${c.bold('Options')}
|
|
273
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
274
|
+
--value <text> Memory value (alternative to positional / stdin)
|
|
275
|
+
--tags <a,b,c> Comma-separated tags (default: none)
|
|
276
|
+
--source-agent <n> Source agent name to record (default: none)
|
|
277
|
+
--trigger <slug> Trigger context slug (default: none)
|
|
278
|
+
--ttl-days <n> Days until auto-expiry 1–365 (remote only)
|
|
279
|
+
--org <slug> Write to this org's scope (remote only)
|
|
280
|
+
--remote Force write to the remote store
|
|
281
|
+
--local Force write to the local offline store
|
|
282
|
+
--json Machine-readable output
|
|
283
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
284
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
285
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
286
|
+
|
|
287
|
+
${c.bold('Examples')}
|
|
288
|
+
npx @lorekit/cli write global my-key "Always prefer guard clauses"
|
|
289
|
+
npx @lorekit/cli write global::my-key "Always prefer guard clauses"
|
|
290
|
+
cat notes.md | npx @lorekit/cli write global my-key --tags "style,aw"
|
|
291
|
+
npx @lorekit/cli write global my-key "body" --local
|
|
292
|
+
npx @lorekit/cli write global my-key "body" --ttl-days 30 --remote
|
|
249
293
|
`,
|
|
250
294
|
show: `${c.bold('lorekit show')} — inspect one memory in full
|
|
251
295
|
|
|
@@ -266,6 +310,7 @@ ${c.bold('Options')}
|
|
|
266
310
|
|
|
267
311
|
${c.bold('Examples')}
|
|
268
312
|
npx @lorekit/cli show global prefer-guard-clauses
|
|
313
|
+
npx @lorekit/cli show global::prefer-guard-clauses
|
|
269
314
|
npx @lorekit/cli show project::widget build-flags --json
|
|
270
315
|
`,
|
|
271
316
|
stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
|
|
@@ -470,6 +515,7 @@ const KNOWN_FLAGS = [
|
|
|
470
515
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
471
516
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
472
517
|
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
518
|
+
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
|
|
473
519
|
];
|
|
474
520
|
|
|
475
521
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
@@ -477,7 +523,7 @@ const KNOWN_FLAGS = [
|
|
|
477
523
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
478
524
|
const HUMAN_COMMANDS = new Set([
|
|
479
525
|
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
|
|
480
|
-
'diff', 'tree', 'lint', 'dedupe', 'migrate',
|
|
526
|
+
'diff', 'tree', 'lint', 'dedupe', 'migrate', 'write',
|
|
481
527
|
]);
|
|
482
528
|
|
|
483
529
|
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
@@ -494,7 +540,7 @@ async function main() {
|
|
|
494
540
|
const argv = process.argv.slice(2);
|
|
495
541
|
const args = parseArgs(argv, {
|
|
496
542
|
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'],
|
|
543
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local'],
|
|
498
544
|
known: KNOWN_FLAGS,
|
|
499
545
|
});
|
|
500
546
|
|
|
@@ -571,6 +617,10 @@ async function main() {
|
|
|
571
617
|
return traceCommand('dedupe', args, VERSION, () => dedupe(args));
|
|
572
618
|
case 'migrate':
|
|
573
619
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
620
|
+
case 'bootstrap':
|
|
621
|
+
return traceCommand('bootstrap', args, VERSION, () => bootstrap(args));
|
|
622
|
+
case 'write':
|
|
623
|
+
return traceCommand('write', args, VERSION, () => write(args));
|
|
574
624
|
default:
|
|
575
625
|
err(`${c.red('Unknown command:')} ${command}\n`);
|
|
576
626
|
log(HELP);
|
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `lorekit bootstrap` — apply the BYOD schema to a user-supplied Supabase database.
|
|
2
|
+
//
|
|
3
|
+
// Reads LOREKIT_STORAGE_URL and LOREKIT_STORAGE_SERVICE_KEY from the environment.
|
|
4
|
+
// If neither is set, prints a helpful message and exits 0 — bootstrap is only
|
|
5
|
+
// needed for BYOD (Bring Your Own Database) setups.
|
|
6
|
+
//
|
|
7
|
+
// Because Supabase's JS client does not expose a raw SQL execution method for DDL,
|
|
8
|
+
// this command instructs the user to run the SQL file directly with psql when no
|
|
9
|
+
// direct execution path is available. It validates connectivity using the anon key
|
|
10
|
+
// and confirms the bootstrap.sql path for the user.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import process from 'node:process';
|
|
15
|
+
import { log, err, heading, status, c } from './util.mjs';
|
|
16
|
+
|
|
17
|
+
// The bootstrap.sql file is at <repo-root>/supabase/byod/bootstrap.sql.
|
|
18
|
+
// This file lives at packages/cli/src/bootstrap.mjs, so the relative path
|
|
19
|
+
// from here to the repo root is ../../../ (src → cli → packages → root).
|
|
20
|
+
const BOOTSTRAP_SQL_PATH = fileURLToPath(
|
|
21
|
+
new URL('../../../supabase/byod/bootstrap.sql', import.meta.url),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export async function bootstrap(_args) {
|
|
25
|
+
heading('LoreKit bootstrap');
|
|
26
|
+
|
|
27
|
+
const storageUrl = process.env['LOREKIT_STORAGE_URL'];
|
|
28
|
+
const storageServiceKey = process.env['LOREKIT_STORAGE_SERVICE_KEY'];
|
|
29
|
+
const storageAnonKey = process.env['LOREKIT_STORAGE_ANON_KEY'];
|
|
30
|
+
|
|
31
|
+
// If no BYOD env vars are set, this command is a no-op — it's only for BYOD.
|
|
32
|
+
if (!storageUrl && !storageServiceKey) {
|
|
33
|
+
log('');
|
|
34
|
+
log(
|
|
35
|
+
` ${c.cyan('•')} No BYOD storage configured — ${c.dim('bootstrap is only needed for custom databases.')}`,
|
|
36
|
+
);
|
|
37
|
+
log('');
|
|
38
|
+
log(' Set LOREKIT_STORAGE_URL and LOREKIT_STORAGE_SERVICE_KEY to use your own');
|
|
39
|
+
log(' Supabase project, then re-run this command.');
|
|
40
|
+
log('');
|
|
41
|
+
log(' See docs/byod.md for setup instructions.');
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!storageUrl) {
|
|
46
|
+
err(`${c.red('Error:')} LOREKIT_STORAGE_SERVICE_KEY is set but LOREKIT_STORAGE_URL is missing.`);
|
|
47
|
+
err(' Both variables are required. Set LOREKIT_STORAGE_URL and try again.');
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Locate the bootstrap SQL file.
|
|
52
|
+
let sqlPath = BOOTSTRAP_SQL_PATH;
|
|
53
|
+
|
|
54
|
+
// When running from a published npm package, the SQL file is not bundled
|
|
55
|
+
// with the CLI. Fall back to looking for it relative to CWD (for dev use).
|
|
56
|
+
if (!fs.existsSync(sqlPath)) {
|
|
57
|
+
const cwdPath = path.resolve(process.cwd(), 'supabase/byod/bootstrap.sql');
|
|
58
|
+
if (fs.existsSync(cwdPath)) {
|
|
59
|
+
sqlPath = cwdPath;
|
|
60
|
+
} else {
|
|
61
|
+
err(`${c.red('Error:')} bootstrap.sql not found at expected path:`);
|
|
62
|
+
err(` ${sqlPath}`);
|
|
63
|
+
err('');
|
|
64
|
+
err(' Apply the schema manually with psql:');
|
|
65
|
+
err(' psql "$DATABASE_URL" -f supabase/byod/bootstrap.sql');
|
|
66
|
+
err('');
|
|
67
|
+
err(' Or download it from the LoreKit repository:');
|
|
68
|
+
err(' https://github.com/mthines/lorekit/blob/main/supabase/byod/bootstrap.sql');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
status('pass', 'bootstrap.sql', sqlPath);
|
|
74
|
+
status('info', 'storage url', storageUrl);
|
|
75
|
+
|
|
76
|
+
// Test connectivity using the anon key if available (non-DDL path).
|
|
77
|
+
if (storageAnonKey) {
|
|
78
|
+
try {
|
|
79
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
80
|
+
const db = createClient(storageUrl, storageAnonKey, {
|
|
81
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
82
|
+
});
|
|
83
|
+
// Simple ping: list memories (will return 0 rows or error if schema not applied yet).
|
|
84
|
+
const { error } = await db.from('memories').select('id').limit(1);
|
|
85
|
+
if (error && error.code === '42P01') {
|
|
86
|
+
// Table does not exist yet — that's expected before bootstrap.
|
|
87
|
+
status('info', 'connectivity', 'connected — schema not yet applied (run bootstrap)');
|
|
88
|
+
} else if (error) {
|
|
89
|
+
status('warn', 'connectivity', `connected but got: ${error.message}`);
|
|
90
|
+
} else {
|
|
91
|
+
status('pass', 'connectivity', 'connected and schema already present');
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
status('warn', 'connectivity', `could not verify: ${e && e.message ? e.message : String(e)}`);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
status('info', 'connectivity', 'skipped — LOREKIT_STORAGE_ANON_KEY not set');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Supabase JS client cannot execute raw DDL SQL directly.
|
|
101
|
+
// The correct path is psql or the Supabase dashboard SQL editor.
|
|
102
|
+
log('');
|
|
103
|
+
log(` ${c.bold('To apply the schema, run:')}`);
|
|
104
|
+
log('');
|
|
105
|
+
log(` ${c.cyan('psql "$DATABASE_URL" -f')} ${sqlPath}`);
|
|
106
|
+
log('');
|
|
107
|
+
log(` Or paste the contents of ${c.dim(sqlPath)}`);
|
|
108
|
+
log(' into the Supabase dashboard → SQL Editor.');
|
|
109
|
+
log('');
|
|
110
|
+
|
|
111
|
+
if (storageServiceKey) {
|
|
112
|
+
status(
|
|
113
|
+
'info',
|
|
114
|
+
'service key',
|
|
115
|
+
'LOREKIT_STORAGE_SERVICE_KEY is set — use it as $DATABASE_URL password with psql',
|
|
116
|
+
);
|
|
117
|
+
log('');
|
|
118
|
+
log(` ${c.dim('Example:')}`);
|
|
119
|
+
log(
|
|
120
|
+
` ${c.cyan('DATABASE_URL')}="postgresql://postgres.${parseRef(storageUrl)}:${storageServiceKey}@aws-0-us-east-1.pooler.supabase.com:6543/postgres"`,
|
|
121
|
+
);
|
|
122
|
+
log(` ${c.cyan('psql "$DATABASE_URL"')} -f ${sqlPath}`);
|
|
123
|
+
log('');
|
|
124
|
+
log(
|
|
125
|
+
` ${c.dim('(Replace the host/port above with the exact connection string from your')}`,
|
|
126
|
+
);
|
|
127
|
+
log(` ${c.dim('Supabase dashboard → Settings → Database → Connection string.)')}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Extract the project ref from a Supabase URL for display in the example.
|
|
134
|
+
function parseRef(url) {
|
|
135
|
+
try {
|
|
136
|
+
const host = new URL(url).hostname; // e.g. abcdefgh.supabase.co
|
|
137
|
+
return host.split('.')[0];
|
|
138
|
+
} catch {
|
|
139
|
+
return '<project-ref>';
|
|
140
|
+
}
|
|
141
|
+
}
|
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
|
@@ -98,6 +98,9 @@ export async function doctor(args) {
|
|
|
98
98
|
await checkRemote(control, root, args, record);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// 4b. BYOD storage connectivity check.
|
|
102
|
+
await checkBYODStorage(record);
|
|
103
|
+
|
|
101
104
|
// 5. Scope.
|
|
102
105
|
const scope = deriveScope(root);
|
|
103
106
|
if (scope.hasRemote) {
|
|
@@ -351,6 +354,40 @@ function hooksForEvent(hooksObj, event) {
|
|
|
351
354
|
return commands;
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
async function checkBYODStorage(record) {
|
|
358
|
+
const storageUrl = process.env['LOREKIT_STORAGE_URL'];
|
|
359
|
+
const storageAnonKey = process.env['LOREKIT_STORAGE_ANON_KEY'];
|
|
360
|
+
|
|
361
|
+
if (!storageUrl && !storageAnonKey) {
|
|
362
|
+
return; // No BYOD configured — skip silently.
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (storageUrl && !storageAnonKey) {
|
|
366
|
+
record('fail', 'byod storage', 'LOREKIT_STORAGE_URL is set but LOREKIT_STORAGE_ANON_KEY is missing');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!storageUrl && storageAnonKey) {
|
|
371
|
+
record('fail', 'byod storage', 'LOREKIT_STORAGE_ANON_KEY is set but LOREKIT_STORAGE_URL is missing');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
377
|
+
const db = createClient(storageUrl, storageAnonKey, {
|
|
378
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
379
|
+
});
|
|
380
|
+
const { error } = await db.from('memories').select('id').limit(1);
|
|
381
|
+
if (error && error.code !== '42P01') {
|
|
382
|
+
record('fail', 'byod storage', `connectivity error: ${error.message}`);
|
|
383
|
+
} else {
|
|
384
|
+
record('pass', 'byod storage', `ok — ${storageUrl}`);
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {
|
|
387
|
+
record('fail', 'byod storage', `could not connect: ${e && e.message ? e.message : String(e)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
354
391
|
function gitTracked(root, dir) {
|
|
355
392
|
// Heuristic: is the store dir ignored by git? If `git check-ignore` names it,
|
|
356
393
|
// it is private; otherwise it will be committed (team-shared).
|
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
|
+
}
|