runlist 0.99.1 → 0.100.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/GETTING_STARTED.md +249 -0
- package/README.md +433 -51
- package/bin/runlist.mjs +404 -53
- package/package.json +3 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
- package/plugins/runlist-codex/bin/runlist-hook +29 -11
- package/plugins/runlist-codex/bin/session-start.mjs +16 -0
- package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
- package/plugins/runlist-codex/hooks/hooks.json +11 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
- package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
- package/runlist.config.example.mjs +6 -0
- package/src/agent-context.mjs +8 -2
- package/src/agent-hosts.mjs +305 -0
- package/src/app-create.mjs +11 -1
- package/src/atomic-mutation.mjs +140 -35
- package/src/baton.mjs +127 -33
- package/src/codex-integration.mjs +57 -7
- package/src/commands.mjs +92 -38
- package/src/config-command.mjs +115 -0
- package/src/config-setting.mjs +163 -0
- package/src/config.mjs +57 -12
- package/src/doctor-hosts.mjs +192 -0
- package/src/doctor.mjs +16 -6
- package/src/export.mjs +11 -0
- package/src/git.mjs +58 -2
- package/src/guard.mjs +41 -13
- package/src/handoff-record.mjs +290 -30
- package/src/handoffs-help.mjs +143 -0
- package/src/handoffs-mode.mjs +486 -0
- package/src/handoffs-scope.mjs +98 -0
- package/src/help.mjs +72 -0
- package/src/host-integration.mjs +7 -1
- package/src/hud.mjs +174 -29
- package/src/index.mjs +11 -4
- package/src/init.mjs +255 -160
- package/src/install.mjs +35 -18
- package/src/installation.mjs +110 -27
- package/src/journal.mjs +2 -2
- package/src/lifecycle.mjs +22 -11
- package/src/managed-path.mjs +68 -4
- package/src/migrate.mjs +7 -0
- package/src/naming.mjs +26 -0
- package/src/new.mjs +202 -91
- package/src/pickup-card.mjs +1 -1
- package/src/plan-changes.mjs +49 -0
- package/src/prompt-handoff.mjs +16 -0
- package/src/prompts.mjs +101 -27
- package/src/rename.mjs +13 -0
- package/src/setup-carry.mjs +291 -0
- package/src/setup-waiting.mjs +20 -0
- package/src/setup-worker.mjs +257 -0
- package/src/setup.mjs +404 -0
- package/src/statuses.mjs +5 -2
- package/src/update.mjs +239 -79
- package/src/use.mjs +4 -2
- package/src/validate.mjs +7 -0
package/src/update.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { green, dim, yellow } from './color.mjs';
|
|
7
|
-
import { executableName
|
|
7
|
+
import { executableName } 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';
|
|
11
|
+
import { installationReport, formatInstallationReport, readInstalledPluginRecords, sourceFingerprint } from './installation.mjs';
|
|
12
12
|
export { readInstalledPluginRecords } from './installation.mjs';
|
|
13
13
|
import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
|
|
14
|
+
import { agentHostReport, discoverClaudeProfiles, discoverHostExecutables } from './agent-hosts.mjs';
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -39,7 +40,9 @@ export function compareVersions(a, b) {
|
|
|
39
40
|
// is registered here — an install record alone is not an installed plugin.
|
|
40
41
|
export function readKnownMarketplaces(opts = {}) {
|
|
41
42
|
const home = opts.home || os.homedir();
|
|
42
|
-
const
|
|
43
|
+
const env = opts.env ?? (opts.home ? {} : process.env);
|
|
44
|
+
const configDir = opts.configDir ?? (env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, '.claude'));
|
|
45
|
+
const file = path.join(configDir, 'plugins', 'known_marketplaces.json');
|
|
43
46
|
try {
|
|
44
47
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
45
48
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
@@ -55,10 +58,12 @@ export function readKnownMarketplaces(opts = {}) {
|
|
|
55
58
|
// that "installed", so `runlist install claude` skipped the one repair it owns
|
|
56
59
|
// and `runlist update` ran a `plugin update` that could only fail.
|
|
57
60
|
export function readInstalledPlugin(opts = {}) {
|
|
58
|
-
const
|
|
61
|
+
const env = opts.env ?? (opts.home ? {} : process.env);
|
|
62
|
+
const configDir = opts.configDir ?? (env.CLAUDE_CONFIG_DIR?.trim() || path.join(opts.home ?? os.homedir(), '.claude'));
|
|
63
|
+
const records = readInstalledPluginRecords({ ...opts, configDir });
|
|
59
64
|
if (!records) return null;
|
|
60
65
|
const marketplace = records.id.split('@')[1] || null;
|
|
61
|
-
const known = readKnownMarketplaces(opts);
|
|
66
|
+
const known = readKnownMarketplaces({ ...opts, configDir });
|
|
62
67
|
return {
|
|
63
68
|
id: records.id,
|
|
64
69
|
entries: records.entries,
|
|
@@ -156,8 +161,14 @@ export function planUpdate(opts, ctx) {
|
|
|
156
161
|
} else if (ctx.plugin.id === LEGACY_PLUGIN_ID) {
|
|
157
162
|
if (ctx.hasClaude) {
|
|
158
163
|
steps.push({ kind: 'marketplace', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] });
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
const scopes = planPluginUpdates(ctx.plugin);
|
|
165
|
+
for (const step of scopes) {
|
|
166
|
+
if (step.kind === 'refusal') { steps.push(step); continue; }
|
|
167
|
+
steps.push({ ...step, needs: 'marketplace', cmd: ['claude', 'plugin', 'install', DEFAULT_PLUGIN_ID, ...step.cmd.slice(4)] });
|
|
168
|
+
}
|
|
169
|
+
for (const step of scopes) if (step.kind !== 'refusal') {
|
|
170
|
+
steps.push({ ...step, kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID, ...step.cmd.slice(4)] });
|
|
171
|
+
}
|
|
161
172
|
} else {
|
|
162
173
|
steps.push({ kind: 'manual', reason: 'Claude CLI not found', lines: [
|
|
163
174
|
`/plugin marketplace add ${CLAUDE_MARKETPLACE}`,
|
|
@@ -170,12 +181,14 @@ export function planUpdate(opts, ctx) {
|
|
|
170
181
|
} else if (!ctx.hasClaude) {
|
|
171
182
|
steps.push({ kind: 'skip', reason: `claude CLI not found — run \`/plugin update ${ctx.plugin.id}\` from a session instead` });
|
|
172
183
|
} else {
|
|
173
|
-
steps.push(
|
|
184
|
+
if (ctx.officialMarketplace) steps.push({ kind: 'marketplace', cmd: ['claude', 'plugin', 'marketplace', 'update', ctx.plugin.marketplace] });
|
|
185
|
+
steps.push(...planPluginUpdates(ctx.plugin).map(step => ctx.officialMarketplace ? { ...step, needs: 'marketplace' } : step));
|
|
174
186
|
}
|
|
175
187
|
if (ctx.legacyPlugin && ctx.plugin?.id !== LEGACY_PLUGIN_ID) {
|
|
176
|
-
|
|
177
|
-
? { kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID] }
|
|
178
|
-
|
|
188
|
+
if (ctx.hasClaude) for (const step of planPluginUpdates(ctx.legacyPlugin)) {
|
|
189
|
+
steps.push(step.kind === 'refusal' ? step : { ...step, kind: 'legacy', needs: 'plugin', cmd: ['claude', 'plugin', 'uninstall', LEGACY_PLUGIN_ID, ...step.cmd.slice(4)] });
|
|
190
|
+
}
|
|
191
|
+
else steps.push({ kind: 'manual', reason: 'Legacy Claude plugin also installed', lines: [`/plugin uninstall ${LEGACY_PLUGIN_ID}`] });
|
|
179
192
|
}
|
|
180
193
|
// The OpenCode integration is a file runlist wrote, so it goes stale silently
|
|
181
194
|
// the moment the CLI moves on. Refresh it here — but only if it is already
|
|
@@ -183,12 +196,12 @@ export function planUpdate(opts, ctx) {
|
|
|
183
196
|
// which stays the job of the explicit `runlist install`.
|
|
184
197
|
if (ctx.opencode?.foreign) {
|
|
185
198
|
steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by runlist — leaving it alone` });
|
|
186
|
-
} else if (ctx.opencode?.exists && (ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
|
|
199
|
+
} else if (ctx.opencode?.exists && (ctx.refreshHosts || ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
|
|
187
200
|
steps.push({ kind: 'opencode', cmd: ['runlist', 'install', 'opencode'] });
|
|
188
201
|
}
|
|
189
202
|
if (ctx.codex?.foreign) {
|
|
190
203
|
steps.push({ kind: 'skip', reason: `${ctx.codex.plugin} was not written by runlist — leaving it alone` });
|
|
191
|
-
} else if (ctx.codex?.exists && (ctx.codex.stale || (!opts.pluginOnly && ctx.hasNpm))) {
|
|
204
|
+
} else if (ctx.codex?.exists && (ctx.refreshHosts || ctx.codex.stale || (!opts.pluginOnly && ctx.hasNpm))) {
|
|
192
205
|
steps.push({ kind: 'codex', cmd: ['runlist', 'install', 'codex'] });
|
|
193
206
|
}
|
|
194
207
|
}
|
|
@@ -213,68 +226,202 @@ export function verifyManagedHostUpdates(before, after, expectedVersion) {
|
|
|
213
226
|
return failures;
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
function currentCodexStatus(version) {
|
|
217
|
-
const source = codexStatus({ version });
|
|
229
|
+
function currentCodexStatus(version, { home = os.homedir(), env = process.env, run = spawnSync, binary } = {}) {
|
|
230
|
+
const source = codexStatus({ version, homedir: home });
|
|
218
231
|
if (!source.exists || source.foreign) return source;
|
|
219
|
-
const installed =
|
|
232
|
+
const installed = binary ? installedCodexStatus({ homedir: home, env, run, binary }) : { ok: false, reason: 'codex CLI not found; installed plugin is unverified' };
|
|
220
233
|
return { ...source, installed, stale: source.stale || !installed.ok || installed.version !== version };
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
236
|
+
function flagValues(argv, flag) {
|
|
237
|
+
const values = [];
|
|
238
|
+
for (let i = 0; i < argv.length; i++) {
|
|
239
|
+
if (argv[i].startsWith(`${flag}=`)) values.push(argv[i].slice(flag.length + 1));
|
|
240
|
+
else if (argv[i] === flag) {
|
|
241
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
242
|
+
values.push(argv[++i]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (values.some(value => !value.trim())) throw new Error(`${flag} requires a value`);
|
|
246
|
+
return values;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function updateOptions(argv, opts = {}) {
|
|
250
|
+
const hosts = flagValues(argv, '--host');
|
|
251
|
+
if (hosts.length > 1) throw new Error('--host may be supplied once');
|
|
252
|
+
if (argv.includes('--json') && !argv.includes('--check')) throw new Error('update --json is available with --check; use update --check --json for offline inspection.');
|
|
253
|
+
const host = hosts[0] ?? opts.host;
|
|
254
|
+
if (host && !['claude', 'codex', 'opencode'].includes(host)) throw new Error('--host must be claude, codex, or opencode');
|
|
255
|
+
const cliOnly = argv.includes('--cli-only') || Boolean(opts.cliOnly);
|
|
256
|
+
if (host && cliOnly) throw new Error('--cli-only conflicts with --host');
|
|
257
|
+
const configDirs = flagValues(argv, '--claude-config');
|
|
258
|
+
const claudeConfigDirs = configDirs.length ? configDirs : opts.claudeConfigDirs ?? [];
|
|
259
|
+
if (claudeConfigDirs.some(dir => !path.isAbsolute(dir))) throw new Error('--claude-config requires an absolute profile directory');
|
|
260
|
+
const binaries = flagValues(argv, '--claude-bin');
|
|
261
|
+
if (binaries.length > 1) throw new Error('--claude-bin may be supplied once');
|
|
262
|
+
return { host, cliOnly, pluginOnly: Boolean(host || argv.includes('--plugin-only') || opts.pluginOnly),
|
|
263
|
+
claudeConfigDirs: claudeConfigDirs.map(dir => path.resolve(dir)), claudeBin: binaries[0] ?? opts.claudeBin,
|
|
264
|
+
check: argv.includes('--check'), json: argv.includes('--json'), dryRun: Boolean(opts.dryRun || argv.includes('--dry-run')) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function inventoryNpm(env) {
|
|
268
|
+
const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''];
|
|
269
|
+
return (env.PATH ?? '').split(path.delimiter).filter(Boolean).some(dir => suffixes.some(suffix => existsSync(path.join(dir, `npm${suffix}`))));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const officialMarketplace = market => (market?.source?.source === 'github' && market.source.repo === CLAUDE_MARKETPLACE)
|
|
273
|
+
|| (market?.source?.source === 'git' && /^https:\/\/github\.com\/reowens\/runlist(?:\.git)?\/?$/.test(market.source.url ?? ''));
|
|
230
274
|
|
|
275
|
+
// Published catalog parity is separate from local checkout parity. An unpublished
|
|
276
|
+
// checkout must never turn a perfectly valid published plugin into a failure.
|
|
277
|
+
export function publishedClaudePluginStatus(plugin, profile) {
|
|
278
|
+
const market = readKnownMarketplaces({ configDir: profile.configDir })[plugin.marketplace];
|
|
279
|
+
if (!officialMarketplace(market) || !path.isAbsolute(market.installLocation ?? '')) return { state: 'unknown' };
|
|
280
|
+
try {
|
|
281
|
+
const catalog = JSON.parse(readFileSync(path.join(market.installLocation, '.claude-plugin', 'marketplace.json'), 'utf8'));
|
|
282
|
+
if (catalog.name !== plugin.marketplace) return { state: 'unknown' };
|
|
283
|
+
const entry = catalog.plugins?.find(item => item.name === plugin.id.split('@')[0]);
|
|
284
|
+
if (typeof entry?.source !== 'string' || path.isAbsolute(entry.source)) return { state: 'unknown' };
|
|
285
|
+
const root = path.resolve(market.installLocation, entry.source);
|
|
286
|
+
const relative = path.relative(realpathSync(market.installLocation), realpathSync(root));
|
|
287
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { state: 'unknown' };
|
|
288
|
+
const manifest = JSON.parse(readFileSync(path.join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
289
|
+
const assets = ['commands', 'skills', 'hooks', 'hooks.json', '.claude-plugin'];
|
|
290
|
+
const expected = sourceFingerprint(root, assets);
|
|
291
|
+
const copies = plugin.entries.map(item => {
|
|
292
|
+
const actual = item.installPath ? sourceFingerprint(item.installPath, assets) : null;
|
|
293
|
+
return { scope: pluginScopeLabel(item), state: !expected.sha256 || !actual?.sha256 ? 'unknown' : expected.sha256 === actual.sha256 ? 'match' : 'mismatch' };
|
|
294
|
+
});
|
|
295
|
+
return { state: copies.some(copy => copy.state === 'mismatch') ? 'mismatch' : copies.length && copies.every(copy => copy.state === 'match') ? 'match' : 'unknown', version: parseVer(manifest.version) ? manifest.version : null, copies };
|
|
296
|
+
} catch { return { state: 'unknown' }; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Keep physical-store writes deduplicated while retaining every profile for the
|
|
300
|
+
// caller's fresh verification. Explicit selectors never fall back to ~/.claude.
|
|
301
|
+
export function planClaudeProfileUpdates(profiles, inventory) {
|
|
302
|
+
const steps = [], seen = new Set();
|
|
303
|
+
const selected = inventory?.selected;
|
|
304
|
+
const binary = typeof selected === 'string' ? selected : selected?.path;
|
|
305
|
+
const candidate = typeof selected === 'object' ? selected : inventory?.candidates?.find(item => item.path === binary);
|
|
306
|
+
for (const profile of profiles) {
|
|
307
|
+
if (profile.registryState === 'unreadable') {
|
|
308
|
+
steps.push({ kind: 'refusal', reason: `${profile.registryPath}: Claude registry unreadable; no profile update attempted` });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (profile.knownMarketplacesState === 'unreadable' || profile.settings?.some(settings => settings.configDir === profile.configDir && settings.state === 'unreadable')) {
|
|
312
|
+
steps.push({ kind: 'refusal', reason: `${profile.configDir}: Claude marketplace/settings data is unreadable; preserve it and resolve it before updating` });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const plugin = profile.installed;
|
|
316
|
+
if (!plugin) { steps.push({ kind: 'skip', reason: `runlist plugin not installed in ${profile.configDir} — skipping plugin update` }); continue; }
|
|
317
|
+
if (seen.has(profile.storeKey)) continue;
|
|
318
|
+
seen.add(profile.storeKey);
|
|
319
|
+
if (inventory?.explicit && !binary) {
|
|
320
|
+
steps.push({ kind: 'refusal', reason: `selected --claude-bin is unavailable; no update attempted for ${profile.configDir}` });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// A wrapper may hard-code a different CLAUDE_CONFIG_DIR. Merely exporting
|
|
324
|
+
// the variable is not proof that it will target the selected profile.
|
|
325
|
+
if (binary && (!candidate?.kind?.startsWith('native') || (!inventory.explicit && inventory.candidates?.filter(item => item.kind.startsWith('native')).length > 1))) {
|
|
326
|
+
steps.push({ kind: 'refusal', reason: `cannot establish wrapper-to-profile mapping for ${binary} and ${profile.configDir}; use --claude-bin with a native executable path and --claude-config ${profile.configDir}` });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const market = readKnownMarketplaces({ configDir: profile.configDir })[plugin.marketplace];
|
|
330
|
+
const legacyPlugin = profile.plugins?.find(item => item.id === LEGACY_PLUGIN_ID);
|
|
331
|
+
for (const step of planUpdate({ pluginOnly: true }, { plugin, legacyPlugin, hasClaude: binary, officialMarketplace: officialMarketplace(market) })) {
|
|
332
|
+
steps.push({ ...step, profile: profile.configDir, dependencyKey: profile.storeKey,
|
|
333
|
+
...(step.cmd?.[0] === 'claude' ? { cmd: [binary || 'claude', ...step.cmd.slice(1)] } : {}) });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return steps;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function runUpdate(argv, _config, opts = {}) {
|
|
340
|
+
const options = updateOptions(argv, opts);
|
|
341
|
+
const { check, cliOnly, pluginOnly, host, claudeConfigDirs, claudeBin, dryRun } = options;
|
|
342
|
+
const home = opts.home ?? os.homedir(), env = opts.env ?? process.env;
|
|
343
|
+
const run = opts.run ?? spawnSync;
|
|
344
|
+
const inventory = opts.executables ?? discoverHostExecutables({ home, env, claudeBin });
|
|
345
|
+
const discovered = discoverClaudeProfiles({ home, env, configDirs: claudeConfigDirs });
|
|
346
|
+
const profiles = claudeConfigDirs.length ? discovered.flatMap(profile => {
|
|
347
|
+
const selected = claudeConfigDirs.find(dir => (profile.configDirs ?? [profile.configDir]).includes(dir));
|
|
348
|
+
return selected ? [{ ...profile, configDir: selected }] : [];
|
|
349
|
+
}) : discovered;
|
|
350
|
+
const reportOptions = { home, env, claudeConfigDirs, claudeBin };
|
|
351
|
+
// Offline checks and previews do not invoke even a host's read-only CLI:
|
|
352
|
+
// wrappers and host startup can perform network or settings writes.
|
|
231
353
|
if (check) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
354
|
+
const installation = installationReport(_config, reportOptions);
|
|
355
|
+
const hosts = agentHostReport(_config, reportOptions);
|
|
356
|
+
const managedSources = { codex: codexStatus({ version: pkg.version, homedir: home }), opencode: opencodeStatus({ version: pkg.version, homedir: home, env }) };
|
|
357
|
+
const report = { ...installation, hostReport: hosts, managedSources, selection: { host: host ?? null, claudeConfigDirs },
|
|
358
|
+
readOnly: true, offline: true, publishedAvailability: 'not-queried-offline' };
|
|
359
|
+
if (options.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
360
|
+
else {
|
|
361
|
+
process.stdout.write('Offline inspection: local installed/catalog files only; newer published versions were not queried.\n');
|
|
362
|
+
process.stdout.write(formatInstallationReport(installation).split('\n').filter(line => !/^(?:claude|codex|opencode):/.test(line)).join('\n'));
|
|
363
|
+
process.stdout.write(`runlist CLI: ${pkg.version}\n`);
|
|
364
|
+
for (const profile of profiles) {
|
|
365
|
+
if (!profile.installed) process.stdout.write(`runlist plugin: not installed (${profile.configDir})\n`);
|
|
366
|
+
for (const plugin of profile.plugins) {
|
|
367
|
+
for (const entry of plugin.entries) {
|
|
368
|
+
const cmp = compareVersions(entry.version, pkg.version);
|
|
369
|
+
const version = cmp === 0 ? 'version matches CLI' : cmp === null ? 'version unknown' : cmp < 0 ? 'behind CLI' : 'ahead of CLI';
|
|
370
|
+
process.stdout.write(`runlist plugin: ${entry.version ?? '?'} (${plugin.id}; ${pluginScopeLabel(entry)}; ${profile.configDir}) ${version}; published source ${publishedClaudePluginStatus(plugin, profile).state}\n`);
|
|
371
|
+
}
|
|
372
|
+
if (plugin.marketplaceRegistered === false) process.stdout.write(` marketplace "${plugin.marketplace}" is not registered; host activation is unknown\n`);
|
|
373
|
+
}
|
|
237
374
|
}
|
|
238
|
-
for (const
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
: yellow('ahead — CLI is behind');
|
|
244
|
-
process.stdout.write(`runlist plugin: ${entry.version ?? '?'} (${plugin.id}; ${pluginScopeLabel(entry)}) ${tag}\n`);
|
|
375
|
+
for (const name of ['claude', 'codex', 'opencode']) {
|
|
376
|
+
const info = hosts.hosts[name];
|
|
377
|
+
process.stdout.write(`${name}: ${info.state}; loaded live session unknown\n`);
|
|
378
|
+
for (const candidate of inventory[name]?.candidates ?? []) process.stdout.write(` executable: ${candidate.path} → ${candidate.resolvedPath} (${candidate.kind})\n`);
|
|
379
|
+
for (const requirement of info.requirements ?? []) process.stdout.write(` ${requirement}\n`);
|
|
245
380
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
} else {
|
|
250
|
-
process.stdout.write(dim('runlist plugin: not installed — `runlist install claude`\n'));
|
|
381
|
+
for (const [name, source] of Object.entries(managedSources)) process.stdout.write(`${name} managed source: ${source.exists ? source.foreign ? 'foreign' : source.sourceParity.state : 'missing'}; host installed copy and loaded session require separate attestation\n`);
|
|
382
|
+
for (const warning of hosts.warnings) process.stdout.write(`${warning}\n`);
|
|
251
383
|
}
|
|
252
|
-
|
|
253
|
-
if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
|
|
254
|
-
else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
|
|
255
|
-
else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('version matches')}\n`);
|
|
256
|
-
const cx = currentCodexStatus(pkg.version);
|
|
257
|
-
if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
|
|
258
|
-
else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
|
|
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`);
|
|
260
|
-
if (desktop) {
|
|
261
|
-
const version = await installedDesktopVersion(desktop);
|
|
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`);
|
|
263
|
-
}
|
|
264
|
-
return;
|
|
384
|
+
return report;
|
|
265
385
|
}
|
|
266
386
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
387
|
+
const selected = name => !host || host === name;
|
|
388
|
+
if (!cliOnly) {
|
|
389
|
+
const source = agentHostReport(_config, reportOptions).candidate;
|
|
390
|
+
process.stdout.write(`CLI candidate: ${source.path} (${source.kind}, version ${source.version ?? 'unknown'}). `
|
|
391
|
+
+ (source.kind === 'checkout' ? 'This may contain unpublished assets; candidate parity is not a published-release attestation.\n' : 'Managed assets are compared with this CLI package.\n'));
|
|
392
|
+
process.stdout.write('Claude updates use registered marketplace catalogs; Codex/OpenCode managed assets come from this CLI installation.\n');
|
|
393
|
+
if (!pluginOnly) process.stdout.write('The CLI update may replace this installation before host installers read its assets.\n');
|
|
394
|
+
}
|
|
395
|
+
const opencode = !cliOnly && selected('opencode') ? opencodeStatus({ version: pkg.version, homedir: home, env }) : null;
|
|
396
|
+
const codex = !cliOnly && selected('codex') ? (dryRun ? codexStatus({ version: pkg.version, homedir: home })
|
|
397
|
+
: currentCodexStatus(pkg.version, { home, env, run, binary: inventory.codex.selected?.path })) : null;
|
|
398
|
+
const desktop = !host && !pluginOnly && process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([], { home, env }).app : null;
|
|
399
|
+
// Keep the existing all-host planner; replace only its single-default-profile
|
|
400
|
+
// Claude plan with the discovered stores and their explicit environments.
|
|
401
|
+
const steps = planUpdate({ cliOnly, pluginOnly }, { opencode, codex, desktop, refreshHosts: Boolean(host || dryRun), hasNpm: inventoryNpm(env) })
|
|
402
|
+
.filter(step => !step.reason?.startsWith('runlist plugin not installed'));
|
|
403
|
+
if (!cliOnly) for (const name of ['codex', 'opencode']) {
|
|
404
|
+
if (selected(name) && !(name === 'codex' ? codex : opencode)?.exists) steps.push({ kind: 'skip', reason: `runlist ${name} integration not installed — skipping ${name} update` });
|
|
405
|
+
}
|
|
406
|
+
if (!cliOnly && selected('claude')) {
|
|
407
|
+
const claudeSteps = planClaudeProfileUpdates(profiles, inventory.claude, { home, explicit: claudeConfigDirs.length > 0 });
|
|
408
|
+
const index = steps.findIndex(step => step.kind !== 'cli');
|
|
409
|
+
steps.splice(index < 0 ? steps.length : index, 0, ...claudeSteps);
|
|
410
|
+
}
|
|
411
|
+
if (dryRun) {
|
|
412
|
+
for (const name of ['codex', 'opencode']) {
|
|
413
|
+
const state = name === 'codex' ? codex : opencode;
|
|
414
|
+
if (selected(name) && state?.exists) process.stdout.write(`[dry-run] ${name} managed source: ${state.foreign ? 'foreign (preserved)' : state.stale ? 'stale → current CLI candidate' : 'current (installed-copy verification may still be needed)'}\n`);
|
|
415
|
+
}
|
|
416
|
+
const skipped = host && steps.every(step => step.kind === 'skip');
|
|
417
|
+
if (skipped) process.stdout.write(`[dry-run] Requested ${host} integration is absent or not writable; no update is planned. Use an explicit install/repair if desired.\n`);
|
|
271
418
|
for (const step of steps) {
|
|
272
419
|
if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
|
|
273
420
|
else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
|
|
274
421
|
else if (step.kind === 'refusal') process.stdout.write(yellow(`[dry-run] Refused: ${step.reason}\n`));
|
|
275
|
-
else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
|
|
422
|
+
else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}${step.profile ? ` [CLAUDE_CONFIG_DIR=${step.profile}]` : ''}\n`));
|
|
276
423
|
}
|
|
277
|
-
return;
|
|
424
|
+
return { steps, dryRun: true, status: skipped ? 'skipped' : 'preview' };
|
|
278
425
|
}
|
|
279
426
|
// The hosts are independent: a failed `claude plugin update` says nothing
|
|
280
427
|
// about the OpenCode file, so every step runs and the failures are reported
|
|
@@ -284,10 +431,11 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
284
431
|
let ran = false;
|
|
285
432
|
const failures = [];
|
|
286
433
|
const failedKinds = new Set();
|
|
434
|
+
const refreshedStores = new Set();
|
|
287
435
|
for (const s of steps) {
|
|
288
436
|
if (s.kind === 'refusal') {
|
|
289
437
|
failures.push(s);
|
|
290
|
-
failedKinds.add('plugin
|
|
438
|
+
failedKinds.add(`${s.dependencyKey ?? ''}:plugin`);
|
|
291
439
|
process.stdout.write(yellow(`${s.reason}\n`));
|
|
292
440
|
continue;
|
|
293
441
|
}
|
|
@@ -300,7 +448,7 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
300
448
|
for (const line of s.lines) process.stdout.write(` ${line}\n`);
|
|
301
449
|
continue;
|
|
302
450
|
}
|
|
303
|
-
if (s.needs && failedKinds.has(s.needs)) {
|
|
451
|
+
if (s.needs && failedKinds.has(`${s.dependencyKey ?? ''}:${s.needs}`)) {
|
|
304
452
|
process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
|
|
305
453
|
continue;
|
|
306
454
|
}
|
|
@@ -310,41 +458,52 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
310
458
|
// reads the new installers/assets; this process still holds the old modules.
|
|
311
459
|
// Use this installation's entry point, not another runlist earlier on PATH.
|
|
312
460
|
const ownCli = s.cmd[0] === 'runlist';
|
|
313
|
-
const r =
|
|
461
|
+
const r = run(ownCli ? process.execPath : path.isAbsolute(s.cmd[0]) ? s.cmd[0] : executableName(s.cmd[0]),
|
|
314
462
|
ownCli ? [path.join(__dirname, '..', 'bin', 'runlist.mjs'), ...s.cmd.slice(1)] : s.cmd.slice(1), {
|
|
315
463
|
stdio: 'inherit',
|
|
464
|
+
env: { ...env, HOME: home, USERPROFILE: home, ...(s.profile ? { CLAUDE_CONFIG_DIR: s.profile } : {}) },
|
|
316
465
|
...(s.cwd ? { cwd: s.cwd } : {}),
|
|
317
|
-
shell: !ownCli && process.platform === 'win32',
|
|
466
|
+
shell: !ownCli && process.platform === 'win32' && (!path.isAbsolute(s.cmd[0]) || /\.(?:cmd|bat)$/i.test(s.cmd[0])),
|
|
318
467
|
});
|
|
319
468
|
ran = true;
|
|
320
469
|
if (r.status !== 0) {
|
|
321
|
-
failedKinds.add(s.kind);
|
|
470
|
+
failedKinds.add(`${s.dependencyKey ?? ''}:${s.kind}`);
|
|
322
471
|
failures.push(s);
|
|
323
472
|
process.stdout.write(yellow(`(${s.cmd[0]} ${r.error ? `failed: ${r.error.message}` : `exited ${r.status ?? '?'}`})\n`));
|
|
324
|
-
if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(
|
|
325
|
-
}
|
|
473
|
+
if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint('runlist')) process.stdout.write(yellow(`${line}\n`));
|
|
474
|
+
} else if (s.kind === 'marketplace' && s.dependencyKey) refreshedStores.add(s.dependencyKey);
|
|
326
475
|
}
|
|
327
476
|
const currentVersion = opts.expectedVersion ?? JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
328
477
|
if (!cliOnly) {
|
|
329
|
-
const after = { opencode: opencodeStatus({ version: currentVersion
|
|
478
|
+
const after = { opencode: opencode && opencodeStatus({ version: currentVersion, homedir: home, env }),
|
|
479
|
+
codex: codex && currentCodexStatus(currentVersion, { home, env, run, binary: inventory.codex.selected?.path }) };
|
|
330
480
|
for (const reason of verifyManagedHostUpdates({ opencode, codex }, after, currentVersion)) {
|
|
331
481
|
failures.push({ reason });
|
|
332
482
|
process.stdout.write(yellow(`${reason}\n`));
|
|
333
483
|
}
|
|
334
484
|
}
|
|
335
|
-
if (!cliOnly &&
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
485
|
+
if (!cliOnly && selected('claude')) for (const profile of profiles) {
|
|
486
|
+
if (!profile.installed) continue;
|
|
487
|
+
// Verify every registry/scope, even when another store or command failed.
|
|
488
|
+
// Shared-store aliases remain separately visible in the inventory report.
|
|
489
|
+
const plugin = profile.installed;
|
|
490
|
+
const after = readInstalledPlugin({ home, configDir: profile.configDir, env });
|
|
491
|
+
const published = after ? publishedClaudePluginStatus(after, profile) : { state: 'unknown' };
|
|
492
|
+
const verified = verifyPluginScopeUpdates(plugin.id === LEGACY_PLUGIN_ID ? { ...plugin, id: DEFAULT_PLUGIN_ID } : plugin, after, opts.expectedVersion ? { expectedVersion: currentVersion }
|
|
493
|
+
: published.version ? { expectedVersion: published.version } : {});
|
|
339
494
|
if (!verified.ok) {
|
|
340
|
-
|
|
341
|
-
process.stdout.write(yellow(`${
|
|
495
|
+
const reason = `${profile.configDir}: ${verified.reason}`;
|
|
496
|
+
failures.push({ reason }); process.stdout.write(yellow(`${reason}\n`));
|
|
497
|
+
}
|
|
498
|
+
if (published.state === 'mismatch') {
|
|
499
|
+
const reason = `${profile.configDir}: installed Claude assets differ from the ${refreshedStores.has(profile.storeKey) ? 'refreshed ' : ''}registered official marketplace catalog`;
|
|
500
|
+
failures.push({ reason }); process.stdout.write(yellow(`${reason}\n`));
|
|
501
|
+
} else if (published.state === 'unknown') process.stdout.write(dim(`${profile.configDir}: published Claude source parity unknown; loaded session unknown\n`));
|
|
502
|
+
if (profile.plugins.some(item => item.id === LEGACY_PLUGIN_ID) && readInstalledPluginRecords({ home, configDir: profile.configDir, id: LEGACY_PLUGIN_ID })) {
|
|
503
|
+
failures.push({ reason: `${profile.configDir}: legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
|
|
342
504
|
}
|
|
343
505
|
}
|
|
344
|
-
if (
|
|
345
|
-
failures.push({ reason: `legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
|
|
346
|
-
}
|
|
347
|
-
if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has('desktop')) {
|
|
506
|
+
if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has(':desktop')) {
|
|
348
507
|
if (compareDesktopVersions(await installedDesktopVersion(desktop), currentVersion) < 0) {
|
|
349
508
|
failures.push({ reason: 'The matching desktop installer is not published yet; retry `runlist update` after the product release completes.' });
|
|
350
509
|
}
|
|
@@ -355,9 +514,10 @@ export async function runUpdate(argv, _config, opts = {}) {
|
|
|
355
514
|
process.stdout.write(dim('the other steps completed; restart the affected host sessions to load the updated integrations.\n'));
|
|
356
515
|
}
|
|
357
516
|
process.exitCode = 1;
|
|
358
|
-
return;
|
|
517
|
+
return { ok: false, failures, steps };
|
|
359
518
|
}
|
|
360
519
|
if (ran) {
|
|
361
520
|
process.stdout.write(green('\n✓ restart the affected host sessions to load the updated integrations.\n'));
|
|
362
521
|
}
|
|
522
|
+
return { ok: true, steps };
|
|
363
523
|
}
|
package/src/use.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
|
|
|
5
5
|
import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
|
|
6
6
|
import { startPlan } from './lifecycle.mjs';
|
|
7
7
|
import { resolveDocArg } from './index.mjs';
|
|
8
|
+
import { managesFile } from './handoffs-scope.mjs';
|
|
8
9
|
import { LIMITS } from './brief.mjs';
|
|
9
10
|
import { runShow } from './show.mjs';
|
|
10
11
|
|
|
@@ -33,8 +34,9 @@ export async function runUse(argv, config, opts = {}) {
|
|
|
33
34
|
// collision — consuming a prompt is the more common intent), then the
|
|
34
35
|
// shared resolver for plan/doc slugs, which dies with did-you-mean on miss.
|
|
35
36
|
const cwdPath = path.resolve(process.cwd(), positional);
|
|
36
|
-
const
|
|
37
|
-
|
|
37
|
+
const managed = file => (file && managesFile(file, config) ? file : null);
|
|
38
|
+
const filePath = managed(resolveDocPath(positional, config))
|
|
39
|
+
?? (existsSync(cwdPath) ? managed(cwdPath) : null)
|
|
38
40
|
?? resolvePromptInput(positional, config, { dieOnMiss: false })
|
|
39
41
|
?? resolveDocArg(positional, config);
|
|
40
42
|
|
package/src/validate.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from
|
|
|
6
6
|
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
7
|
import { getStageDefinitions, readPlanStage } from './stages.mjs';
|
|
8
8
|
import { assessmentIssues } from './yardstick.mjs';
|
|
9
|
+
import { RETIRED_PROMPT_STATUSES } from './naming.mjs';
|
|
9
10
|
|
|
10
11
|
const NOW = new Date();
|
|
11
12
|
|
|
@@ -98,6 +99,12 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
98
99
|
|
|
99
100
|
if (!doc.status) {
|
|
100
101
|
doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
|
|
102
|
+
} else if (doc.type === 'prompt' && RETIRED_PROMPT_STATUSES.has(doc.status) && !isValidStatus(doc.status, doc.root, config, doc.type)) {
|
|
103
|
+
// Saved by an older build, carried with its status unchanged.
|
|
104
|
+
const remedy = doc.status === 'claimed'
|
|
105
|
+
? `\`runlist prompts archive ${doc.path}\` sets it aside`
|
|
106
|
+
: `\`runlist set pending ${doc.path}\` makes it pending again, or \`runlist prompts archive ${doc.path}\` sets it aside`;
|
|
107
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: `Retired prompt status \`${doc.status}\`, from an older runlist: ${remedy}.` });
|
|
101
108
|
} else if (!isValidStatus(doc.status, doc.root, config, doc.type)) {
|
|
102
109
|
const typeSet = doc.type && config.typeStatuses?.get(doc.type);
|
|
103
110
|
const rootSet = config.rootValidStatuses?.get(doc.root);
|