@lorekit/cli 1.18.0 → 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 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). --json. Usage: show <scope> <key>.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.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/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
  }
@@ -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
- // Both positionals are required: `show <scope> <key>`.
44
- const scope = typeof args._[1] === 'string' ? args._[1] : '';
45
- const key = typeof args._[2] === 'string' ? args._[2] : '';
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
+ }