wicked-bus 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/cli.js +11 -1
- package/commands/cmd-daemon.js +238 -0
- package/commands/cmd-ui.js +156 -0
- package/install.mjs +88 -15
- package/lib/archive.js +198 -0
- package/lib/cas.js +310 -0
- package/lib/causality.js +129 -0
- package/lib/daemon-client.js +209 -0
- package/lib/daemon-notify.js +97 -0
- package/lib/daemon-singleton.js +186 -0
- package/lib/daemon.js +444 -0
- package/lib/db.js +13 -6
- package/lib/emit.js +94 -5
- package/lib/errors.js +15 -0
- package/lib/ipc-protocol.js +158 -0
- package/lib/migrate.js +133 -0
- package/lib/query.js +189 -0
- package/lib/schema-registry.js +271 -0
- package/lib/subscribe-push-or-poll.js +263 -0
- package/lib/sweep-v2.js +420 -0
- package/lib/sweep.js +22 -12
- package/lib/ui-server.js +301 -0
- package/package.json +1 -1
package/commands/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ function printUsage() {
|
|
|
33
33
|
commands: [
|
|
34
34
|
'init', 'emit', 'subscribe', 'status', 'replay',
|
|
35
35
|
'cleanup', 'register', 'deregister', 'list', 'ack',
|
|
36
|
-
'dlq',
|
|
36
|
+
'dlq', 'daemon', 'ui',
|
|
37
37
|
],
|
|
38
38
|
global_flags: ['--db-path <path>', '--json', '--log-level <level>'],
|
|
39
39
|
};
|
|
@@ -128,6 +128,16 @@ async function main() {
|
|
|
128
128
|
await cmdDlq(args, globals, args._positional || []);
|
|
129
129
|
break;
|
|
130
130
|
}
|
|
131
|
+
case 'daemon': {
|
|
132
|
+
const { cmdDaemon } = await import('./cmd-daemon.js');
|
|
133
|
+
await cmdDaemon(args, globals, args._positional || []);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'ui': {
|
|
137
|
+
const { cmdUi } = await import('./cmd-ui.js');
|
|
138
|
+
await cmdUi(args, globals);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
131
141
|
default:
|
|
132
142
|
printUsage();
|
|
133
143
|
process.exit(command ? 1 : 0);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wicked-bus daemon <subcommand>` — start, stop, status.
|
|
3
|
+
*
|
|
4
|
+
* - `start [--detached]` spawn the daemon in this process or detach a child
|
|
5
|
+
* - `stop` send SIGTERM to the running daemon (clean shutdown)
|
|
6
|
+
* - `status` JSON snapshot: socket path, pid, subscribers
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle uses lib/daemon-singleton.js for the PID lock and
|
|
9
|
+
* lib/daemon.js for the socket server. Subscribers connect via the
|
|
10
|
+
* library / wrapper APIs; this CLI is operator-facing only.
|
|
11
|
+
*
|
|
12
|
+
* @module commands/cmd-daemon
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
|
|
20
|
+
import {
|
|
21
|
+
startDaemon,
|
|
22
|
+
socketPath,
|
|
23
|
+
} from '../lib/daemon.js';
|
|
24
|
+
import {
|
|
25
|
+
acquireDaemonLock,
|
|
26
|
+
} from '../lib/daemon-singleton.js';
|
|
27
|
+
import { probeDaemon } from '../lib/daemon-client.js';
|
|
28
|
+
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
31
|
+
|
|
32
|
+
export async function cmdDaemon(args, globals, positional) {
|
|
33
|
+
const sub = (positional && positional[0]) || null;
|
|
34
|
+
if (!sub) {
|
|
35
|
+
return emitJson({
|
|
36
|
+
error: 'usage',
|
|
37
|
+
message: 'daemon requires a subcommand: start | stop | status',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case 'start': return cmdDaemonStart(args, globals);
|
|
43
|
+
case 'stop': return cmdDaemonStop(args, globals);
|
|
44
|
+
case 'status': return cmdDaemonStatus(args, globals);
|
|
45
|
+
default:
|
|
46
|
+
return emitJson({
|
|
47
|
+
error: 'usage',
|
|
48
|
+
message: `unknown daemon subcommand: ${sub}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// start
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async function cmdDaemonStart(args /*, globals */) {
|
|
58
|
+
const dataDir = resolveDataDir();
|
|
59
|
+
ensureDataDir();
|
|
60
|
+
|
|
61
|
+
// Detached: fork ourselves with `--no-detach` so the child runs the
|
|
62
|
+
// foreground branch. Parent waits for the socket to come up, prints
|
|
63
|
+
// status, exits 0. Stderr is captured to a log file in dataDir so the
|
|
64
|
+
// child's startup errors are diagnosable even when stdio is detached.
|
|
65
|
+
if (args.detached !== false && (args.detached === true || args['detach'] === true)) {
|
|
66
|
+
const logPath = path.join(dataDir, 'daemon.log');
|
|
67
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
68
|
+
const child = spawn(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[path.resolve(__dirname, 'cli.js'), 'daemon', 'start', '--no-detach'],
|
|
71
|
+
{
|
|
72
|
+
detached: true,
|
|
73
|
+
stdio: ['ignore', logFd, logFd],
|
|
74
|
+
env: process.env,
|
|
75
|
+
cwd: dataDir,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
fs.closeSync(logFd);
|
|
79
|
+
child.unref();
|
|
80
|
+
|
|
81
|
+
const ok = await waitForSocket(dataDir, 5000);
|
|
82
|
+
if (!ok) {
|
|
83
|
+
let logTail = '';
|
|
84
|
+
try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
|
|
85
|
+
catch (_e) { /* ignore */ }
|
|
86
|
+
return emitJsonExit({
|
|
87
|
+
error: 'daemon-start-timeout',
|
|
88
|
+
message: 'spawned daemon did not start listening within 5s',
|
|
89
|
+
socket: socketPath(dataDir),
|
|
90
|
+
log_tail: logTail,
|
|
91
|
+
}, 1);
|
|
92
|
+
}
|
|
93
|
+
return emitJson({
|
|
94
|
+
ok: true,
|
|
95
|
+
mode: 'detached',
|
|
96
|
+
pid: child.pid,
|
|
97
|
+
socket: socketPath(dataDir),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Foreground: hold the lock, run the daemon, hand off to a SIGTERM/SIGINT
|
|
102
|
+
// handler for clean shutdown. This branch is what `--detached` re-enters.
|
|
103
|
+
let lock;
|
|
104
|
+
try {
|
|
105
|
+
lock = acquireDaemonLock(dataDir);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e.code === 'EALREADY_RUNNING') {
|
|
108
|
+
return emitJsonExit({
|
|
109
|
+
error: 'EALREADY_RUNNING',
|
|
110
|
+
message: e.message,
|
|
111
|
+
prior_pid: e.priorPid,
|
|
112
|
+
socket: socketPath(dataDir),
|
|
113
|
+
}, 1);
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const daemon = await startDaemon({
|
|
119
|
+
dataDir,
|
|
120
|
+
inline_payload_max_bytes: numFlag(args, 'inline-payload-max-bytes'),
|
|
121
|
+
subscriber_queue_max: numFlag(args, 'subscriber-queue-max'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const shutdown = async () => {
|
|
125
|
+
try { await daemon.stop(); } catch (_e) { /* ignore */ }
|
|
126
|
+
try { lock.release(); } catch (_e) { /* ignore */ }
|
|
127
|
+
process.exit(0);
|
|
128
|
+
};
|
|
129
|
+
process.on('SIGTERM', shutdown);
|
|
130
|
+
process.on('SIGINT', shutdown);
|
|
131
|
+
|
|
132
|
+
emitJson({
|
|
133
|
+
ok: true,
|
|
134
|
+
mode: 'foreground',
|
|
135
|
+
pid: process.pid,
|
|
136
|
+
socket: daemon.socketPath,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Stay alive until a signal fires shutdown(). The Node event loop keeps
|
|
140
|
+
// the process running because the server holds an active handle.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// stop
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async function cmdDaemonStop(/* args, globals */) {
|
|
148
|
+
const dataDir = resolveDataDir();
|
|
149
|
+
const lockPath = path.join(dataDir, 'daemon.lock');
|
|
150
|
+
|
|
151
|
+
let pid;
|
|
152
|
+
try {
|
|
153
|
+
pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null;
|
|
154
|
+
} catch (_e) {
|
|
155
|
+
pid = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!pid) {
|
|
159
|
+
return emitJson({ ok: false, reason: 'no-lock-file', socket: socketPath(dataDir) });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
process.kill(pid, 'SIGTERM');
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return emitJsonExit({
|
|
166
|
+
error: 'kill-failed',
|
|
167
|
+
pid,
|
|
168
|
+
message: e.message,
|
|
169
|
+
}, 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Best-effort wait for the daemon to release the lock.
|
|
173
|
+
const released = await waitForLockRelease(lockPath, 5000);
|
|
174
|
+
return emitJson({
|
|
175
|
+
ok: true,
|
|
176
|
+
pid,
|
|
177
|
+
released,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// status
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async function cmdDaemonStatus(/* args, globals */) {
|
|
186
|
+
const dataDir = resolveDataDir();
|
|
187
|
+
const lockPath = path.join(dataDir, 'daemon.lock');
|
|
188
|
+
const sockPath = socketPath(dataDir);
|
|
189
|
+
|
|
190
|
+
let pid = null;
|
|
191
|
+
try { pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null; }
|
|
192
|
+
catch (_e) { /* no lock */ }
|
|
193
|
+
|
|
194
|
+
const reachable = await probeDaemon(dataDir, 200);
|
|
195
|
+
|
|
196
|
+
return emitJson({
|
|
197
|
+
socket: sockPath,
|
|
198
|
+
lock_path: lockPath,
|
|
199
|
+
pid,
|
|
200
|
+
reachable,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function emitJson(obj) {
|
|
207
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function emitJsonExit(obj, code) {
|
|
211
|
+
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
212
|
+
process.exit(code);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function numFlag(args, name) {
|
|
216
|
+
const v = args[name];
|
|
217
|
+
if (v == null || v === true) return undefined;
|
|
218
|
+
const n = Number(v);
|
|
219
|
+
return Number.isFinite(n) ? n : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function waitForSocket(dataDir, timeoutMs) {
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
if (await probeDaemon(dataDir, 100)) return true;
|
|
226
|
+
await new Promise(r => setTimeout(r, 50));
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function waitForLockRelease(lockPath, timeoutMs) {
|
|
232
|
+
const deadline = Date.now() + timeoutMs;
|
|
233
|
+
while (Date.now() < deadline) {
|
|
234
|
+
if (!fs.existsSync(lockPath)) return true;
|
|
235
|
+
await new Promise(r => setTimeout(r, 50));
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wicked-bus ui` — operator-facing CLI for the read-only UI server.
|
|
3
|
+
*
|
|
4
|
+
* wicked-bus ui start in foreground, JSON to stdout
|
|
5
|
+
* wicked-bus ui --detached fork a child, print connection info, exit 0
|
|
6
|
+
* wicked-bus ui --rotate-token regenerate the bearer token
|
|
7
|
+
* wicked-bus ui --host 0.0.0.0 bind non-loopback (warning printed)
|
|
8
|
+
* wicked-bus ui --port 7842 bind a specific port (default 0 = ephemeral
|
|
9
|
+
* in foreground; default 7842 in detached
|
|
10
|
+
* mode so a known port is reachable)
|
|
11
|
+
*
|
|
12
|
+
* Foreground sets up SIGTERM/SIGINT handlers for clean shutdown. Detached
|
|
13
|
+
* redirects child stdio to `<dataDir>/ui.log` so startup failures are
|
|
14
|
+
* diagnosable (same lesson as the daemon CLI: stdio:'ignore' on a long-
|
|
15
|
+
* running spawn hides everything).
|
|
16
|
+
*
|
|
17
|
+
* @module commands/cmd-ui
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
|
|
25
|
+
import { openDb } from '../lib/db.js';
|
|
26
|
+
import { startUiServer, DEFAULT_HOST, DEFAULT_PORT } from '../lib/ui-server.js';
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = path.dirname(__filename);
|
|
30
|
+
|
|
31
|
+
export async function cmdUi(args /*, globals */) {
|
|
32
|
+
const dataDir = resolveDataDir();
|
|
33
|
+
ensureDataDir();
|
|
34
|
+
|
|
35
|
+
// --no-detach is the inner branch; --detached is the outer fork.
|
|
36
|
+
if (args.detached === true || args['detach'] === true) {
|
|
37
|
+
return startDetached(args, dataDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return startForeground(args, dataDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
async function startDetached(args, dataDir) {
|
|
46
|
+
const logPath = path.join(dataDir, 'ui.log');
|
|
47
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
48
|
+
|
|
49
|
+
// Re-invoke ourselves with --no-detach + a known port so the child can
|
|
50
|
+
// start cleanly and the parent can probe.
|
|
51
|
+
const port = args.port ? String(args.port) : String(DEFAULT_PORT);
|
|
52
|
+
const host = args.host ? String(args.host) : DEFAULT_HOST;
|
|
53
|
+
|
|
54
|
+
const childArgs = [
|
|
55
|
+
path.resolve(__dirname, 'cli.js'),
|
|
56
|
+
'ui', '--no-detach',
|
|
57
|
+
'--port', port,
|
|
58
|
+
'--host', host,
|
|
59
|
+
];
|
|
60
|
+
if (args['rotate-token'] === true || args.rotate_token === true) {
|
|
61
|
+
childArgs.push('--rotate-token');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const child = spawn(process.execPath, childArgs, {
|
|
65
|
+
detached: true,
|
|
66
|
+
stdio: ['ignore', logFd, logFd],
|
|
67
|
+
env: process.env,
|
|
68
|
+
cwd: dataDir,
|
|
69
|
+
});
|
|
70
|
+
fs.closeSync(logFd);
|
|
71
|
+
child.unref();
|
|
72
|
+
|
|
73
|
+
const reachable = await waitForUi(host, port, 5000);
|
|
74
|
+
if (!reachable) {
|
|
75
|
+
let logTail = '';
|
|
76
|
+
try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
|
|
77
|
+
catch (_e) { /* ignore */ }
|
|
78
|
+
return emitJsonExit({
|
|
79
|
+
error: 'ui-start-timeout',
|
|
80
|
+
message: 'spawned UI did not start listening within 5s',
|
|
81
|
+
log_tail: logTail,
|
|
82
|
+
}, 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return emitJson({
|
|
86
|
+
ok: true,
|
|
87
|
+
mode: 'detached',
|
|
88
|
+
pid: child.pid,
|
|
89
|
+
host,
|
|
90
|
+
port: Number(port),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
async function startForeground(args, dataDir) {
|
|
97
|
+
const liveDb = openDb();
|
|
98
|
+
const ui = await startUiServer({
|
|
99
|
+
dataDir,
|
|
100
|
+
liveDb,
|
|
101
|
+
host: args.host ?? DEFAULT_HOST,
|
|
102
|
+
port: args.port != null ? Number(args.port) : DEFAULT_PORT,
|
|
103
|
+
rotate_token: args['rotate-token'] === true || args.rotate_token === true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const shutdown = async () => {
|
|
107
|
+
try { await ui.stop(); } catch (_e) { /* ignore */ }
|
|
108
|
+
try { liveDb.close(); } catch (_e) { /* ignore */ }
|
|
109
|
+
process.exit(0);
|
|
110
|
+
};
|
|
111
|
+
process.on('SIGTERM', shutdown);
|
|
112
|
+
process.on('SIGINT', shutdown);
|
|
113
|
+
|
|
114
|
+
emitJson({
|
|
115
|
+
ok: true,
|
|
116
|
+
mode: 'foreground',
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
host: ui.host,
|
|
119
|
+
port: ui.port,
|
|
120
|
+
token_path: ui.token_path,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async function waitForUi(host, port, timeoutMs) {
|
|
127
|
+
const http = await import('node:http');
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (await ping(http.default, host, port)) return true;
|
|
131
|
+
await new Promise(r => setTimeout(r, 50));
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ping(http, host, port) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const req = http.request({ host, port, path: '/healthz', method: 'GET', timeout: 200 }, (res) => {
|
|
139
|
+
// Drain so the connection releases.
|
|
140
|
+
res.resume();
|
|
141
|
+
resolve(res.statusCode === 200);
|
|
142
|
+
});
|
|
143
|
+
req.on('error', () => resolve(false));
|
|
144
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function emitJson(obj) {
|
|
150
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function emitJsonExit(obj, code) {
|
|
154
|
+
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
155
|
+
process.exit(code);
|
|
156
|
+
}
|
package/install.mjs
CHANGED
|
@@ -11,8 +11,46 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
|
11
11
|
const skillsSource = join(__dirname, "skills");
|
|
12
12
|
const home = homedir();
|
|
13
13
|
|
|
14
|
+
// Claude-root candidate builder. Mirrors the 1.1.1 wicked-testing /
|
|
15
|
+
// wicked-brain fix: $CLAUDE_CONFIG_DIR is authoritative when set;
|
|
16
|
+
// otherwise probe common alt-config layouts. Claude Code's config root
|
|
17
|
+
// is redirectable, and hardcoded ~/.claude silently misses users on
|
|
18
|
+
// shared-home / multi-tenant setups.
|
|
19
|
+
function buildClaudeTarget(rootDir, source, { trusted = false } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
name: "claude",
|
|
22
|
+
rootDir,
|
|
23
|
+
dir: join(rootDir, "skills"),
|
|
24
|
+
platform: "claude",
|
|
25
|
+
identityMarkers: ["settings.json", "plugins", "projects"],
|
|
26
|
+
source,
|
|
27
|
+
trusted,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveClaudeCandidates() {
|
|
32
|
+
const envDir = process.env.CLAUDE_CONFIG_DIR;
|
|
33
|
+
if (envDir && typeof envDir === "string" && envDir.trim()) {
|
|
34
|
+
// Function replacement avoids `$&` etc. being interpreted as regex
|
|
35
|
+
// back-references if $HOME contains those literals.
|
|
36
|
+
const root = resolve(envDir.trim().replace(/^~/, () => home));
|
|
37
|
+
return [buildClaudeTarget(root, "env:CLAUDE_CONFIG_DIR", { trusted: true })];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
buildClaudeTarget(join(home, ".claude"), "default"),
|
|
41
|
+
buildClaudeTarget(join(home, "alt-configs", ".claude"), "alt-configs"),
|
|
42
|
+
buildClaudeTarget(join(home, ".config", "claude"), "xdg"),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function claudeHasIdentityMarker(target) {
|
|
47
|
+
if (target.trusted) return true;
|
|
48
|
+
if (!existsSync(target.rootDir)) return false;
|
|
49
|
+
return (target.identityMarkers || []).some(m => existsSync(join(target.rootDir, m)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Non-claude canonical targets. Claude is expanded dynamically above.
|
|
14
53
|
const CLI_TARGETS = [
|
|
15
|
-
{ name: "claude", dir: join(home, ".claude", "skills"), platform: "claude" },
|
|
16
54
|
{ name: "gemini", dir: join(home, ".gemini", "skills"), platform: "gemini" },
|
|
17
55
|
{ name: "copilot", dir: join(home, ".github", "skills"), platform: "copilot" },
|
|
18
56
|
{ name: "codex", dir: join(home, ".codex", "skills"), platform: "codex" },
|
|
@@ -24,19 +62,43 @@ const CLI_TARGETS = [
|
|
|
24
62
|
console.log("wicked-bus installer\n");
|
|
25
63
|
|
|
26
64
|
const args = argv.slice(2);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
65
|
+
|
|
66
|
+
// Flag parser supporting both --flag=value and --flag value forms, plus
|
|
67
|
+
// narrow string-boolean coercion ("true" / "false" → booleans). Previously
|
|
68
|
+
// the ad-hoc parser silently dropped space-separated values — same bug
|
|
69
|
+
// that hit wicked-testing 0.3.2 / wicked-brain 0.3.7.
|
|
70
|
+
const flagValue = (name) => {
|
|
71
|
+
const f = args.find(a => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
72
|
+
if (!f) return null;
|
|
73
|
+
let val;
|
|
74
|
+
if (f.includes("=")) {
|
|
75
|
+
// slice from the first '=' forward — split("=")[1] would truncate at
|
|
76
|
+
// the second '=' (e.g. --path=/volumes/build=artifacts).
|
|
77
|
+
val = f.slice(f.indexOf("=") + 1);
|
|
78
|
+
} else {
|
|
79
|
+
const idx = args.indexOf(f);
|
|
80
|
+
const next = args[idx + 1];
|
|
81
|
+
val = (next && !next.startsWith("-")) ? next : true;
|
|
82
|
+
}
|
|
83
|
+
if (val === "false") return false;
|
|
84
|
+
if (val === "true") return true;
|
|
85
|
+
return val;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const cliArg = flagValue("cli");
|
|
89
|
+
const pathArg = flagValue("path");
|
|
90
|
+
|
|
91
|
+
// Validate --cli upfront so a mistyped --cli / --cli= fails fast
|
|
92
|
+
// instead of silently falling through to "all detected".
|
|
93
|
+
if (cliArg === true || cliArg === "") {
|
|
94
|
+
console.error("Error: --cli requires a value (e.g. --cli=claude or --cli claude)");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
30
97
|
|
|
31
98
|
let targets;
|
|
32
99
|
|
|
33
|
-
if (pathArg) {
|
|
34
|
-
const
|
|
35
|
-
if (!rawPath) {
|
|
36
|
-
console.error("Error: --path requires a value (e.g. --path=~/.claude)");
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
const customPath = resolve(rawPath.replace(/^~/, home));
|
|
100
|
+
if (pathArg && typeof pathArg === "string" && pathArg !== "") {
|
|
101
|
+
const customPath = resolve(pathArg.replace(/^~/, () => home));
|
|
40
102
|
const dirName = basename(customPath).replace(/^\./, "");
|
|
41
103
|
targets = [{
|
|
42
104
|
name: dirName,
|
|
@@ -44,18 +106,29 @@ if (pathArg) {
|
|
|
44
106
|
platform: dirName,
|
|
45
107
|
}];
|
|
46
108
|
console.log(`Custom path: ${customPath}\n`);
|
|
109
|
+
} else if (pathArg === true || pathArg === "") {
|
|
110
|
+
console.error("Error: --path requires a value (e.g. --path=~/.claude or --path ~/.claude)");
|
|
111
|
+
process.exit(1);
|
|
47
112
|
} else {
|
|
48
|
-
|
|
113
|
+
// Expanded detection: claude candidates (env var OR alt-config probes,
|
|
114
|
+
// identity-marker gated) + non-claude parent-dir-exists heuristic.
|
|
115
|
+
const claudeDetected = resolveClaudeCandidates().filter(claudeHasIdentityMarker);
|
|
116
|
+
const otherDetected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
|
|
117
|
+
const detected = [...claudeDetected, ...otherDetected];
|
|
49
118
|
|
|
50
119
|
if (detected.length === 0) {
|
|
51
120
|
console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
|
|
52
|
-
console.log("Install skills manually by copying the skills/ directory.");
|
|
121
|
+
console.log("Install skills manually by copying the skills/ directory, or set CLAUDE_CONFIG_DIR.");
|
|
53
122
|
process.exit(1);
|
|
54
123
|
}
|
|
55
124
|
|
|
56
|
-
|
|
125
|
+
const claudeCount = claudeDetected.length;
|
|
126
|
+
const label = (d) => d.name === "claude" && claudeCount > 1 && d.source
|
|
127
|
+
? `${d.name}[${d.source}]`
|
|
128
|
+
: d.name;
|
|
129
|
+
console.log(`Detected CLIs: ${detected.map(label).join(", ")}\n`);
|
|
57
130
|
|
|
58
|
-
const cliFilter = cliArg ?
|
|
131
|
+
const cliFilter = (typeof cliArg === "string" && cliArg !== "") ? cliArg.split(",") : null;
|
|
59
132
|
targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
|
|
60
133
|
}
|
|
61
134
|
|