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/install.mjs
CHANGED
|
@@ -9,12 +9,13 @@ import {
|
|
|
9
9
|
} from './host-integration.mjs';
|
|
10
10
|
import { LEGACY_PLUGIN_ID, readInstalledPlugin, readInstalledPluginRecords } from './update.mjs';
|
|
11
11
|
import { executableName, which } from './util.mjs';
|
|
12
|
-
import { installationReport, formatInstallationReport } from './installation.mjs';
|
|
12
|
+
import { installationReport, formatInstallationReport, persistentCliOnPath, PERSISTENT_CLI_WARNING } from './installation.mjs';
|
|
13
13
|
import { codexStatus, installCodexPlugin, installedCodexStatus } from './codex-integration.mjs';
|
|
14
14
|
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
17
17
|
|
|
18
|
+
const CLAUDE_CODE_SETUP_URL = 'https://code.claude.com/docs/en/setup';
|
|
18
19
|
const HOSTS = ['claude', 'codex', 'opencode'];
|
|
19
20
|
const ALIASES = { 'claude-code': 'claude', claudecode: 'claude' };
|
|
20
21
|
|
|
@@ -85,20 +86,23 @@ function reportStatus(json, config) {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function installCodex(argv, dryRun, json) {
|
|
89
|
+
function installCodex(argv, dryRun, json, prerequisites) {
|
|
89
90
|
if (argv.includes('--remove')) {
|
|
90
91
|
process.stderr.write('Codex removal is managed by `codex plugin remove`; the personal marketplace source is left available.\n');
|
|
91
92
|
process.exitCode = 1;
|
|
92
93
|
return;
|
|
93
94
|
}
|
|
94
|
-
const result = installCodexPlugin({ version: pkg.version, dryRun });
|
|
95
|
+
const result = { ...installCodexPlugin({ version: pkg.version, dryRun }), ...prerequisites };
|
|
95
96
|
if (result.action === 'refused') {
|
|
96
97
|
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
97
98
|
else process.stderr.write(`refused: ${result.reason}\n`);
|
|
98
99
|
process.exitCode = 1;
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
|
-
if (!json)
|
|
102
|
+
if (!json) {
|
|
103
|
+
const action = dryRun ? ({ installed: 'Would install', updated: 'Would update', current: 'Already current' }[result.action] ?? result.action) : result.action;
|
|
104
|
+
process.stdout.write(`${dryRun ? '[dry-run] ' : ''}${action} codex integration (${pkg.version}) at ${result.plugin}\n`);
|
|
105
|
+
}
|
|
102
106
|
if (dryRun) {
|
|
103
107
|
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
104
108
|
return;
|
|
@@ -125,11 +129,11 @@ function installCodex(argv, dryRun, json) {
|
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
function installClaude(argv, dryRun, json) {
|
|
132
|
+
function installClaude(argv, dryRun, json, opts = {}, prerequisites = {}) {
|
|
129
133
|
const remove = argv.includes('--remove');
|
|
130
|
-
const steps = planClaudeInstall({ installed: readInstalledPlugin(), legacyInstalled: readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID }), hasClaude: which('claude'), remove });
|
|
134
|
+
const steps = planClaudeInstall({ installed: readInstalledPlugin(), legacyInstalled: readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID, ...(process.env.CLAUDE_CONFIG_DIR ? { configDir: process.env.CLAUDE_CONFIG_DIR } : {}) }), hasClaude: opts.claudeBin || which('claude'), remove });
|
|
131
135
|
if (json) {
|
|
132
|
-
process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps }, null, 2) + '\n');
|
|
136
|
+
process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps, ...prerequisites }, null, 2) + '\n');
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
let ran = false;
|
|
@@ -141,6 +145,17 @@ function installClaude(argv, dryRun, json) {
|
|
|
141
145
|
continue;
|
|
142
146
|
}
|
|
143
147
|
if (step.kind === 'manual') {
|
|
148
|
+
if (step.lines.some(line => line.startsWith('/plugin install'))) {
|
|
149
|
+
// Newcomers mostly land here from Claude Desktop with no `claude` command, so lead with
|
|
150
|
+
// the documented terminal form. Desktop's plugin browser only lists marketplaces that are
|
|
151
|
+
// already registered, so it is where to find runlist afterwards, not where to add it.
|
|
152
|
+
process.stdout.write(`${step.reason ? `${step.reason}. ` : ''}${yellow('The claude CLI is not on PATH.')} Install the Claude Code CLI (${CLAUDE_CODE_SETUP_URL}), then run in a terminal:\n`);
|
|
153
|
+
for (const line of step.lines) process.stdout.write(` ${bold(line.replace(/^\/plugin /, 'claude plugin '))}\n`);
|
|
154
|
+
process.stdout.write('Or run these from inside a Claude Code session:\n');
|
|
155
|
+
for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
|
|
156
|
+
process.stdout.write(dim('Claude Desktop\'s Code tab reads the same plugin settings as the terminal. Once the marketplace is added, runlist appears under Code tab → + → Add plugins.\n'));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
144
159
|
process.stdout.write(`${yellow(step.reason ?? 'claude CLI not on PATH')} — run these from a Claude Code session:\n`);
|
|
145
160
|
for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
|
|
146
161
|
continue;
|
|
@@ -148,8 +163,8 @@ function installClaude(argv, dryRun, json) {
|
|
|
148
163
|
if (step.reason) process.stdout.write(`${step.reason}\n`);
|
|
149
164
|
if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`)); continue; }
|
|
150
165
|
process.stdout.write(dim(`$ ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
|
|
151
|
-
const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
|
|
152
|
-
stdio: 'inherit', shell: process.platform === 'win32',
|
|
166
|
+
const result = spawnSync(opts.claudeBin || executableName(step.cmd[0]), step.cmd.slice(1), {
|
|
167
|
+
stdio: 'inherit', shell: process.platform === 'win32' && !opts.claudeBin,
|
|
153
168
|
...(step.cwd ? { cwd: step.cwd } : {}),
|
|
154
169
|
});
|
|
155
170
|
ran = true;
|
|
@@ -167,12 +182,12 @@ function installClaude(argv, dryRun, json) {
|
|
|
167
182
|
if (ran && !remove && !process.exitCode) process.stdout.write(green('\n✓ restart Claude Code (or /reload-plugins) to apply.\n'));
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
function installOpencode(argv, dryRun, json) {
|
|
185
|
+
function installOpencode(argv, dryRun, json, prerequisites) {
|
|
171
186
|
const force = argv.includes('--force');
|
|
172
187
|
const dir = flagValue(argv, '--path');
|
|
173
|
-
const result = argv.includes('--remove')
|
|
188
|
+
const result = { ...(argv.includes('--remove')
|
|
174
189
|
? removeOpencodePlugin({ dryRun, force, dir })
|
|
175
|
-
: installOpencodePlugin({ version: pkg.version, dryRun, force, dir });
|
|
190
|
+
: installOpencodePlugin({ version: pkg.version, dryRun, force, dir })), ...prerequisites };
|
|
176
191
|
|
|
177
192
|
if (json) {
|
|
178
193
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
@@ -197,13 +212,12 @@ function installOpencode(argv, dryRun, json) {
|
|
|
197
212
|
process.stdout.write(dim('Restart OpenCode to apply. Sessions fall back to a process-scoped identity.\n'));
|
|
198
213
|
return result;
|
|
199
214
|
default:
|
|
200
|
-
process.stdout.write(`${prefix}${green('✓')} ${result.action} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
|
|
215
|
+
process.stdout.write(`${prefix}${dryRun ? ({ installed: 'Would install', updated: 'Would update' }[result.action] ?? result.action) : `${green('✓')} ${result.action}`} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
|
|
201
216
|
if (dryRun) return result;
|
|
202
217
|
process.stdout.write('\nRestart OpenCode to apply. New sessions then get:\n');
|
|
203
218
|
process.stdout.write(` ${dim('·')} a per-session ownership identity (one session can no longer release another's plan)\n`);
|
|
204
219
|
process.stdout.write(` ${dim('·')} the ${bold('runlist hud')} primer at session start, like Claude Code's SessionStart hook\n`);
|
|
205
|
-
process.stdout.write(dim('\
|
|
206
|
-
process.stdout.write(dim('close them before restarting, or reclaim with --force afterwards.\n'));
|
|
220
|
+
process.stdout.write(dim('\nIf plans were claimed under a previous process-scoped identity, close them before restarting or explicitly reclaim them afterwards.\n'));
|
|
207
221
|
return result;
|
|
208
222
|
}
|
|
209
223
|
}
|
|
@@ -221,7 +235,10 @@ export function runInstall(argv, _config, opts = {}) {
|
|
|
221
235
|
}
|
|
222
236
|
|
|
223
237
|
const dryRun = Boolean(opts.dryRun);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
const persistentCliMissing = !argv.includes('--remove') && !persistentCliOnPath().available;
|
|
239
|
+
const prerequisites = persistentCliMissing ? { persistentCliMissing: true } : {};
|
|
240
|
+
if (persistentCliMissing && !json) process.stderr.write(`Warning: ${PERSISTENT_CLI_WARNING}\n`);
|
|
241
|
+
if (host === 'claude') { installClaude(argv, dryRun, json, opts, prerequisites); return; }
|
|
242
|
+
if (host === 'codex') { installCodex(argv, dryRun, json, prerequisites); return; }
|
|
243
|
+
installOpencode(argv, dryRun, json, prerequisites);
|
|
227
244
|
}
|
package/src/installation.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
2
|
import { createRequire } from 'node:module';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
@@ -7,38 +6,122 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
6
|
import { desktopLaunch } from './desktop.mjs';
|
|
8
7
|
import { codexStatus } from './codex-integration.mjs';
|
|
9
8
|
import { opencodeStatus } from './host-integration.mjs';
|
|
9
|
+
import { sourceFingerprint } from './agent-hosts.mjs';
|
|
10
|
+
import { LEGACY_PRODUCT_NAME, PRODUCT_NAME } from './naming.mjs';
|
|
11
|
+
export { sourceFingerprint } from './agent-hosts.mjs';
|
|
10
12
|
|
|
11
13
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
14
|
function json(file) { try { return JSON.parse(readFileSync(file,'utf8')); } catch { return null; } }
|
|
13
15
|
function resolved(file) { try { return realpathSync(file); } catch { return null; } }
|
|
14
|
-
function executable(name, env) {
|
|
15
|
-
const suffixes = process.platform === 'win32' ? ['',
|
|
16
|
+
function executable(name, env, { cwd = process.cwd(), runnable = false, accept = () => true } = {}) {
|
|
17
|
+
const suffixes = process.platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')] : [''];
|
|
16
18
|
for (const dir of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) for (const suffix of suffixes) {
|
|
17
|
-
const file = path.resolve(dir,name+suffix);
|
|
18
|
-
try {
|
|
19
|
+
const file = path.resolve(cwd, dir, name + suffix);
|
|
20
|
+
try {
|
|
21
|
+
if (!accept(file)) continue;
|
|
22
|
+
if (runnable) {
|
|
23
|
+
if (!statSync(file).isFile()) continue;
|
|
24
|
+
accessSync(file, constants.X_OK);
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
27
|
+
if (lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) return file;
|
|
28
|
+
} catch {}
|
|
19
29
|
}
|
|
20
30
|
return null;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
export const PERSISTENT_CLI_WARNING = 'Host hooks need a globally installed `runlist` on PATH and will not run without one. A temporary npx/one-shot copy does not count; run `npm install -g runlist`, then restart/reload the host.';
|
|
34
|
+
|
|
35
|
+
export function isOneShotCliPath(file) {
|
|
36
|
+
const value = file.replace(/\\/g, '/');
|
|
37
|
+
return /(?:^|\/)_npx\/[^/]+\/node_modules(?:\/|$)/.test(value)
|
|
38
|
+
|| /(?:^|\/)dlx\/(?:[a-z2-7]{26}|[a-f0-9]{32,64})\/[^/]+\/node_modules(?:\/|$)/i.test(value)
|
|
39
|
+
|| /(?:^|\/)tmp\/dlx-[0-9]+\/node_modules(?:\/|$)/.test(value)
|
|
40
|
+
|| /(?:^|\/)bunx-[0-9]+-[^/]+\/node_modules(?:\/|$)/.test(value)
|
|
41
|
+
|| /(?:^|\/)\.bunx-[0-9]+\/[^/]+\/node_modules(?:\/|$)/.test(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function persistentCliOnPath({ env = process.env, cwd = process.cwd() } = {}) {
|
|
45
|
+
const normalize = file => {
|
|
46
|
+
const value = path.resolve(cwd, file).replace(/\\/g, '/');
|
|
47
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
48
|
+
};
|
|
49
|
+
const injectedBins = new Set();
|
|
50
|
+
const excludeBin = bin => {
|
|
51
|
+
injectedBins.add(normalize(bin));
|
|
52
|
+
injectedBins.add(normalize(resolved(bin) || bin));
|
|
53
|
+
};
|
|
54
|
+
// Evidence: npm 11.20.0, offline npx --package <local tarball> <probe>,
|
|
55
|
+
// exported npm_command=exec, npm_lifecycle_event=npx and prepended
|
|
56
|
+
// <cache>/_npx/<hash>/node_modules/.bin, then project/ancestor bins.
|
|
57
|
+
// libnpmexec/lib/index.js and @npmcli/run-script/lib/set-path.js confirm
|
|
58
|
+
// those shapes. Also observed with pnpm 11.17.0 and a local probe tarball:
|
|
59
|
+
// $XDG_CACHE_HOME/pnpm/dlx/<hash>/<id>/node_modules/.bin on PATH.
|
|
60
|
+
// pnpm v11.17.0 pnpm11/exec/commands/src/dlx.ts builds that shape under
|
|
61
|
+
// any configured cacheDir, using a 32-hex key (v10.11.0 uses 64).
|
|
62
|
+
// Its config/reader/src/dirs.ts defaults to pnpm-cache on Windows.
|
|
63
|
+
// v9.15.9 uses the same layout with a 26-character base32 cache key,
|
|
64
|
+
// also observed with a local runlist tarball in an isolated HOME/cache.
|
|
65
|
+
// pnpm v8.15.9 exec/plugin-commands-script-runners/src/dlx.ts instead
|
|
66
|
+
// prepends <storeDir>/tmp/dlx-<pid>/node_modules/.bin. Source-verified:
|
|
67
|
+
// https://github.com/pnpm/pnpm/blob/v11.17.0/pnpm11/exec/commands/src/dlx.ts
|
|
68
|
+
// https://github.com/pnpm/pnpm/blob/v9.15.9/exec/plugin-commands-script-runners/src/dlx.ts
|
|
69
|
+
// https://github.com/pnpm/pnpm/blob/v8.15.9/exec/plugin-commands-script-runners/src/dlx.ts
|
|
70
|
+
// Source-inferred (not runtime-tested here): Bun bun-v1.3.3's
|
|
71
|
+
// src/cli/bunx_command.zig uses <tmp>/bunx-<uid>-<package>/node_modules/.bin;
|
|
72
|
+
// current src/runtime/cli/bunx_command.rs also uses
|
|
73
|
+
// <cache-or-tmp>/.bunx-<uid>/<package>/node_modules/.bin on Unix.
|
|
74
|
+
// Match whole path segments under these roots, including resolved links.
|
|
75
|
+
// These are one-shot launchers regardless of npm env markers.
|
|
76
|
+
// npm also injects project/ancestor bins,
|
|
77
|
+
// even for local devDependencies; exclude those while under npm.
|
|
78
|
+
// Do not infer persistence from the launcher.
|
|
79
|
+
if (env.npm_command || env.npm_execpath || env.npm_lifecycle_event) {
|
|
80
|
+
for (const start of [cwd, env.INIT_CWD, env.npm_config_local_prefix, env.npm_package_json && path.dirname(env.npm_package_json)].filter(Boolean)) {
|
|
81
|
+
let dir = path.resolve(cwd, start);
|
|
82
|
+
for (;;) {
|
|
83
|
+
const bin = path.join(dir, 'node_modules', '.bin');
|
|
84
|
+
excludeBin(bin);
|
|
85
|
+
const parent = path.dirname(dir);
|
|
86
|
+
if (parent === dir) break;
|
|
87
|
+
dir = parent;
|
|
37
88
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Yarn 4.9.2 scriptUtils.ts: executePackageAccessibleBinary and
|
|
92
|
+
// makeScriptEnv create a separate <tmp>/xfs-<id> wrapper folder, prepend
|
|
93
|
+
// it to PATH and export BERRY_BIN_FOLDER. run/exec use it too. The wrappers
|
|
94
|
+
// are regular files; their realpaths do not reveal the dlx package folder.
|
|
95
|
+
// Observed with a local probe tarball and source-verified at
|
|
96
|
+
// a9edb7777f04ba16f51503ef6775325b353b67cc:
|
|
97
|
+
// https://github.com/yarnpkg/berry/blob/a9edb7777f04ba16f51503ef6775325b353b67cc/packages/yarnpkg-core/sources/scriptUtils.ts
|
|
98
|
+
// Nested Yarn calls retain outer wrapper folders on PATH while replacing
|
|
99
|
+
// BERRY_BIN_FOLDER (observed with a devDependency via `yarn run ybin`, whose
|
|
100
|
+
// script calls `yarn runlist`). xfs.ts creates xfs-<8 hex> under os.tmpdir():
|
|
101
|
+
// https://github.com/yarnpkg/berry/blob/a9edb7777f04ba16f51503ef6775325b353b67cc/packages/yarnpkg-fslib/sources/xfs.ts
|
|
102
|
+
// Exclude all xfs wrapper folders under the real temp root
|
|
103
|
+
// only while Yarn's marker is set; an ordinary shell may use such a folder.
|
|
104
|
+
if (env.BERRY_BIN_FOLDER) {
|
|
105
|
+
excludeBin(env.BERRY_BIN_FOLDER);
|
|
106
|
+
const tmp = normalize(resolved(os.tmpdir()) || os.tmpdir());
|
|
107
|
+
for (const bin of (env.PATH ?? '').split(path.delimiter).filter(Boolean)) {
|
|
108
|
+
const realBin = resolved(path.resolve(cwd, bin)) || path.resolve(cwd, bin);
|
|
109
|
+
if (normalize(path.dirname(realBin)) === tmp && /^xfs-[0-9a-f]{8}$/.test(path.basename(realBin))) excludeBin(bin);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const inCache = file => isOneShotCliPath(normalize(file));
|
|
113
|
+
const accept = file => {
|
|
114
|
+
const dir = path.dirname(file), realDir = resolved(dir) || dir;
|
|
115
|
+
const realFile = resolved(file) || file;
|
|
116
|
+
return !injectedBins.has(normalize(dir)) && !injectedBins.has(normalize(realDir))
|
|
117
|
+
&& !injectedBins.has(normalize(path.dirname(realFile)))
|
|
118
|
+
&& !inCache(dir) && !inCache(realDir) && !inCache(realFile);
|
|
119
|
+
};
|
|
120
|
+
for (const name of [PRODUCT_NAME, LEGACY_PRODUCT_NAME]) {
|
|
121
|
+
const file = executable(name, env, { cwd, runnable: true, accept });
|
|
122
|
+
if (file) return { available: true, path: file };
|
|
123
|
+
}
|
|
124
|
+
return { available: false, path: null };
|
|
42
125
|
}
|
|
43
126
|
|
|
44
127
|
function identity(root) {
|
|
@@ -49,8 +132,8 @@ function identity(root) {
|
|
|
49
132
|
}
|
|
50
133
|
|
|
51
134
|
// Shared with update; reading a host registry does not activate or modify it.
|
|
52
|
-
export function readInstalledPluginRecords({home=os.homedir(),id}={}) {
|
|
53
|
-
const plugins=json(path.join(
|
|
135
|
+
export function readInstalledPluginRecords({home=os.homedir(),configDir=path.join(home,'.claude'),id}={}) {
|
|
136
|
+
const plugins=json(path.join(configDir,'plugins/installed_plugins.json'))?.plugins??{};
|
|
54
137
|
id ??= plugins['runlist@runlist']?'runlist@runlist':plugins['dotmd@dotmd']?'dotmd@dotmd':Object.keys(plugins).find(k=>/^(?:runlist|dotmd)@/.test(k));
|
|
55
138
|
if(!id||!plugins[id])return null;
|
|
56
139
|
return {id,entries:(Array.isArray(plugins[id])?plugins[id]:[plugins[id]]).filter(Boolean)};
|
|
@@ -79,7 +162,7 @@ export function installationReport(config={}, {root=packageRoot,home=os.homedir(
|
|
|
79
162
|
const cache=path.join(home,'.codex/plugins/cache',market,'runlist-codex');
|
|
80
163
|
let cached=[];
|
|
81
164
|
try {cached=readdirSync(cache).slice(0,20).map(version=>({path:path.join(cache,version),version,role:'cached copy; active host copy unverified'}));}catch{}
|
|
82
|
-
const pluginFiles=['skills','commands','hooks','.claude-plugin','.codex-plugin'];
|
|
165
|
+
const pluginFiles=['skills','commands','hooks','hooks.json','bin','.claude-plugin','.codex-plugin'];
|
|
83
166
|
const pluginCopy = (file,version,role,source) => {
|
|
84
167
|
const fingerprint=file?sourceFingerprint(file,pluginFiles):{state:'unavailable'},expected=sourceFingerprint(source,pluginFiles);
|
|
85
168
|
return {path:file,version:version??null,role,fingerprint,sameSource:fingerprint.sha256&&expected.sha256?fingerprint.sha256===expected.sha256:null};
|
package/src/journal.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const REDACTED = '[redacted]';
|
|
|
27
27
|
const SENSITIVE_VALUE_FLAGS = new Set([
|
|
28
28
|
'--body', '--message', '--note', '--reason', '--question', '--answers', '--choice', '--title',
|
|
29
29
|
'--token', '--password', '--passphrase', '--secret', '--api-key', '--apikey',
|
|
30
|
-
'--auth', '--authorization', '--cookie', '--header',
|
|
30
|
+
'--auth', '--authorization', '--cookie', '--header', '--current-state', '--next-step',
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
33
|
function sanitizeCredentialShapes(value) {
|
|
@@ -120,7 +120,7 @@ export function sanitizeTelemetryText(value, secrets = []) {
|
|
|
120
120
|
text = text.split(secret).join(REDACTED);
|
|
121
121
|
}
|
|
122
122
|
return sanitizeCredentialShapes(text)
|
|
123
|
-
.replace(/((?:--body|--message|--note|--reason|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
|
|
123
|
+
.replace(/((?:--body|--message|--note|--reason|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header|--current-state|--next-step)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
|
|
124
124
|
.replace(/(git\s+commit\b[^\n]*?(?:\s-m|\s--message)(?:=|\s+))((?:"[^"]*")|(?:'[^']*')|[^\s]+)/gi, `$1${REDACTED}`)
|
|
125
125
|
.replace(/((?:sed|perl|g?awk)\b[^\n]*?\s(?:-e\s+)?)((?:"[^"]*")|(?:'[^']*'))/gi, `$1${REDACTED}`);
|
|
126
126
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
updateOwnershipOperation,
|
|
34
34
|
validatedClaimOperation,
|
|
35
35
|
} from './pickup.mjs';
|
|
36
|
+
import { assertInsideHandoffs } from './handoffs-scope.mjs';
|
|
37
|
+
import { LEGACY_PROMPT_DIR, PROMPT_DIR, rootIsWorkingFolder } from './naming.mjs';
|
|
36
38
|
|
|
37
39
|
export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
|
|
38
40
|
raw = normalizeEol(raw);
|
|
@@ -165,20 +167,24 @@ export function updateFrontmatterAtomic(filePath, updates, config, options = {})
|
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
// Before any config the root is the working folder, so prompts sit two levels
|
|
171
|
+
// down in docs/prompts/; one an older build saved in a root prompts/ still files
|
|
172
|
+
// beside itself until it is carried over (src/naming.mjs).
|
|
173
|
+
function defaultTypeDirs(docType, config) {
|
|
174
|
+
if (docType === 'plan') return ['plans'];
|
|
175
|
+
if (docType === 'prompt') return rootIsWorkingFolder(config) ? [PROMPT_DIR, LEGACY_PROMPT_DIR] : ['prompts'];
|
|
171
176
|
const templateDir = config.raw?.templates?.[docType]?.dir;
|
|
172
|
-
return typeof templateDir === 'string' && templateDir ? templateDir :
|
|
177
|
+
return typeof templateDir === 'string' && templateDir ? [templateDir] : [];
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
function findFilingRoot(filePath, fileRoot, docType, config) {
|
|
176
|
-
const dirName = defaultTypeDir(docType, config);
|
|
177
|
-
if (!dirName) return fileRoot;
|
|
178
|
-
if (path.basename(fileRoot) === dirName) return fileRoot;
|
|
179
|
-
|
|
180
181
|
const relSegments = path.relative(fileRoot, filePath).split(path.sep);
|
|
181
|
-
|
|
182
|
+
for (const dirName of defaultTypeDirs(docType, config)) {
|
|
183
|
+
// A template's own dir is one folder name, as it always was.
|
|
184
|
+
const segments = dirName === PROMPT_DIR ? dirName.split('/') : [dirName];
|
|
185
|
+
if (fileRoot.split(path.sep).slice(-segments.length).join('/') === dirName) return fileRoot;
|
|
186
|
+
if (segments.every((segment, i) => relSegments[i] === segment)) return path.join(fileRoot, ...segments);
|
|
187
|
+
}
|
|
182
188
|
return fileRoot;
|
|
183
189
|
}
|
|
184
190
|
|
|
@@ -188,7 +194,7 @@ function findFilingRoot(filePath, fileRoot, docType, config) {
|
|
|
188
194
|
// prompt churn doesn't bury plans/docs in the shared <root>/<archiveDir>.
|
|
189
195
|
// Everything else archives under fileRoot. Used by both runStatus (set
|
|
190
196
|
// archived) and runArchive so the two paths stay in lockstep.
|
|
191
|
-
function archiveBaseFor(filePath, fileRoot, docType, config) {
|
|
197
|
+
export function archiveBaseFor(filePath, fileRoot, docType, config) {
|
|
192
198
|
const nest = config.lifecycle?.archiveNestedTypes?.has(docType) ?? false;
|
|
193
199
|
return nest ? findFilingRoot(filePath, fileRoot, docType, config) : fileRoot;
|
|
194
200
|
}
|
|
@@ -378,7 +384,9 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
378
384
|
let newStatus = argv[1];
|
|
379
385
|
|
|
380
386
|
if (!opts.suppressDeprecation) {
|
|
381
|
-
process.stderr.write(dim(
|
|
387
|
+
process.stderr.write(dim(config.mode === 'handoffs'
|
|
388
|
+
? '`runlist status <file> <status>` is deprecated; prefer `runlist set <status> <prompt>` (note: <status> first). Removed in a future major.\n'
|
|
389
|
+
: '`runlist status <file> <status>` is deprecated; prefer `runlist set <status> [<file>]` (note: <status> first; <file> optional when a plan is in-session). Removed in a future major.\n'));
|
|
382
390
|
}
|
|
383
391
|
|
|
384
392
|
if (!input) { die('Usage: runlist status <file> <new-status>'); }
|
|
@@ -979,6 +987,8 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
979
987
|
|
|
980
988
|
if (!newStatus) die('Usage: runlist set <status> [<path>]');
|
|
981
989
|
let sessionId = null;
|
|
990
|
+
// Where planning is off there is no claimed plan to default to.
|
|
991
|
+
if (!input && config.mode === 'handoffs') die(`Name the prompt to change: runlist set ${newStatus} <prompt>`);
|
|
982
992
|
if (!input) {
|
|
983
993
|
sessionId = authoritativeSessionId();
|
|
984
994
|
const owned = listOwnedPlans(config, sessionId);
|
|
@@ -1119,6 +1129,7 @@ export function runBulkArchive(argv, config, opts = {}) {
|
|
|
1119
1129
|
const matched = [];
|
|
1120
1130
|
|
|
1121
1131
|
for (const input of inputs) {
|
|
1132
|
+
assertInsideHandoffs(input, config);
|
|
1122
1133
|
const filePath = resolveDocPath(input, config);
|
|
1123
1134
|
if (filePath) {
|
|
1124
1135
|
matched.push(filePath);
|
package/src/managed-path.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { managesFile, outsideHandoffsLine } from './handoffs-scope.mjs';
|
|
4
|
+
import { LEGACY_PROMPT_DIR, PROMPT_DIR } from './naming.mjs';
|
|
3
5
|
|
|
4
6
|
// These checks are authorization preflights, not filesystem transactions. A
|
|
5
7
|
// concurrent actor can still replace a checked ancestor before the later write
|
|
@@ -38,13 +40,20 @@ function lexicalRootsFor(config) {
|
|
|
38
40
|
}));
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
function rootsFor(config) {
|
|
43
|
+
function rootsFor(config, { allowMissingRoots = false } = {}) {
|
|
42
44
|
return lexicalRootsFor(config).map(root => {
|
|
43
45
|
const { configuredPath, lexicalPath } = root;
|
|
44
46
|
let canonicalPath;
|
|
45
47
|
try {
|
|
46
48
|
canonicalPath = realpathSync(lexicalPath);
|
|
47
49
|
} catch (err) {
|
|
50
|
+
if (allowMissingRoots && err.code === 'ENOENT') {
|
|
51
|
+
const ancestor = nearestExistingAncestor(lexicalPath, [root], 'Configured docs root');
|
|
52
|
+
let existing = lexicalPath;
|
|
53
|
+
while (!existsSync(existing)) existing = path.dirname(existing);
|
|
54
|
+
canonicalPath = path.resolve(ancestor, path.relative(existing, lexicalPath));
|
|
55
|
+
return { configuredPath, lexicalPath, canonicalPath };
|
|
56
|
+
}
|
|
48
57
|
throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
|
|
49
58
|
}
|
|
50
59
|
return { configuredPath, lexicalPath, canonicalPath };
|
|
@@ -95,8 +104,8 @@ export function findLexicalDocsRoot(input, config) {
|
|
|
95
104
|
return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
|
|
96
105
|
}
|
|
97
106
|
|
|
98
|
-
export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
|
|
99
|
-
const roots = rootsFor(config);
|
|
107
|
+
export function authorizeManagedSource(input, config, { kind = 'Managed mutation source', allowMissingRoots = false } = {}) {
|
|
108
|
+
const roots = rootsFor(config, { allowMissingRoots });
|
|
100
109
|
const lexicalPath = path.resolve(input);
|
|
101
110
|
markdown(lexicalPath, kind, roots);
|
|
102
111
|
|
|
@@ -116,6 +125,9 @@ export function authorizeManagedSource(input, config, { kind = 'Managed mutation
|
|
|
116
125
|
if (!statSync(canonicalPath).isFile()) {
|
|
117
126
|
throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
118
127
|
}
|
|
128
|
+
if (!managesFile(canonicalPath, config)) {
|
|
129
|
+
throw new Error(`${kind}: ${outsideHandoffsLine(path.relative(config.repoRoot, lexicalPath).split(path.sep).join('/'))}`);
|
|
130
|
+
}
|
|
119
131
|
|
|
120
132
|
const lexical = lexicalOwner(lexicalPath, roots);
|
|
121
133
|
if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
|
|
@@ -137,10 +149,62 @@ export function authorizeManagedSource(input, config, { kind = 'Managed mutation
|
|
|
137
149
|
return { path: managedPath, canonicalPath, root };
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
|
|
152
|
+
// One prompt location (src/naming.mjs): before any config, and where
|
|
153
|
+
// planning is off, nothing is created in the root prompts/ an older runlist
|
|
154
|
+
// saved prompts in. Its prompts are carried out to docs/prompts/ instead.
|
|
155
|
+
// Transaction recovery restoring a carry's source is not a new file.
|
|
156
|
+
// Whether `file` is in the root prompts/, by its path or by where its folders
|
|
157
|
+
// physically lead: a docs/ that is a symlink to prompts/ puts docs/prompts/
|
|
158
|
+
// inside it. Configured planning projects keep the prompt folder they chose,
|
|
159
|
+
// so callers ask only before any config or where planning is off.
|
|
160
|
+
export function inLegacyPromptFolder(file, repoRoot) {
|
|
161
|
+
const top = path.resolve(repoRoot);
|
|
162
|
+
const legacy = path.join(top, LEGACY_PROMPT_DIR);
|
|
163
|
+
const lexical = path.resolve(file);
|
|
164
|
+
if (contains(legacy, lexical)) return true;
|
|
165
|
+
let existing = lexical;
|
|
166
|
+
const rest = [];
|
|
167
|
+
while (!existsSync(existing)) {
|
|
168
|
+
const parent = path.dirname(existing);
|
|
169
|
+
if (parent === existing) return false;
|
|
170
|
+
rest.unshift(path.basename(existing));
|
|
171
|
+
existing = parent;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const physical = path.join(realpathSync(existing), ...rest);
|
|
175
|
+
const physicalLegacy = existsSync(legacy) ? realpathSync(legacy) : path.join(realpathSync(top), LEGACY_PROMPT_DIR);
|
|
176
|
+
return contains(physicalLegacy, physical);
|
|
177
|
+
} catch { return false; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function legacyPromptFolderMessage(file, repoRoot, kind) {
|
|
181
|
+
const relative = path.relative(repoRoot, path.resolve(file)).split(path.sep).join('/') || '.';
|
|
182
|
+
return `${kind}: ${relative} ${legacyPromptFolderPlace(file, repoRoot)}, where runlist no longer writes; prompts live in ${PROMPT_DIR}/.`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function legacyPromptFolderPlace(file, repoRoot) {
|
|
186
|
+
return contains(path.join(path.resolve(repoRoot), LEGACY_PROMPT_DIR), path.resolve(file))
|
|
187
|
+
? `is in ${LEGACY_PROMPT_DIR}/ at the project root`
|
|
188
|
+
: `leads into ${LEGACY_PROMPT_DIR}/ at the project root through a symlinked folder`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assertNotLegacyPromptFolder(lexicalPath, config, kind) {
|
|
192
|
+
if (config.configFound && config.mode !== 'handoffs') return;
|
|
193
|
+
if (!inLegacyPromptFolder(lexicalPath, config.repoRoot)) return;
|
|
194
|
+
throw new Error(legacyPromptFolderMessage(lexicalPath, config.repoRoot, kind));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Files runlist writes outside the document roots (exports): before any
|
|
198
|
+
// config and where planning is off, never into the root prompts/.
|
|
199
|
+
export function assertOutsideLegacyPromptFolder(file, config, kind) {
|
|
200
|
+
assertNotLegacyPromptFolder(path.resolve(file), config, kind);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination', recovery = false } = {}) {
|
|
141
204
|
const roots = rootsFor(config);
|
|
142
205
|
const lexicalPath = path.resolve(input);
|
|
143
206
|
markdown(lexicalPath, kind, roots);
|
|
207
|
+
if (!recovery) assertNotLegacyPromptFolder(lexicalPath, config, kind);
|
|
144
208
|
|
|
145
209
|
const root = requiredRoot
|
|
146
210
|
? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
|
package/src/migrate.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { collectDocFiles } from './index.mjs';
|
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { bold, green, dim } from './color.mjs';
|
|
8
8
|
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
9
|
+
import { assertInsideHandoffs, isHandoffsMode, planningRefusal } from './handoffs-scope.mjs';
|
|
9
10
|
|
|
10
11
|
export function runMigrate(argv, config, opts = {}) {
|
|
11
12
|
const { dryRun } = opts;
|
|
@@ -25,6 +26,11 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
25
26
|
if (!field || !oldValue || !newValue) {
|
|
26
27
|
die('Usage: runlist migrate <field> <old-value> <new-value> [files...]');
|
|
27
28
|
}
|
|
29
|
+
// A handoffs-only project manages prompts and nothing else: a prompt given
|
|
30
|
+
// another type would drop out of every listing (src/handoffs-scope.mjs).
|
|
31
|
+
if (isHandoffsMode(config) && field === 'type' && newValue !== 'prompt') {
|
|
32
|
+
die(planningRefusal(`\`runlist migrate type ${oldValue} ${newValue}\``));
|
|
33
|
+
}
|
|
28
34
|
|
|
29
35
|
const allFiles = collectDocFiles(config);
|
|
30
36
|
|
|
@@ -34,6 +40,7 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
34
40
|
const matched = [];
|
|
35
41
|
const unresolved = [];
|
|
36
42
|
for (const input of fileArgs) {
|
|
43
|
+
assertInsideHandoffs(input, config);
|
|
37
44
|
const filePath = resolveDocPath(input, config);
|
|
38
45
|
if (filePath) {
|
|
39
46
|
matched.push(filePath);
|
package/src/naming.mjs
CHANGED
|
@@ -22,6 +22,32 @@ export const CONFIG_FILENAMES = [
|
|
|
22
22
|
'dotmd.config.js',
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
+
// Where runlist writes prompts before any config, in a handoffs-only project
|
|
26
|
+
// and in a project `runlist init` starts. Builds before that saved them in a
|
|
27
|
+
// root prompts/ when there was no config; that folder is now only a source to
|
|
28
|
+
// carry them from (src/setup-carry.mjs), never a place anything new is
|
|
29
|
+
// written. A configured planning project keeps the prompt folder it chose.
|
|
30
|
+
export const PROMPT_DIR = 'docs/prompts';
|
|
31
|
+
export const LEGACY_PROMPT_DIR = 'prompts';
|
|
32
|
+
|
|
33
|
+
// Prompt statuses older builds wrote and later dropped: a prompt saved with
|
|
34
|
+
// one is still one runlist saved (carried like any other), and `check` names
|
|
35
|
+
// the verb that settles it rather than calling the status unknown.
|
|
36
|
+
export const RETIRED_PROMPT_STATUSES = new Set(['held', 'shelved', 'claimed']);
|
|
37
|
+
|
|
38
|
+
// The prompt routing a config written by `runlist setup` spells out, so its
|
|
39
|
+
// prompts go to <docs root>/prompts/ whatever the root is called. Without it
|
|
40
|
+
// the built-in routing reads a root whose name ends in "prompts" (notes/prompts,
|
|
41
|
+
// review-prompts) as the prompt folder itself, as configured projects rely on.
|
|
42
|
+
export const SETUP_PROMPT_ROUTING = Object.freeze({ dir: 'prompts', targetRoot: null });
|
|
43
|
+
|
|
44
|
+
// Before any config the docs root is the working folder itself, so prompts sit
|
|
45
|
+
// two levels down in it. A layout projected for a config about to be written
|
|
46
|
+
// (init, setup) has a docs root of its own and keeps the usual prompts/ in it.
|
|
47
|
+
export function rootIsWorkingFolder(config) {
|
|
48
|
+
return !config.configFound && Boolean(config.configDir) && path.resolve(config.docsRoot) === path.resolve(config.configDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
export const ENV_PREFIX = 'RUNLIST_';
|
|
26
52
|
export const LEGACY_ENV_PREFIX = 'DOTMD_';
|
|
27
53
|
|