runlist 0.96.0 → 0.97.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 +170 -43
- package/assets/app/app.css +189 -3
- package/assets/app/app.mjs +49 -31
- package/assets/app/document-create.mjs +3 -2
- package/assets/app/document-lifecycle.mjs +17 -8
- package/assets/app/document-stages.mjs +3 -1
- package/assets/app/document-views.mjs +23 -1
- package/assets/app/editor-navigation.mjs +16 -23
- package/assets/app/filing-navigation.mjs +18 -16
- package/assets/app/git-changes.mjs +11 -2
- package/assets/app/index.html +34 -23
- package/assets/app/library-navigation.mjs +29 -16
- package/assets/app/record-navigation.mjs +68 -23
- package/assets/app/recovery-center.mjs +8 -7
- package/assets/app/settings.mjs +48 -10
- package/assets/app/template-editor.mjs +22 -12
- package/assets/app/transport.mjs +1 -2
- package/bin/runlist.mjs +64 -15
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
- package/src/app.mjs +9 -4
- package/src/baton.mjs +49 -11
- package/src/brief.mjs +145 -0
- package/src/commands.mjs +4 -3
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/extractors.mjs +7 -14
- package/src/flags.mjs +5 -3
- package/src/hud.mjs +32 -63
- package/src/index.mjs +2 -0
- package/src/install.mjs +6 -4
- package/src/installation.mjs +117 -0
- package/src/new.mjs +27 -7
- package/src/prompt-handoff.mjs +132 -0
- package/src/prompts.mjs +112 -16
- package/src/section.mjs +46 -11
- package/src/show.mjs +132 -16
- package/src/template-store.mjs +16 -5
- package/src/update.mjs +7 -28
- package/src/util.mjs +7 -0
package/src/prompts.mjs
CHANGED
|
@@ -18,11 +18,13 @@ import {
|
|
|
18
18
|
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
19
19
|
import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
|
|
20
20
|
import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
|
|
21
|
+
import { groupPrompts, parentPromptName } from './prompt-handoff.mjs';
|
|
22
|
+
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
21
23
|
|
|
22
24
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
23
25
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
24
26
|
// canonical output ("Consumed: …") is unchanged.
|
|
25
|
-
const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new']);
|
|
27
|
+
const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'link']);
|
|
26
28
|
|
|
27
29
|
// Removed 2026-08-30 with the `held`/`shelved` statuses. Named rather than left
|
|
28
30
|
// to fall through to the list filter below, which would silently answer a
|
|
@@ -53,9 +55,75 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
53
55
|
case 'peek': return runPromptsShow(rest, config);
|
|
54
56
|
case 'archive': return runPromptsArchive(rest, config, opts);
|
|
55
57
|
case 'new': return runPromptsNew(rest, config, opts);
|
|
58
|
+
case 'link': return runPromptsLink(rest, config, opts);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
function runPromptsLink(argv, config, opts) {
|
|
63
|
+
let parentInput = null;
|
|
64
|
+
const inputs = [];
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
if (argv[i] === '--parent') { parentInput = parentPromptName(argv[++i]); continue; }
|
|
67
|
+
if (argv[i] === '--json') continue;
|
|
68
|
+
if (argv[i].startsWith('-')) die(`Unknown link option: ${argv[i]}`);
|
|
69
|
+
inputs.push(argv[i]);
|
|
70
|
+
}
|
|
71
|
+
if (!parentInput || !inputs.length) die('Usage: runlist prompts link <child...> --parent resume-name');
|
|
72
|
+
const parentPath = authorizeManagedSource(resolvePromptInput(parentInput, config), config, { kind: 'Parent prompt' }).path;
|
|
73
|
+
const parentRaw = readFileSync(parentPath, 'utf8');
|
|
74
|
+
const parentFm = parseSimpleFrontmatter(extractFrontmatter(parentRaw).frontmatter);
|
|
75
|
+
if (asString(parentFm.type) !== 'prompt' || asString(parentFm.status) !== 'pending'
|
|
76
|
+
|| isArchivedPath(toRepoPath(parentPath, config.repoRoot), config) || asString(parentFm.parent_prompt)
|
|
77
|
+
|| path.basename(parentPath, '.md') !== parentInput) die('The parent must be an existing pending resume prompt without its own parent.');
|
|
78
|
+
const updates = [];
|
|
79
|
+
const paths = new Set();
|
|
80
|
+
for (const input of inputs) {
|
|
81
|
+
const file = authorizeManagedSource(resolvePromptInput(input, config), config, { kind: 'Child prompt' }).path;
|
|
82
|
+
if (paths.has(file)) continue;
|
|
83
|
+
paths.add(file);
|
|
84
|
+
const raw = readFileSync(file, 'utf8');
|
|
85
|
+
const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter);
|
|
86
|
+
if (file === parentPath || path.basename(file).startsWith('resume-') || asString(fm.type) !== 'prompt'
|
|
87
|
+
|| asString(fm.status) !== 'pending' || isArchivedPath(toRepoPath(file, config.repoRoot), config)) {
|
|
88
|
+
die(`Child must be a pending non-resume prompt: ${toRepoPath(file, config.repoRoot)}`);
|
|
89
|
+
}
|
|
90
|
+
const ref = path.dirname(file) === path.dirname(parentPath) ? parentInput : toRepoPath(parentPath, config.repoRoot);
|
|
91
|
+
const header = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(raw);
|
|
92
|
+
if (!header || (header[2].match(/^parent_prompt:/gm) ?? []).length > 1) die(`Repair the parent_prompt frontmatter first: ${input}`);
|
|
93
|
+
if (asString(fm.parent_prompt) === ref) continue;
|
|
94
|
+
const eol = header[1].endsWith('\r\n') ? '\r\n' : '\n';
|
|
95
|
+
const field = `parent_prompt: ${JSON.stringify(ref)}`;
|
|
96
|
+
const parentField = /^parent_prompt:[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*/m;
|
|
97
|
+
const frontmatter = parentField.test(header[2]) ? header[2].replace(parentField, field) : `${header[2]}${eol}${field}`;
|
|
98
|
+
updates.push({ path: file, expectedContent: raw, content: `${header[1]}${frontmatter}${header[3]}${raw.slice(header[0].length)}` });
|
|
99
|
+
}
|
|
100
|
+
if (!opts.dryRun && updates.length) mutateFileSet({ updates, guards: [{ path: parentPath, expectedContent: parentRaw }] }, {
|
|
101
|
+
repoRoot: config.repoRoot, testHooks: opts.testHooks,
|
|
102
|
+
});
|
|
103
|
+
const result = { operation: 'prompts-link', dryRun: Boolean(opts.dryRun), parent: toRepoPath(parentPath, config.repoRoot),
|
|
104
|
+
linked: updates.map(item => toRepoPath(item.path, config.repoRoot)) };
|
|
105
|
+
process.stdout.write(argv.includes('--json') ? JSON.stringify(result, null, 2) + '\n'
|
|
106
|
+
: `${opts.dryRun ? 'Would link' : 'Linked'} ${updates.length} child prompt(s) to ${parentInput}.\n`);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function childHandoffs(repoPath, config, index = null) {
|
|
111
|
+
if (!path.basename(repoPath).startsWith('resume-')) return { paths: [], notice: '' };
|
|
112
|
+
index ??= buildIndex(config, { fast: true, invokeHooks: false });
|
|
113
|
+
const docs = index.docs.filter(doc => doc.type === 'prompt' && doc.status !== 'archived' && !isArchivedPath(doc.path, config));
|
|
114
|
+
// Consumption removes a parent from the live index. A read-only peek at its
|
|
115
|
+
// archive can still discover children linked to its former live location.
|
|
116
|
+
const livePath = repoPath.split('/').filter(part => part !== config.archiveDir).join('/');
|
|
117
|
+
if (livePath !== repoPath && !docs.some(doc => doc.path === livePath)) {
|
|
118
|
+
docs.push({ path: livePath });
|
|
119
|
+
}
|
|
120
|
+
const children = groupPrompts(docs.sort(comparePromptDocs)).find(group => group.doc.path === livePath)?.children ?? [];
|
|
121
|
+
if (!children.length) return { paths: [], notice: '' };
|
|
122
|
+
const parent = path.basename(repoPath, '.md');
|
|
123
|
+
const names = children.map(doc => `- \`${doc.path}\``).join('\n');
|
|
124
|
+
return { paths: children.map(doc => doc.path), notice: `\n\n## Saved child handoffs\n\n${children.length} child handoff(s) remain pending:\n\n${names}\n\nInspect a child with \`rl prompts show <name>\`. Review its current state and authorization before resuming its lane. Fold completed results into the owning plan, then archive the completed handoff explicitly.\n\nPass \`--parent ${parent}\` when saving another child, or pass \`RUNLIST_PARENT_PROMPT=${parent}\` into its environment.\n` };
|
|
125
|
+
}
|
|
126
|
+
|
|
59
127
|
function runPromptsList(argv, config, opts = {}) {
|
|
60
128
|
// Listing prompts renders slug / status / updated / target — nothing any
|
|
61
129
|
// validating pass produces, and `runQuery` never reads warnings either. A
|
|
@@ -111,13 +179,29 @@ function renderPromptQueueList(index, config) {
|
|
|
111
179
|
const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
|
|
112
180
|
process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
|
|
113
181
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
const groups = groupPrompts(prompts);
|
|
183
|
+
const isAgentHandoff = doc => !path.basename(doc.path).startsWith('resume-')
|
|
184
|
+
&& (doc.parentPrompt || /^(?:subagent|agent|session)-/.test(path.basename(doc.path)));
|
|
185
|
+
const standalone = groups.filter(group => isAgentHandoff(group.doc));
|
|
186
|
+
const visible = groups.filter(group => !isAgentHandoff(group.doc));
|
|
187
|
+
const maxSlug = Math.min(36, Math.max(0, ...visible.map(({ doc }) => path.basename(doc.path, '.md').length)));
|
|
188
|
+
const nextPath = queue[0]?.doc.path;
|
|
189
|
+
const childCount = children => `[+ ${children.length} subagent prompt${children.length === 1 ? '' : 's'}]`;
|
|
190
|
+
for (const { doc: p, children } of visible) {
|
|
117
191
|
const slug = path.basename(p.path, '.md').padEnd(maxSlug);
|
|
118
|
-
const marker =
|
|
192
|
+
const marker = p.path === nextPath ? green('[NEXT]') : ' ';
|
|
119
193
|
const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
|
|
120
|
-
|
|
194
|
+
const childMarker = children.some(doc => doc.path === nextPath) ? `${green('[NEXT]')} ` : '';
|
|
195
|
+
const badge = children.length ? ` ${childMarker}${dim(childCount(children))}` : '';
|
|
196
|
+
process.stdout.write(` ${marker} ${slug} ${status}${badge}\n`);
|
|
197
|
+
}
|
|
198
|
+
if (standalone.length) {
|
|
199
|
+
const children = standalone.map(group => group.doc);
|
|
200
|
+
const marker = children.some(doc => doc.path === nextPath) ? green('[NEXT]') : ' ';
|
|
201
|
+
process.stdout.write(` ${marker} ${childCount(children)}\n`);
|
|
202
|
+
}
|
|
203
|
+
if (standalone.length || visible.some(group => group.children.length)) {
|
|
204
|
+
process.stdout.write(dim('\nUse `rl prompts --verbose` to expand subagent prompts.\n'));
|
|
121
205
|
}
|
|
122
206
|
}
|
|
123
207
|
|
|
@@ -173,12 +257,17 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
173
257
|
const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
|
|
174
258
|
process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
|
|
175
259
|
|
|
176
|
-
|
|
260
|
+
const render = (p, child) => {
|
|
177
261
|
const slug = path.basename(p.path, '.md');
|
|
178
262
|
const target = findPromptTarget(p, config);
|
|
179
263
|
const status = (p.status ?? 'unknown').toUpperCase();
|
|
180
264
|
const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
|
|
181
|
-
|
|
265
|
+
const indent = child ? ' - ' : ' ';
|
|
266
|
+
process.stdout.write(`${indent}${green(slug)} [${status}]\n${child ? ' ' : ''}${arrow}\n`);
|
|
267
|
+
};
|
|
268
|
+
for (const {doc,children} of groupPrompts(prompts)) {
|
|
269
|
+
render(doc, false);
|
|
270
|
+
for (const child of children) render(child, true);
|
|
182
271
|
}
|
|
183
272
|
}
|
|
184
273
|
|
|
@@ -281,6 +370,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
281
370
|
if (status === 'archived' || isArchivedPath(repoPath, config)) {
|
|
282
371
|
die(`Already consumed: ${repoPath}`);
|
|
283
372
|
}
|
|
373
|
+
const family = childHandoffs(repoPath, config);
|
|
284
374
|
|
|
285
375
|
const planRef = asString(parsed.plan);
|
|
286
376
|
let linkedClaim = null;
|
|
@@ -297,12 +387,13 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
297
387
|
const prefix = dim('[dry-run]');
|
|
298
388
|
process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
299
389
|
if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
|
|
300
|
-
const
|
|
301
|
-
const
|
|
390
|
+
const previewBody = body + family.notice;
|
|
391
|
+
const bytes = Buffer.byteLength(previewBody, 'utf8');
|
|
392
|
+
const lines = previewBody.split('\n').length;
|
|
302
393
|
process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
|
|
303
394
|
process.stderr.write(`${dim('---8<---')}\n`);
|
|
304
|
-
process.stderr.write(
|
|
305
|
-
if (!
|
|
395
|
+
process.stderr.write(previewBody);
|
|
396
|
+
if (!previewBody.endsWith('\n')) process.stderr.write('\n');
|
|
306
397
|
process.stderr.write(`${dim('--->8---')}\n`);
|
|
307
398
|
runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr, skipInboundRefs: true });
|
|
308
399
|
return;
|
|
@@ -334,7 +425,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
334
425
|
// Consume output is at-most-once: archive/claim commits before stdout. A
|
|
335
426
|
// downstream failure cannot roll the transaction back or make it consumable
|
|
336
427
|
// again; the archived path remains available through `prompts show`.
|
|
337
|
-
await writeConsumedBody(consumedBody, consumedPath, opts.writeBody, linkedClaim?.repoPath);
|
|
428
|
+
await writeConsumedBody(consumedBody + family.notice, consumedPath, opts.writeBody, linkedClaim?.repoPath);
|
|
338
429
|
let completion = { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
|
|
339
430
|
if (linkedClaim) {
|
|
340
431
|
try {
|
|
@@ -375,6 +466,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
375
466
|
].filter(Boolean).map(normalize))];
|
|
376
467
|
return {
|
|
377
468
|
operation: 'consume',
|
|
469
|
+
childPrompts: family.paths,
|
|
378
470
|
status: { from: status ?? null, to: 'archived', changed: true },
|
|
379
471
|
repositoryFiles,
|
|
380
472
|
sessionFiles,
|
|
@@ -550,6 +642,7 @@ function runPromptsShow(argv, config) {
|
|
|
550
642
|
// prompt keep working.
|
|
551
643
|
const multi = targets.length > 1;
|
|
552
644
|
let shown = 0;
|
|
645
|
+
let familyIndex = null;
|
|
553
646
|
for (const filePath of targets) {
|
|
554
647
|
const prompt = readPromptForShow(filePath, config, { strict: !all && !multi });
|
|
555
648
|
if (!prompt) continue;
|
|
@@ -558,10 +651,13 @@ function runPromptsShow(argv, config) {
|
|
|
558
651
|
if (shown > 0) process.stdout.write('\n');
|
|
559
652
|
process.stdout.write(dim(`──── ${repoPath} [${status}] ────\n`));
|
|
560
653
|
} else {
|
|
561
|
-
|
|
654
|
+
const hint = status === 'archived' || isArchivedPath(repoPath, config) ? 'archived prompt' : `\`runlist use ${repoPath}\` to consume`;
|
|
655
|
+
process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; ${hint}\n`));
|
|
562
656
|
}
|
|
563
|
-
|
|
564
|
-
|
|
657
|
+
if (path.basename(repoPath).startsWith('resume-')) familyIndex ??= buildIndex(config, { fast: true, invokeHooks: false });
|
|
658
|
+
const displayedBody = body + childHandoffs(repoPath, config, familyIndex).notice;
|
|
659
|
+
process.stdout.write(displayedBody);
|
|
660
|
+
if (!displayedBody.endsWith('\n')) process.stdout.write('\n');
|
|
565
661
|
shown++;
|
|
566
662
|
}
|
|
567
663
|
|
package/src/section.mjs
CHANGED
|
@@ -3,26 +3,32 @@
|
|
|
3
3
|
// and body-relative line numbers (1-indexed). Callers with stripped frontmatter
|
|
4
4
|
// add extractFrontmatter().bodyLineOffset before exposing file coordinates.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Calls visit(line, index) for every line outside a fenced code block. A fence
|
|
7
|
+
// closes only on the character that opened it, so ``` inside ~~~ stays code.
|
|
8
|
+
function eachUnfencedLine(lines, visit) {
|
|
8
9
|
const fenceRe = /^(`{3,}|~{3,})/;
|
|
9
|
-
const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
|
|
10
|
-
const sections = [];
|
|
11
10
|
let fenceChar = null;
|
|
12
|
-
let h2Ancestor = null;
|
|
13
|
-
|
|
14
11
|
for (let i = 0; i < lines.length; i++) {
|
|
15
|
-
const
|
|
16
|
-
const fence = line.match(fenceRe);
|
|
12
|
+
const fence = lines[i].match(fenceRe);
|
|
17
13
|
if (fence) {
|
|
18
14
|
const tok = fence[1][0]; // ` or ~
|
|
19
15
|
if (fenceChar === null) fenceChar = tok;
|
|
20
16
|
else if (fenceChar === tok) fenceChar = null;
|
|
21
17
|
continue;
|
|
22
18
|
}
|
|
23
|
-
if (fenceChar
|
|
19
|
+
if (fenceChar === null) visit(lines[i], i);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function walkSections(body) {
|
|
24
|
+
const lines = body.split('\n');
|
|
25
|
+
const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
|
|
26
|
+
const sections = [];
|
|
27
|
+
let h2Ancestor = null;
|
|
28
|
+
|
|
29
|
+
eachUnfencedLine(lines, (line, i) => {
|
|
24
30
|
const h = line.match(headingRe);
|
|
25
|
-
if (!h)
|
|
31
|
+
if (!h) return;
|
|
26
32
|
const level = h[1].length;
|
|
27
33
|
const heading = h[2];
|
|
28
34
|
if (level === 1) h2Ancestor = null;
|
|
@@ -35,7 +41,7 @@ export function walkSections(body) {
|
|
|
35
41
|
bodyLineStart: i + 2,
|
|
36
42
|
});
|
|
37
43
|
if (level === 2) h2Ancestor = heading;
|
|
38
|
-
}
|
|
44
|
+
});
|
|
39
45
|
|
|
40
46
|
for (let i = 0; i < sections.length; i++) {
|
|
41
47
|
const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
|
|
@@ -57,6 +63,35 @@ export function findSection(sections, name) {
|
|
|
57
63
|
return sections.find(s => norm(s.heading) === target) ?? null;
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
// Every section whose heading matches `query`, best first: exact, then prefix,
|
|
67
|
+
// then substring, document order within each. Case-insensitive; leading `#`s
|
|
68
|
+
// and trailing markers are ignored on both sides, as in findSection.
|
|
69
|
+
export function matchSections(sections, query) {
|
|
70
|
+
const norm = (s) => s.toLowerCase().replace(/^#+\s*/, '').replace(/[^\w\s]+$/, '').replace(/\s+/g, ' ').trim();
|
|
71
|
+
const q = norm(query);
|
|
72
|
+
if (!q) return [];
|
|
73
|
+
const tiers = [[], [], []];
|
|
74
|
+
for (const s of sections) {
|
|
75
|
+
const h = norm(s.heading);
|
|
76
|
+
if (h === q) tiers[0].push(s);
|
|
77
|
+
else if (h.startsWith(q)) tiers[1].push(s);
|
|
78
|
+
else if (h.includes(q)) tiers[2].push(s);
|
|
79
|
+
}
|
|
80
|
+
return tiers.flat();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Checklist boxes outside code fences, with body-relative 1-indexed lines.
|
|
84
|
+
// `[ ]` and `[~]` are open, as in phaseTally and the index's checklist counts.
|
|
85
|
+
const BOX_RE = /^\s*[-*]\s+\[([ xX~])\](?:\s+(.*?))?\s*$/;
|
|
86
|
+
export function walkChecklist(body) {
|
|
87
|
+
const boxes = [];
|
|
88
|
+
eachUnfencedLine(body.split('\n'), (line, i) => {
|
|
89
|
+
const m = line.match(BOX_RE);
|
|
90
|
+
if (m) boxes.push({ line: i + 1, open: m[1].toLowerCase() !== 'x', text: m[2] ?? '' });
|
|
91
|
+
});
|
|
92
|
+
return boxes;
|
|
93
|
+
}
|
|
94
|
+
|
|
60
95
|
// Status marker detection for phase headings. Returns one of:
|
|
61
96
|
// 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
|
|
62
97
|
// Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
|
package/src/show.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { extractFrontmatter, normalizeEol, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { buildBrief, collapseFrontmatter, cut, LIMITS, readSection, SURVEY_LIMITS } from './brief.mjs';
|
|
4
5
|
import { parseDocFile, resolveDocArg } from './index.mjs';
|
|
5
6
|
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
6
|
-
import { die, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
|
|
7
|
+
import { die, normalizeStringList, resolveRefPath, shellQuote, toRepoPath } from './util.mjs';
|
|
7
8
|
|
|
8
9
|
// `runlist show <file...>` — one document's card as data: its title, status,
|
|
9
10
|
// next step, blockers and checklist, the plans and docs its frontmatter names,
|
|
10
|
-
// and the documents its body links to
|
|
11
|
+
// and the documents its body links to, followed by its brief (outline and open
|
|
12
|
+
// boxes with line numbers; src/brief.mjs). `--section` reads one section in
|
|
13
|
+
// place of the whole file. Read-only; nothing is claimed.
|
|
11
14
|
|
|
12
15
|
// Frontmatter lists that name other documents. The configured reference fields
|
|
13
16
|
// are read too; `related_docs` is read whether or not a repo configures it.
|
|
@@ -76,26 +79,139 @@ export function showDoc(abs, config) {
|
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
// Fields the text card prints. Everything else non-empty is named on one line
|
|
83
|
+
// so a reader knows it is there without printing the frontmatter.
|
|
84
|
+
const CARD_FIELDS = new Set(['title', 'status', 'next_step', 'blockers']);
|
|
85
|
+
|
|
86
|
+
function otherFields(abs, config) {
|
|
87
|
+
const raw = readFileSync(abs, 'utf8');
|
|
88
|
+
const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter ?? '', []);
|
|
89
|
+
const shown = new Set([...CARD_FIELDS, ...relatedFields(config)]);
|
|
90
|
+
return Object.keys(fm).filter(k => !shown.has(k) && normalizeStringList(fm[k]).some(v => String(v).trim()));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseShowArgs(args) {
|
|
94
|
+
const opts = { json: false, card: false, all: false, frontmatter: false, section: null, from: null, targets: [] };
|
|
95
|
+
for (let i = 0; i < args.length; i++) {
|
|
96
|
+
const a = args[i];
|
|
97
|
+
if (a === '--json') opts.json = true;
|
|
98
|
+
else if (a === '--card') opts.card = true;
|
|
99
|
+
else if (a === '--all') opts.all = true;
|
|
100
|
+
else if (a === '--frontmatter') opts.frontmatter = true;
|
|
101
|
+
else if (a === '--section' || a === '-s') opts.section = args[++i] ?? die('--section needs a heading');
|
|
102
|
+
else if (a === '--from') opts.from = args[++i];
|
|
103
|
+
else if (a.startsWith('-')) die(`Unknown option for show: ${a}`);
|
|
104
|
+
else opts.targets.push(a);
|
|
105
|
+
}
|
|
106
|
+
if (opts.from != null) {
|
|
107
|
+
if (opts.section == null) die('--from continues a --section read; name the section too');
|
|
108
|
+
const n = Number(opts.from);
|
|
109
|
+
if (!Number.isInteger(n) || n < 1) die(`--from takes a line number, got: ${opts.from}`);
|
|
110
|
+
opts.from = n;
|
|
111
|
+
}
|
|
112
|
+
return opts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pad = (n, width) => String(n).padStart(width);
|
|
116
|
+
|
|
117
|
+
function runSection(target, opts, config) {
|
|
118
|
+
const abs = resolveDocArg(target, config);
|
|
119
|
+
const rel = toRepoPath(abs, config.repoRoot);
|
|
120
|
+
const r = readSection(readFileSync(abs, 'utf8'), opts.section, { from: opts.from });
|
|
121
|
+
const again = from => `runlist show ${shellQuote(rel)} --section ${shellQuote(opts.section)} --from ${from}`;
|
|
122
|
+
if (opts.json) {
|
|
123
|
+
const { headings, ...rest } = r;
|
|
124
|
+
const out = r.found
|
|
125
|
+
? { path: rel, ...rest, ...(r.next ? { continue: again(r.next) } : {}) }
|
|
126
|
+
: { path: rel, error: 'no heading matches', query: r.query, headings: headings.map(({ line, level, heading }) => ({ line, level, heading })) };
|
|
127
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
128
|
+
if (!r.found || r.past) process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!r.found) {
|
|
132
|
+
const list = r.headings.slice(0, 20).map(h => ` ${h.line} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
|
|
133
|
+
if (r.headings.length > 20) list.push(` (+${r.headings.length - 20} more)`);
|
|
134
|
+
process.stderr.write([`No heading in ${rel} matches "${opts.section}". Headings:`, ...list].join('\n') + '\n');
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const h = r.heading;
|
|
139
|
+
if (r.past) {
|
|
140
|
+
process.stderr.write(`${rel}: "${h.heading}" runs ${h.line} to ${h.end}; --from ${r.from} is past its end.\n`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const width = String(r.to).length;
|
|
145
|
+
const span = r.from === h.line && !r.next ? `${h.line} to ${h.end}` : `${r.from} to ${r.to} of ${h.line} to ${h.end}`;
|
|
146
|
+
const out = [`${rel} lines ${span} (${'#'.repeat(h.level)} ${h.heading}):`];
|
|
147
|
+
for (const row of r.rows) out.push(`${pad(row.line, width)}\t${row.text}`);
|
|
148
|
+
if (r.next) out.push(`(cut at line ${r.to}; the section runs to ${h.end}. Next: ${again(r.next)})`);
|
|
149
|
+
if (r.others.length) out.push(`Also matching: ${r.others.map(o => `${o.line} ${cut(o.heading, 60)}`).join('; ')}`);
|
|
150
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderBrief(card, brief) {
|
|
154
|
+
const out = [];
|
|
155
|
+
const width = String(brief.lines).length;
|
|
156
|
+
const kb = Math.max(1, Math.round(brief.bytes / 1024));
|
|
157
|
+
out.push('', `Outline: ${brief.lines.toLocaleString('en-US')} lines, ${kb} KB (line, heading)`);
|
|
158
|
+
if (!brief.outline.length) out.push(' (no headings)');
|
|
159
|
+
for (const h of brief.outline) out.push(` ${pad(h.line, width)} ${cut(`${'#'.repeat(h.level)} ${h.heading}`)}`);
|
|
160
|
+
if (brief.outlineOmitted) out.push(` (+${brief.outlineOmitted} deeper headings not shown; a --section read shows them)`);
|
|
161
|
+
const total = brief.boxes.open + brief.boxes.done;
|
|
162
|
+
if (total) {
|
|
163
|
+
out.push(`Open boxes: ${brief.boxes.open} of ${total} (line, text)`);
|
|
164
|
+
for (const b of brief.open) out.push(` ${pad(b.line, width)} ${cut(b.text)}`);
|
|
165
|
+
if (brief.openOmitted) out.push(` (+${brief.openOmitted} more: add --all)`);
|
|
166
|
+
}
|
|
167
|
+
if (brief.frontmatter) {
|
|
168
|
+
out.push(`Frontmatter (lines 1 to ${brief.frontmatterLines}):`, ...brief.frontmatter.map(l => ` ${l}`));
|
|
169
|
+
} else if (card.otherFields?.length) {
|
|
170
|
+
out.push(`Other fields: ${card.otherFields.join(', ')} (--frontmatter prints them)`);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
79
175
|
export function runShow(args, config) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (
|
|
176
|
+
const opts = parseShowArgs(args);
|
|
177
|
+
if (!opts.targets.length) die('Usage: runlist show <file...> [--section "<heading>" [--from <line>]] [--all] [--card] [--frontmatter] [--json]');
|
|
178
|
+
if (opts.section != null) {
|
|
179
|
+
if (opts.targets.length > 1) die('--section reads one file; name one');
|
|
180
|
+
runSection(opts.targets[0], opts, config);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const limits = opts.targets.length > 1 ? SURVEY_LIMITS : LIMITS;
|
|
83
184
|
const cards = [];
|
|
84
|
-
for (const t of targets) {
|
|
85
|
-
const abs = resolveDocArg(t, config, { dieOnMiss: !json });
|
|
86
|
-
|
|
185
|
+
for (const t of opts.targets) {
|
|
186
|
+
const abs = resolveDocArg(t, config, { dieOnMiss: !opts.json });
|
|
187
|
+
if (!abs) { cards.push({ path: t, error: 'not found' }); continue; }
|
|
188
|
+
const card = showDoc(abs, config);
|
|
189
|
+
if (!opts.card) {
|
|
190
|
+
const raw = readFileSync(abs, 'utf8');
|
|
191
|
+
card.brief = buildBrief(raw, { all: opts.all, limits });
|
|
192
|
+
if (opts.frontmatter) card.brief.frontmatter = collapseFrontmatter(extractFrontmatter(normalizeEol(raw)).frontmatter, limits);
|
|
193
|
+
card.otherFields = otherFields(abs, config);
|
|
194
|
+
}
|
|
195
|
+
cards.push(card);
|
|
87
196
|
}
|
|
88
|
-
if (json) {
|
|
197
|
+
if (opts.json) {
|
|
89
198
|
process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
|
|
90
199
|
return;
|
|
91
200
|
}
|
|
92
201
|
cards.forEach((c, n) => {
|
|
93
202
|
if (n) process.stdout.write('\n');
|
|
94
|
-
|
|
95
|
-
if (c.nextStep)
|
|
96
|
-
for (const b of c.blockers)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
203
|
+
const out = [`${c.title} (${c.status ?? 'no status'})`, c.path];
|
|
204
|
+
if (c.nextStep) out.push(`Next: ${c.nextStep}`);
|
|
205
|
+
for (const b of c.blockers) out.push(`Blocker: ${b}`);
|
|
206
|
+
// The brief's open-box line carries the same count, read outside code fences.
|
|
207
|
+
if (c.checklist?.total && !c.brief) out.push(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done`);
|
|
208
|
+
for (const r of c.related) out.push(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}`);
|
|
209
|
+
if (c.links.length) out.push(`Linked from the body: ${c.links.map(l => l.path).join(', ')}`);
|
|
210
|
+
if (c.brief) out.push(...renderBrief(c, c.brief));
|
|
211
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
100
212
|
});
|
|
213
|
+
if (!opts.card) {
|
|
214
|
+
const file = cards.length === 1 ? shellQuote(cards[0].path) : '<file>';
|
|
215
|
+
process.stdout.write(`\nOne section: runlist show ${file} --section "<heading>"\n`);
|
|
216
|
+
}
|
|
101
217
|
}
|
package/src/template-store.mjs
CHANGED
|
@@ -4,16 +4,24 @@ import path from 'node:path';
|
|
|
4
4
|
import { authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
5
5
|
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
6
6
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
7
|
+
import { HANDOFF_TEMPLATES } from './prompt-handoff.mjs';
|
|
7
8
|
|
|
8
9
|
export const templateFile = 'runlist.templates.json';
|
|
9
|
-
export const templateNames = ['doc','plan','prompt'];
|
|
10
|
+
export const templateNames = ['doc','plan','prompt',...Object.keys(HANDOFF_TEMPLATES)];
|
|
10
11
|
const tokens = new Set(['title','status','date','body','version']);
|
|
11
12
|
const revision = text => createHash('sha256').update(text).digest('hex');
|
|
12
13
|
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
13
14
|
export function validateTemplate(name, source) {
|
|
14
|
-
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or
|
|
15
|
-
if (typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) fail('invalid-template','Template must be Markdown smaller than 128 KB.');
|
|
15
|
+
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan, prompt, resume or subagent template.');
|
|
16
|
+
if (typeof source !== 'string' || !source.trim() || Buffer.byteLength(source) > 128 * 1024) fail('invalid-template','Template must be nonempty Markdown smaller than 128 KB.');
|
|
16
17
|
const parts = extractFrontmatter(source), metadata=parseSimpleFrontmatter(parts.frontmatter);
|
|
18
|
+
if (Object.hasOwn(HANDOFF_TEMPLATES,name)) {
|
|
19
|
+
for (const match of source.matchAll(/\{\{([^{}]+)\}\}/g)) if (name !== 'handoff:subagent' || match[1] !== 'parent_prompt') fail('invalid-template',`Unknown placeholder: {{${match[1]}}}.`);
|
|
20
|
+
if (name === 'handoff:subagent' && (!source.startsWith('---\n') || metadata.parent_prompt !== '{{parent_prompt}}' || (parts.frontmatter.match(/^parent_prompt:/gm)??[]).length !== 1)) fail('invalid-template','Keep parent_prompt: {{parent_prompt}} in the subagent frontmatter.');
|
|
21
|
+
if (name === 'handoff:resume' && Object.hasOwn(metadata,'parent_prompt')) fail('invalid-template','A resume draft cannot have its own parent_prompt; use the subagent draft for child handoffs.');
|
|
22
|
+
if (['type','status','created','updated'].some(key=>Object.hasOwn(metadata,key))) fail('invalid-template','Handoff drafts contain authored content; saved prompt metadata is supplied by the prompt scaffold.');
|
|
23
|
+
return parts;
|
|
24
|
+
}
|
|
17
25
|
if (!source.startsWith('---\n') || metadata.type !== name) fail('invalid-template',`Keep the frontmatter and type: ${name}.`);
|
|
18
26
|
for (const match of source.matchAll(/\{\{([^{}]+)\}\}/g)) if (!tokens.has(match[1])) fail('invalid-template',`Unknown placeholder: {{${match[1]}}}.`);
|
|
19
27
|
if (!/\{\{title\}\}/.test(parts.body) && name !== 'prompt') fail('invalid-template','Keep {{title}} in the document body.');
|
|
@@ -31,7 +39,7 @@ export function readTemplateStore(config) {
|
|
|
31
39
|
let data = {version:1,templates:{}};
|
|
32
40
|
if (source !== null) {
|
|
33
41
|
try { data = JSON.parse(source); } catch { fail('invalid-template','runlist.templates.json contains invalid JSON.'); }
|
|
34
|
-
if (data.version !== 1 || !data.templates || typeof data.templates !== 'object' || Array.isArray(data.templates) || Object.keys(data).some(k=>!['version','templates'].includes(k))) fail('invalid-template','Unsupported template settings format.');
|
|
42
|
+
if (!data || typeof data !== 'object' || data.version !== 1 || !data.templates || typeof data.templates !== 'object' || Array.isArray(data.templates) || Object.keys(data).some(k=>!['version','templates'].includes(k))) fail('invalid-template','Unsupported template settings format.');
|
|
35
43
|
for (const [name,value] of Object.entries(data.templates)) {
|
|
36
44
|
if (!value || Object.keys(value).some(k=>k!=='source')) fail('invalid-template','Template settings accept a Markdown source field.');
|
|
37
45
|
validateTemplate(name,value.source);
|
|
@@ -40,17 +48,20 @@ export function readTemplateStore(config) {
|
|
|
40
48
|
return {file,source,data,revision:source === null ? null : revision(source)};
|
|
41
49
|
}
|
|
42
50
|
export function saveTemplate(config,{name,source,expectedRevision,reset=false}) {
|
|
43
|
-
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan or
|
|
51
|
+
if (!templateNames.includes(name)) fail('invalid-template','Choose a doc, plan, prompt, resume or subagent template.');
|
|
44
52
|
if (!reset) validateTemplate(name,source);
|
|
45
53
|
const store = readTemplateStore(config);
|
|
46
54
|
if (expectedRevision !== store.revision) fail('template-conflict','Templates changed in another tab or process. Your draft is kept; reload the saved templates before trying again.');
|
|
47
55
|
if (reset) delete store.data.templates[name]; else store.data.templates[name] = {source};
|
|
48
56
|
const content = JSON.stringify(store.data,null,2)+'\n';
|
|
57
|
+
if (Buffer.byteLength(content) > 512 * 1024) fail('invalid-template','Combined template settings must be smaller than 512 KB.');
|
|
49
58
|
try { mutateFileSet(store.source === null ? {creations:[{path:store.file,content}]} : {updates:[{path:store.file,expectedContent:store.source,content}]},{repoRoot:config.repoRoot}); }
|
|
50
59
|
catch (error) { if (error instanceof MutationConflictError) fail('template-conflict','Templates changed while saving. Your draft is kept.'); throw error; }
|
|
51
60
|
return {revision:revision(content)};
|
|
52
61
|
}
|
|
53
62
|
export function applyTemplateOverride(name,base,config,version) {
|
|
63
|
+
// Markdown document overrides apply only to the three built-in types.
|
|
64
|
+
if (!['doc','plan','prompt'].includes(name)) return base;
|
|
54
65
|
const source = readTemplateStore(config).data.templates[name]?.source;
|
|
55
66
|
if (source === undefined) return base;
|
|
56
67
|
const parts = validateTemplate(name,source);
|
package/src/update.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { executableName, which } from './util.mjs';
|
|
|
8
8
|
import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, opencodeStatus } from './host-integration.mjs';
|
|
9
9
|
import { codexStatus, installedCodexStatus } from './codex-integration.mjs';
|
|
10
10
|
import { desktopLaunch } from './desktop.mjs';
|
|
11
|
+
import { installationReport, formatInstallationReport, readInstalledPluginRecords } from './installation.mjs';
|
|
12
|
+
export { readInstalledPluginRecords } from './installation.mjs';
|
|
11
13
|
import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
|
|
12
14
|
|
|
13
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -32,30 +34,6 @@ export function compareVersions(a, b) {
|
|
|
32
34
|
return 0;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
// Read Claude Code's plugin install record to find the installed runlist plugin's
|
|
36
|
-
// id + version. Network-free. `opts.home` is injectable for tests. Returns
|
|
37
|
-
// { id, version } or null when nothing is installed / the file is absent.
|
|
38
|
-
export function readInstalledPluginRecords(opts = {}) {
|
|
39
|
-
const home = opts.home || os.homedir();
|
|
40
|
-
const file = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
|
|
41
|
-
try {
|
|
42
|
-
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
-
const plugins = j.plugins || {};
|
|
44
|
-
const id = opts.id
|
|
45
|
-
? (plugins[opts.id] ? opts.id : null)
|
|
46
|
-
: plugins[DEFAULT_PLUGIN_ID]
|
|
47
|
-
? DEFAULT_PLUGIN_ID
|
|
48
|
-
: plugins[LEGACY_PLUGIN_ID]
|
|
49
|
-
? LEGACY_PLUGIN_ID
|
|
50
|
-
: Object.keys(plugins).find(k => /^(?:runlist|dotmd)@/.test(k));
|
|
51
|
-
if (!id) return null;
|
|
52
|
-
const entries = Array.isArray(plugins[id]) ? plugins[id] : [plugins[id]];
|
|
53
|
-
return { id, entries: entries.filter(Boolean) };
|
|
54
|
-
} catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
37
|
// Claude Code's marketplace registry, keyed by marketplace name. A plugin id
|
|
60
38
|
// is `<plugin>@<marketplace>`, and the plugin only loads while its marketplace
|
|
61
39
|
// is registered here — an install record alone is not an installed plugin.
|
|
@@ -251,6 +229,7 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
251
229
|
const desktop = process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([]).app : null;
|
|
252
230
|
|
|
253
231
|
if (check) {
|
|
232
|
+
process.stdout.write(formatInstallationReport(installationReport(_config)));
|
|
254
233
|
process.stdout.write(`runlist CLI: ${pkg.version}\n`);
|
|
255
234
|
if (plugin) {
|
|
256
235
|
if (plugin.id === LEGACY_PLUGIN_ID || (legacyPlugin && plugin.id !== LEGACY_PLUGIN_ID)) {
|
|
@@ -258,7 +237,7 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
258
237
|
}
|
|
259
238
|
for (const entry of plugin.entries) {
|
|
260
239
|
const cmp = compareVersions(entry.version, pkg.version);
|
|
261
|
-
const tag = cmp === 0 ? green('
|
|
240
|
+
const tag = cmp === 0 ? green('version matches')
|
|
262
241
|
: cmp === null ? dim('(unknown)')
|
|
263
242
|
: cmp < 0 ? yellow('behind — run `runlist update`')
|
|
264
243
|
: yellow('ahead — CLI is behind');
|
|
@@ -273,14 +252,14 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
273
252
|
const oc = opencodeStatus({ version: pkg.version });
|
|
274
253
|
if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
|
|
275
254
|
else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
|
|
276
|
-
else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('
|
|
255
|
+
else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('version matches')}\n`);
|
|
277
256
|
const cx = currentCodexStatus(pkg.version);
|
|
278
257
|
if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
|
|
279
258
|
else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
|
|
280
|
-
else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('
|
|
259
|
+
else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('version matches')}\n`);
|
|
281
260
|
if (desktop) {
|
|
282
261
|
const version = await installedDesktopVersion(desktop);
|
|
283
|
-
process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') :
|
|
262
|
+
process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : dim('version current; see source comparison above')}\n`);
|
|
284
263
|
}
|
|
285
264
|
return;
|
|
286
265
|
}
|
package/src/util.mjs
CHANGED
|
@@ -306,6 +306,13 @@ export function suggestCandidates(query, candidates, max = 3) {
|
|
|
306
306
|
.map(([cand]) => cand);
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// One argument as it must be typed into a POSIX shell: bare when it is plainly
|
|
310
|
+
// safe, otherwise double-quoted with `"`, `\`, `$` and backtick escaped.
|
|
311
|
+
export function shellQuote(value) {
|
|
312
|
+
const raw = String(value);
|
|
313
|
+
return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
|
|
314
|
+
}
|
|
315
|
+
|
|
309
316
|
export function resolveDocPath(input, config) {
|
|
310
317
|
if (!input) return null;
|
|
311
318
|
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|