@lorekit/cli 1.0.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/README.md +320 -0
- package/bin/lorekit.mjs +117 -0
- package/package.json +36 -0
- package/skill/lorekit-memory/SKILL.md +139 -0
- package/skill/lorekit-memory/references/scope-resolution.md +65 -0
- package/skill/lorekit-memory/rules/intake.md +61 -0
- package/skill/lorekit-memory/rules/retrospective.md +85 -0
- package/src/adapters/claude.mjs +41 -0
- package/src/adapters/codex.mjs +36 -0
- package/src/adapters/cursor.mjs +42 -0
- package/src/config.mjs +132 -0
- package/src/control.mjs +152 -0
- package/src/core/failure.mjs +28 -0
- package/src/core/lessons.mjs +60 -0
- package/src/core/record.mjs +27 -0
- package/src/core/state.mjs +27 -0
- package/src/doctor.mjs +251 -0
- package/src/hook.mjs +106 -0
- package/src/install.mjs +95 -0
- package/src/mcp-server.mjs +233 -0
- package/src/mcp.mjs +89 -0
- package/src/migrate.mjs +149 -0
- package/src/scope.mjs +59 -0
- package/src/store/format.mjs +99 -0
- package/src/store/index.mjs +18 -0
- package/src/store/local.mjs +313 -0
- package/src/store/remote.mjs +90 -0
- package/src/util.mjs +77 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Local file store: markdown lessons under a store directory (default
|
|
2
|
+
// `.lorekit/`). One file per scope+key. Implements the common store contract
|
|
3
|
+
// over the filesystem. Zero-dependency (node built-ins only).
|
|
4
|
+
//
|
|
5
|
+
// A `TwoTierStore` (below) composes two `LocalStore`s — a per-user `home` tier
|
|
6
|
+
// and an opt-in per-repo `project` tier — behind the same contract, so callers
|
|
7
|
+
// (`core/lessons`, `hook`, `doctor`, `migrate`) keep talking to one interface.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
|
|
11
|
+
|
|
12
|
+
export function createLocalStore(baseDir) {
|
|
13
|
+
return new LocalStore(baseDir);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class LocalStore {
|
|
17
|
+
constructor(baseDir) {
|
|
18
|
+
this.baseDir = baseDir;
|
|
19
|
+
this.mode = 'local';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_dir(scope) {
|
|
23
|
+
return scopeToDir(this.baseDir, scope);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_files(scope) {
|
|
27
|
+
const dir = this._dir(scope);
|
|
28
|
+
let names;
|
|
29
|
+
try {
|
|
30
|
+
names = fs.readdirSync(dir);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return names.filter((n) => n.endsWith('.md')).map((n) => path.join(dir, n));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_readAll(scope) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const file of this._files(scope)) {
|
|
40
|
+
try {
|
|
41
|
+
const entry = parseEntry(fs.readFileSync(file, 'utf8'));
|
|
42
|
+
if (entry) out.push({ entry, file });
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip an unreadable file rather than fail the whole listing.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_findByKey(scope, key) {
|
|
51
|
+
return this._readAll(scope).find((r) => r.entry.key === key) || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Raw lookup by scope+key — returns the stored entry regardless of archived
|
|
55
|
+
// state (unlike read(), which hides archived). Synchronous; used by migrate
|
|
56
|
+
// to classify ADD / UPDATE / NOOP without reviving archived entries.
|
|
57
|
+
getEntry({ scope, key } = {}) {
|
|
58
|
+
const found = this._findByKey(scope, key);
|
|
59
|
+
return found ? found.entry : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// list({ scope, tags, limit }) → { ok, entries } — newest-first, tag-filtered,
|
|
63
|
+
// archived hidden.
|
|
64
|
+
async list({ scope, tags, limit } = {}) {
|
|
65
|
+
let rows = this._readAll(scope)
|
|
66
|
+
.map((r) => r.entry)
|
|
67
|
+
.filter((e) => !e.archived_at);
|
|
68
|
+
if (Array.isArray(tags) && tags.length) {
|
|
69
|
+
rows = rows.filter((e) => tags.every((t) => (e.tags || []).includes(t)));
|
|
70
|
+
}
|
|
71
|
+
rows.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || '')));
|
|
72
|
+
if (limit) rows = rows.slice(0, limit);
|
|
73
|
+
return { ok: true, entries: rows };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// read({ scope, key }) → { ok, entry } — null when absent or archived.
|
|
77
|
+
async read({ scope, key } = {}) {
|
|
78
|
+
const found = this._findByKey(scope, key);
|
|
79
|
+
const entry = found && !found.entry.archived_at ? found.entry : null;
|
|
80
|
+
return { ok: true, entry };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
|
|
84
|
+
// refreshes `updated`; writing an archived key revives it.
|
|
85
|
+
async write({ scope, key, value, tags, source_agent, trigger } = {}) {
|
|
86
|
+
const dir = this._dir(scope);
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const existing = this._findByKey(scope, key);
|
|
90
|
+
const entry = {
|
|
91
|
+
scope,
|
|
92
|
+
key,
|
|
93
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
94
|
+
source_agent: source_agent || null,
|
|
95
|
+
trigger: trigger || null,
|
|
96
|
+
created: existing ? existing.entry.created || now : now,
|
|
97
|
+
updated: now,
|
|
98
|
+
archived_at: null,
|
|
99
|
+
value: value == null ? '' : String(value),
|
|
100
|
+
};
|
|
101
|
+
const file = existing ? existing.file : this._freshPath(dir, key);
|
|
102
|
+
fs.writeFileSync(file, serializeEntry(entry));
|
|
103
|
+
return { ok: true, entry };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// putEntry(entry) — verbatim upsert by scope+key. Unlike write(), it does NOT
|
|
107
|
+
// synthesize timestamps or clear archived_at: every field of the given entry
|
|
108
|
+
// is preserved as-is. This is the lossless primitive migrate uses to relocate
|
|
109
|
+
// a store (including archived entries) idempotently.
|
|
110
|
+
async putEntry(entry = {}) {
|
|
111
|
+
const scope = entry.scope;
|
|
112
|
+
const dir = this._dir(scope);
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const existing = this._findByKey(scope, entry.key);
|
|
116
|
+
const full = {
|
|
117
|
+
scope,
|
|
118
|
+
key: entry.key,
|
|
119
|
+
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
120
|
+
source_agent: entry.source_agent ?? null,
|
|
121
|
+
trigger: entry.trigger ?? null,
|
|
122
|
+
created: entry.created ?? now,
|
|
123
|
+
updated: entry.updated ?? now,
|
|
124
|
+
archived_at: entry.archived_at ?? null,
|
|
125
|
+
value: entry.value == null ? '' : String(entry.value),
|
|
126
|
+
};
|
|
127
|
+
const file = existing ? existing.file : this._freshPath(dir, entry.key);
|
|
128
|
+
fs.writeFileSync(file, serializeEntry(full));
|
|
129
|
+
return { ok: true, entry: full };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_freshPath(dir, key) {
|
|
133
|
+
const base = slugify(key);
|
|
134
|
+
let name = `${base}.md`;
|
|
135
|
+
let i = 2;
|
|
136
|
+
while (fs.existsSync(path.join(dir, name))) name = `${base}-${i++}.md`;
|
|
137
|
+
return path.join(dir, name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// delete({ scope, key, force }) — force removes the file; soft-delete archives.
|
|
141
|
+
async delete({ scope, key, force } = {}) {
|
|
142
|
+
const found = this._findByKey(scope, key);
|
|
143
|
+
if (!found) return { ok: true, deleted: false };
|
|
144
|
+
if (force) {
|
|
145
|
+
try {
|
|
146
|
+
fs.unlinkSync(found.file);
|
|
147
|
+
} catch {
|
|
148
|
+
// Already gone — treat as deleted.
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, deleted: true };
|
|
151
|
+
}
|
|
152
|
+
return this.archive({ scope, key });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async archive({ scope, key } = {}) {
|
|
156
|
+
return this._setArchived(scope, key, new Date().toISOString());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async restore({ scope, key } = {}) {
|
|
160
|
+
return this._setArchived(scope, key, null);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_setArchived(scope, key, ts) {
|
|
164
|
+
const found = this._findByKey(scope, key);
|
|
165
|
+
if (!found) return { ok: true, archived: false };
|
|
166
|
+
const entry = { ...found.entry, archived_at: ts, updated: new Date().toISOString() };
|
|
167
|
+
fs.writeFileSync(found.file, serializeEntry(entry));
|
|
168
|
+
return { ok: true, archived: ts != null, entry };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// search({ q, scopes, tags }) → { ok, entries } — keyword over key/tags/body.
|
|
172
|
+
async search({ q, scopes, tags } = {}) {
|
|
173
|
+
const needle = String(q || '').toLowerCase();
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const scope of scopes || []) {
|
|
176
|
+
const { entries } = await this.list({ scope, tags });
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
const hay = `${e.key}\n${(e.tags || []).join(' ')}\n${e.value || ''}`.toLowerCase();
|
|
179
|
+
if (!needle || hay.includes(needle)) out.push(e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, entries: out };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Total non-archived entries across the given scopes (doctor uses this).
|
|
186
|
+
async count(scopes) {
|
|
187
|
+
let n = 0;
|
|
188
|
+
for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A scope string is global when its type segment is `global`.
|
|
194
|
+
function isGlobalScope(scope) {
|
|
195
|
+
return String(scope).split('::')[0] === 'global';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Union two entry lists, keyed by `keyFn`. The first list (winners) shadows the
|
|
199
|
+
// second (losers) on a key collision — so passing the project tier first makes
|
|
200
|
+
// the closer scope win, mirroring the remote narrow→broad merge in fetchLessons.
|
|
201
|
+
function mergeByKey(winners, losers, keyFn) {
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const list of [winners, losers]) {
|
|
205
|
+
for (const e of list) {
|
|
206
|
+
const k = keyFn(e);
|
|
207
|
+
if (seen.has(k)) continue;
|
|
208
|
+
seen.add(k);
|
|
209
|
+
out.push(e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createTwoTierStore({ home, project } = {}) {
|
|
216
|
+
return new TwoTierStore({ home, project });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Two-tier local store: a per-user `home` tier (always available) and an opt-in
|
|
220
|
+
// per-repo `project` tier (active only when its directory exists). Presents the
|
|
221
|
+
// same contract as a single LocalStore:
|
|
222
|
+
// - reads (list / read / search) union both tiers, project shadowing home;
|
|
223
|
+
// - writes route by scope — global → home, everything else → project when
|
|
224
|
+
// opted-in, else home;
|
|
225
|
+
// - delete / archive / restore act on the tier that holds the visible entry
|
|
226
|
+
// (project first, then home).
|
|
227
|
+
class TwoTierStore {
|
|
228
|
+
constructor({ home, project } = {}) {
|
|
229
|
+
this.mode = 'local';
|
|
230
|
+
this.homeDir = home;
|
|
231
|
+
this.projectDir = project || null;
|
|
232
|
+
this.home = new LocalStore(home);
|
|
233
|
+
this.project = this.projectDir ? new LocalStore(this.projectDir) : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The project tier is opted-in when its directory exists (checked live, so a
|
|
237
|
+
// freshly-created `.lorekit/` or a migrate --to project takes effect at once).
|
|
238
|
+
projectActive() {
|
|
239
|
+
return Boolean(this.project && this.projectDir && fs.existsSync(this.projectDir));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The LocalStore a write for `scope` should target.
|
|
243
|
+
tierFor(scope) {
|
|
244
|
+
if (isGlobalScope(scope)) return this.home;
|
|
245
|
+
return this.projectActive() ? this.project : this.home;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async list({ scope, tags, limit } = {}) {
|
|
249
|
+
const homeRes = await this.home.list({ scope, tags });
|
|
250
|
+
const projRes = this.projectActive()
|
|
251
|
+
? await this.project.list({ scope, tags })
|
|
252
|
+
: { entries: [] };
|
|
253
|
+
const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => e.key);
|
|
254
|
+
merged.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || '')));
|
|
255
|
+
return { ok: true, entries: limit ? merged.slice(0, limit) : merged };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async read({ scope, key } = {}) {
|
|
259
|
+
if (this.projectActive()) {
|
|
260
|
+
const r = await this.project.read({ scope, key });
|
|
261
|
+
if (r.entry) return r;
|
|
262
|
+
}
|
|
263
|
+
return this.home.read({ scope, key });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async write(args = {}) {
|
|
267
|
+
return this.tierFor(args.scope).write(args);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async putEntry(entry = {}) {
|
|
271
|
+
return this.tierFor(entry.scope).putEntry(entry);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async delete({ scope, key, force } = {}) {
|
|
275
|
+
if (this.projectActive()) {
|
|
276
|
+
const r = await this.project.delete({ scope, key, force });
|
|
277
|
+
if (r.deleted || r.archived) return r;
|
|
278
|
+
}
|
|
279
|
+
return this.home.delete({ scope, key, force });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async archive({ scope, key } = {}) {
|
|
283
|
+
if (this.projectActive()) {
|
|
284
|
+
const r = await this.project.archive({ scope, key });
|
|
285
|
+
if (r.entry) return r;
|
|
286
|
+
}
|
|
287
|
+
return this.home.archive({ scope, key });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async restore({ scope, key } = {}) {
|
|
291
|
+
if (this.projectActive()) {
|
|
292
|
+
const r = await this.project.restore({ scope, key });
|
|
293
|
+
if (r.entry) return r;
|
|
294
|
+
}
|
|
295
|
+
return this.home.restore({ scope, key });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async search({ q, scopes, tags } = {}) {
|
|
299
|
+
const homeRes = await this.home.search({ q, scopes, tags });
|
|
300
|
+
const projRes = this.projectActive()
|
|
301
|
+
? await this.project.search({ q, scopes, tags })
|
|
302
|
+
: { entries: [] };
|
|
303
|
+
const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => `${e.scope}${e.key}`);
|
|
304
|
+
return { ok: true, entries: merged };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Merged, de-duplicated non-archived count across the given scopes.
|
|
308
|
+
async count(scopes) {
|
|
309
|
+
let n = 0;
|
|
310
|
+
for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
|
|
311
|
+
return n;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Remote store: wraps the LoreKit MCP `memory.*` tools behind the common store
|
|
2
|
+
// contract. Behaviour is identical to the previous direct `mcpCall` usage —
|
|
3
|
+
// this only relocates it behind the interface. Zero-dependency.
|
|
4
|
+
import { mcpCall } from '../mcp.mjs';
|
|
5
|
+
|
|
6
|
+
// LoreKit returns tool output as { content: [{ type:'text', text:'<json>' }] }.
|
|
7
|
+
function unwrap(result) {
|
|
8
|
+
if (!result) return null;
|
|
9
|
+
if (Array.isArray(result.content)) {
|
|
10
|
+
const text = result.content.map((c) => (c && c.text) || '').join('');
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(text);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Drop undefined/null args so the JSON-RPC payload matches the old direct calls
|
|
21
|
+
// (e.g. `memory.list` with only { scope, limit }).
|
|
22
|
+
function clean(obj) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [k, v] of Object.entries(obj || {})) if (v !== undefined && v !== null) out[k] = v;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRemoteStore({ endpoint, token } = {}) {
|
|
29
|
+
return new RemoteStore(endpoint, token);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class RemoteStore {
|
|
33
|
+
constructor(endpoint, token) {
|
|
34
|
+
this.endpoint = endpoint;
|
|
35
|
+
this.token = token;
|
|
36
|
+
this.mode = 'remote';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
usable() {
|
|
40
|
+
return Boolean(this.endpoint && this.token && !String(this.endpoint).includes('<project-ref>'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async _call(name, args) {
|
|
44
|
+
if (!this.usable()) return { ok: false, unusable: true };
|
|
45
|
+
return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_entries(res) {
|
|
49
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
50
|
+
const payload = unwrap(res.result);
|
|
51
|
+
const entries = payload && Array.isArray(payload.entries) ? payload.entries : [];
|
|
52
|
+
return { ok: true, entries };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async list({ scope, tags, limit } = {}) {
|
|
56
|
+
return this._entries(await this._call('memory.list', clean({ scope, tags, limit })));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async search({ q, scopes, tags } = {}) {
|
|
60
|
+
return this._entries(await this._call('memory.search', clean({ q, scopes, tags })));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async read({ scope, key } = {}) {
|
|
64
|
+
const res = await this._call('memory.read', { scope, key });
|
|
65
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
66
|
+
const payload = unwrap(res.result);
|
|
67
|
+
return { ok: true, entry: payload && payload.entry ? payload.entry : payload };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async write(args = {}) {
|
|
71
|
+
const res = await this._call('memory.write', clean(args));
|
|
72
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError, result: res.result };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async delete({ scope, key, force } = {}) {
|
|
76
|
+
const res = await this._call('memory.delete', { scope, key, force: Boolean(force) });
|
|
77
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async archive({ scope, key } = {}) {
|
|
81
|
+
const res = await this._call('memory.archive', { scope, key });
|
|
82
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Connectivity probe for doctor — a transport check, not a memory op.
|
|
86
|
+
async ping() {
|
|
87
|
+
if (!this.usable()) return { ok: false, unusable: true };
|
|
88
|
+
return mcpCall(this.endpoint, this.token, 'tools/list', {});
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Tiny zero-dependency console helpers. No colors when not a TTY (CI-friendly).
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
|
|
5
|
+
|
|
6
|
+
const wrap = (code) => (s) => (useColor ? `[${code}m${s}[0m` : String(s));
|
|
7
|
+
|
|
8
|
+
export const c = {
|
|
9
|
+
bold: wrap('1'),
|
|
10
|
+
dim: wrap('2'),
|
|
11
|
+
red: wrap('31'),
|
|
12
|
+
green: wrap('32'),
|
|
13
|
+
yellow: wrap('33'),
|
|
14
|
+
cyan: wrap('36'),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const sym = {
|
|
18
|
+
pass: useColor ? c.green('✓') : 'PASS',
|
|
19
|
+
fail: useColor ? c.red('✗') : 'FAIL',
|
|
20
|
+
warn: useColor ? c.yellow('!') : 'WARN',
|
|
21
|
+
info: useColor ? c.cyan('•') : '-',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function log(msg = '') {
|
|
25
|
+
process.stdout.write(`${msg}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function err(msg = '') {
|
|
29
|
+
process.stderr.write(`${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function heading(title) {
|
|
33
|
+
log(`\n${c.bold(title)}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A single doctor-style status line.
|
|
37
|
+
export function status(kind, label, detail) {
|
|
38
|
+
const mark = sym[kind] ?? sym.info;
|
|
39
|
+
const tail = detail ? ` ${c.dim('— ' + detail)}` : '';
|
|
40
|
+
log(` ${mark} ${label}${tail}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Minimal flag parser: --key value, --key=value, -k value, and bare --flags.
|
|
44
|
+
// `aliases` maps short → long; `booleans` lists flags that take no value.
|
|
45
|
+
export function parseArgs(argv, { aliases = {}, booleans = [] } = {}) {
|
|
46
|
+
const out = { _: [] };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
let token = argv[i];
|
|
49
|
+
if (!token.startsWith('-')) {
|
|
50
|
+
out._.push(token);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let value;
|
|
54
|
+
const eq = token.indexOf('=');
|
|
55
|
+
if (eq !== -1) {
|
|
56
|
+
value = token.slice(eq + 1);
|
|
57
|
+
token = token.slice(0, eq);
|
|
58
|
+
}
|
|
59
|
+
let key = token.replace(/^-+/, '');
|
|
60
|
+
if (aliases[key]) key = aliases[key];
|
|
61
|
+
if (booleans.includes(key)) {
|
|
62
|
+
out[key] = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
const next = argv[i + 1];
|
|
67
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
68
|
+
value = next;
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
value = true; // treat as boolean-ish when no value follows
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
out[key] = value;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|