@hua-labs/tap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -0
- package/bin/tap-comms.mjs +2 -0
- package/dist/bridges/codex-bridge-runner.d.mts +2 -0
- package/dist/bridges/codex-bridge-runner.mjs +1009 -0
- package/dist/bridges/codex-bridge-runner.mjs.map +1 -0
- package/dist/cli.d.mts +2 -0
- package/dist/cli.mjs +4166 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +272 -0
- package/dist/index.mjs +439 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,4166 @@
|
|
|
1
|
+
// src/commands/init.ts
|
|
2
|
+
import * as fs5 from "fs";
|
|
3
|
+
import * as path5 from "path";
|
|
4
|
+
|
|
5
|
+
// src/state.ts
|
|
6
|
+
import * as fs2 from "fs";
|
|
7
|
+
import * as path2 from "path";
|
|
8
|
+
import * as crypto from "crypto";
|
|
9
|
+
|
|
10
|
+
// src/config/resolve.ts
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
var SHARED_CONFIG_FILE = "tap-config.json";
|
|
14
|
+
var LOCAL_CONFIG_FILE = "tap-config.local.json";
|
|
15
|
+
var DEFAULT_RUNTIME_COMMAND = "node";
|
|
16
|
+
var DEFAULT_APP_SERVER_URL = "ws://127.0.0.1:4501";
|
|
17
|
+
function findRepoRoot(startDir = process.cwd()) {
|
|
18
|
+
let dir = path.resolve(startDir);
|
|
19
|
+
while (true) {
|
|
20
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
21
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir) break;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
return process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function loadJsonFile(filePath) {
|
|
29
|
+
if (!fs.existsSync(filePath)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function loadSharedConfig(repoRoot) {
|
|
38
|
+
return loadJsonFile(path.join(repoRoot, SHARED_CONFIG_FILE));
|
|
39
|
+
}
|
|
40
|
+
function loadLocalConfig(repoRoot) {
|
|
41
|
+
return loadJsonFile(path.join(repoRoot, LOCAL_CONFIG_FILE));
|
|
42
|
+
}
|
|
43
|
+
function resolveConfig(overrides = {}, startDir) {
|
|
44
|
+
const repoRoot = findRepoRoot(startDir);
|
|
45
|
+
const shared = loadSharedConfig(repoRoot) ?? {};
|
|
46
|
+
const local = loadLocalConfig(repoRoot) ?? {};
|
|
47
|
+
const sources = {
|
|
48
|
+
repoRoot: "auto",
|
|
49
|
+
commsDir: "auto",
|
|
50
|
+
stateDir: "auto",
|
|
51
|
+
runtimeCommand: "auto",
|
|
52
|
+
appServerUrl: "auto"
|
|
53
|
+
};
|
|
54
|
+
let commsDir;
|
|
55
|
+
if (overrides.commsDir) {
|
|
56
|
+
commsDir = path.resolve(overrides.commsDir);
|
|
57
|
+
sources.commsDir = "cli-flag";
|
|
58
|
+
} else if (process.env.TAP_COMMS_DIR) {
|
|
59
|
+
commsDir = path.resolve(process.env.TAP_COMMS_DIR);
|
|
60
|
+
sources.commsDir = "env";
|
|
61
|
+
} else if (local.commsDir) {
|
|
62
|
+
commsDir = resolvePath(repoRoot, local.commsDir);
|
|
63
|
+
sources.commsDir = "local-config";
|
|
64
|
+
} else if (shared.commsDir) {
|
|
65
|
+
commsDir = resolvePath(repoRoot, shared.commsDir);
|
|
66
|
+
sources.commsDir = "shared-config";
|
|
67
|
+
} else {
|
|
68
|
+
commsDir = path.join(path.dirname(repoRoot), "tap-comms");
|
|
69
|
+
}
|
|
70
|
+
let stateDir;
|
|
71
|
+
if (overrides.stateDir) {
|
|
72
|
+
stateDir = path.resolve(overrides.stateDir);
|
|
73
|
+
sources.stateDir = "cli-flag";
|
|
74
|
+
} else if (process.env.TAP_STATE_DIR) {
|
|
75
|
+
stateDir = path.resolve(process.env.TAP_STATE_DIR);
|
|
76
|
+
sources.stateDir = "env";
|
|
77
|
+
} else if (local.stateDir) {
|
|
78
|
+
stateDir = resolvePath(repoRoot, local.stateDir);
|
|
79
|
+
sources.stateDir = "local-config";
|
|
80
|
+
} else if (shared.stateDir) {
|
|
81
|
+
stateDir = resolvePath(repoRoot, shared.stateDir);
|
|
82
|
+
sources.stateDir = "shared-config";
|
|
83
|
+
} else {
|
|
84
|
+
stateDir = path.join(repoRoot, ".tap-comms");
|
|
85
|
+
}
|
|
86
|
+
let runtimeCommand;
|
|
87
|
+
if (overrides.runtimeCommand) {
|
|
88
|
+
runtimeCommand = overrides.runtimeCommand;
|
|
89
|
+
sources.runtimeCommand = "cli-flag";
|
|
90
|
+
} else if (process.env.TAP_RUNTIME_COMMAND) {
|
|
91
|
+
runtimeCommand = process.env.TAP_RUNTIME_COMMAND;
|
|
92
|
+
sources.runtimeCommand = "env";
|
|
93
|
+
} else if (local.runtimeCommand) {
|
|
94
|
+
runtimeCommand = local.runtimeCommand;
|
|
95
|
+
sources.runtimeCommand = "local-config";
|
|
96
|
+
} else if (shared.runtimeCommand) {
|
|
97
|
+
runtimeCommand = shared.runtimeCommand;
|
|
98
|
+
sources.runtimeCommand = "shared-config";
|
|
99
|
+
} else {
|
|
100
|
+
runtimeCommand = DEFAULT_RUNTIME_COMMAND;
|
|
101
|
+
}
|
|
102
|
+
let appServerUrl;
|
|
103
|
+
if (overrides.appServerUrl) {
|
|
104
|
+
appServerUrl = overrides.appServerUrl;
|
|
105
|
+
sources.appServerUrl = "cli-flag";
|
|
106
|
+
} else if (process.env.TAP_APP_SERVER_URL) {
|
|
107
|
+
appServerUrl = process.env.TAP_APP_SERVER_URL;
|
|
108
|
+
sources.appServerUrl = "env";
|
|
109
|
+
} else if (local.appServerUrl) {
|
|
110
|
+
appServerUrl = local.appServerUrl;
|
|
111
|
+
sources.appServerUrl = "local-config";
|
|
112
|
+
} else if (shared.appServerUrl) {
|
|
113
|
+
appServerUrl = shared.appServerUrl;
|
|
114
|
+
sources.appServerUrl = "shared-config";
|
|
115
|
+
} else {
|
|
116
|
+
appServerUrl = DEFAULT_APP_SERVER_URL;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
config: { repoRoot, commsDir, stateDir, runtimeCommand, appServerUrl },
|
|
120
|
+
sources
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function resolvePath(repoRoot, p) {
|
|
124
|
+
return path.isAbsolute(p) ? p : path.resolve(repoRoot, p);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/state.ts
|
|
128
|
+
var STATE_FILE = "state.json";
|
|
129
|
+
var SCHEMA_VERSION = 2;
|
|
130
|
+
function getStateDir(repoRoot) {
|
|
131
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
132
|
+
return config.stateDir;
|
|
133
|
+
}
|
|
134
|
+
function getStatePath(repoRoot) {
|
|
135
|
+
return path2.join(getStateDir(repoRoot), STATE_FILE);
|
|
136
|
+
}
|
|
137
|
+
function stateExists(repoRoot) {
|
|
138
|
+
return fs2.existsSync(getStatePath(repoRoot));
|
|
139
|
+
}
|
|
140
|
+
function migrateStateV1toV2(v1) {
|
|
141
|
+
const instances = {};
|
|
142
|
+
for (const [runtime, rs] of Object.entries(v1.runtimes)) {
|
|
143
|
+
if (!rs) continue;
|
|
144
|
+
const instanceId = runtime;
|
|
145
|
+
instances[instanceId] = {
|
|
146
|
+
instanceId,
|
|
147
|
+
runtime,
|
|
148
|
+
agentName: null,
|
|
149
|
+
port: null,
|
|
150
|
+
headless: null,
|
|
151
|
+
...rs
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: SCHEMA_VERSION,
|
|
156
|
+
createdAt: v1.createdAt,
|
|
157
|
+
updatedAt: v1.updatedAt,
|
|
158
|
+
commsDir: v1.commsDir,
|
|
159
|
+
repoRoot: v1.repoRoot,
|
|
160
|
+
packageVersion: v1.packageVersion,
|
|
161
|
+
instances
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function loadState(repoRoot) {
|
|
165
|
+
const statePath = getStatePath(repoRoot);
|
|
166
|
+
if (!fs2.existsSync(statePath)) return null;
|
|
167
|
+
const raw = fs2.readFileSync(statePath, "utf-8");
|
|
168
|
+
const parsed = JSON.parse(raw);
|
|
169
|
+
if (parsed.schemaVersion === 1 || parsed.runtimes) {
|
|
170
|
+
const migrated = migrateStateV1toV2(parsed);
|
|
171
|
+
saveState(repoRoot, migrated);
|
|
172
|
+
return migrated;
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
function saveState(repoRoot, state) {
|
|
177
|
+
const stateDir = getStateDir(repoRoot);
|
|
178
|
+
fs2.mkdirSync(stateDir, { recursive: true });
|
|
179
|
+
const statePath = getStatePath(repoRoot);
|
|
180
|
+
const tmp = `${statePath}.tmp.${process.pid}`;
|
|
181
|
+
fs2.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
182
|
+
fs2.renameSync(tmp, statePath);
|
|
183
|
+
}
|
|
184
|
+
function createInitialState(commsDir, repoRoot, packageVersion) {
|
|
185
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: SCHEMA_VERSION,
|
|
188
|
+
createdAt: now,
|
|
189
|
+
updatedAt: now,
|
|
190
|
+
commsDir: path2.resolve(commsDir),
|
|
191
|
+
repoRoot: path2.resolve(repoRoot),
|
|
192
|
+
packageVersion,
|
|
193
|
+
instances: {}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function updateInstanceState(state, instanceId, instanceState) {
|
|
197
|
+
return {
|
|
198
|
+
...state,
|
|
199
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
200
|
+
instances: {
|
|
201
|
+
...state.instances,
|
|
202
|
+
[instanceId]: instanceState
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function removeInstanceState(state, instanceId) {
|
|
207
|
+
const { [instanceId]: _removed, ...remaining } = state.instances;
|
|
208
|
+
return {
|
|
209
|
+
...state,
|
|
210
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
211
|
+
instances: remaining
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function getInstalledInstances(state) {
|
|
215
|
+
return Object.keys(state.instances).filter(
|
|
216
|
+
(id) => state.instances[id]?.installed
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
function ensureBackupDir(stateDir, instanceId) {
|
|
220
|
+
const backupDir = path2.join(stateDir, "backups", instanceId);
|
|
221
|
+
fs2.mkdirSync(backupDir, { recursive: true });
|
|
222
|
+
return backupDir;
|
|
223
|
+
}
|
|
224
|
+
function backupFile(filePath, backupDir) {
|
|
225
|
+
const basename3 = path2.basename(filePath);
|
|
226
|
+
const hash = fileHash(filePath);
|
|
227
|
+
const backupPath = path2.join(backupDir, `${basename3}.${hash}.bak`);
|
|
228
|
+
fs2.copyFileSync(filePath, backupPath);
|
|
229
|
+
return backupPath;
|
|
230
|
+
}
|
|
231
|
+
function fileHash(filePath) {
|
|
232
|
+
if (!fs2.existsSync(filePath)) return "";
|
|
233
|
+
const content = fs2.readFileSync(filePath);
|
|
234
|
+
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/utils.ts
|
|
238
|
+
import * as fs3 from "fs";
|
|
239
|
+
import * as path3 from "path";
|
|
240
|
+
var VALID_RUNTIMES = ["claude", "codex", "gemini"];
|
|
241
|
+
function isValidRuntime(name) {
|
|
242
|
+
return VALID_RUNTIMES.includes(name);
|
|
243
|
+
}
|
|
244
|
+
function detectPlatform() {
|
|
245
|
+
return process.platform;
|
|
246
|
+
}
|
|
247
|
+
function findRepoRoot2(startDir = process.cwd()) {
|
|
248
|
+
let dir = path3.resolve(startDir);
|
|
249
|
+
while (true) {
|
|
250
|
+
if (fs3.existsSync(path3.join(dir, ".git"))) return dir;
|
|
251
|
+
if (fs3.existsSync(path3.join(dir, "package.json"))) return dir;
|
|
252
|
+
const parent = path3.dirname(dir);
|
|
253
|
+
if (parent === dir) break;
|
|
254
|
+
dir = parent;
|
|
255
|
+
}
|
|
256
|
+
return process.cwd();
|
|
257
|
+
}
|
|
258
|
+
function resolveCommsDir(args, repoRoot) {
|
|
259
|
+
const idx = args.indexOf("--comms-dir");
|
|
260
|
+
if (idx !== -1 && args[idx + 1]) {
|
|
261
|
+
return path3.resolve(args[idx + 1]);
|
|
262
|
+
}
|
|
263
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
264
|
+
return config.commsDir;
|
|
265
|
+
}
|
|
266
|
+
function createAdapterContext(commsDir, repoRoot) {
|
|
267
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
268
|
+
return {
|
|
269
|
+
commsDir: path3.resolve(commsDir),
|
|
270
|
+
repoRoot: path3.resolve(repoRoot),
|
|
271
|
+
stateDir: config.stateDir,
|
|
272
|
+
platform: detectPlatform()
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function parseArgs(args) {
|
|
276
|
+
const positional = [];
|
|
277
|
+
const flags = {};
|
|
278
|
+
for (let i = 0; i < args.length; i++) {
|
|
279
|
+
const arg = args[i];
|
|
280
|
+
if (arg.startsWith("--")) {
|
|
281
|
+
const key = arg.slice(2);
|
|
282
|
+
const next = args[i + 1];
|
|
283
|
+
if (next && !next.startsWith("--")) {
|
|
284
|
+
flags[key] = next;
|
|
285
|
+
i++;
|
|
286
|
+
} else {
|
|
287
|
+
flags[key] = true;
|
|
288
|
+
}
|
|
289
|
+
} else if (arg.startsWith("-")) {
|
|
290
|
+
flags[arg.slice(1)] = true;
|
|
291
|
+
} else {
|
|
292
|
+
positional.push(arg);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { positional, flags };
|
|
296
|
+
}
|
|
297
|
+
var _jsonMode = false;
|
|
298
|
+
function setJsonMode(enabled) {
|
|
299
|
+
_jsonMode = enabled;
|
|
300
|
+
}
|
|
301
|
+
function log(message) {
|
|
302
|
+
if (!_jsonMode) console.log(` ${message}`);
|
|
303
|
+
}
|
|
304
|
+
function logSuccess(message) {
|
|
305
|
+
if (!_jsonMode) console.log(` + ${message}`);
|
|
306
|
+
}
|
|
307
|
+
function logWarn(message) {
|
|
308
|
+
if (!_jsonMode) console.log(` ! ${message}`);
|
|
309
|
+
}
|
|
310
|
+
function logError(message) {
|
|
311
|
+
if (!_jsonMode) console.error(` x ${message}`);
|
|
312
|
+
}
|
|
313
|
+
function logHeader(message) {
|
|
314
|
+
if (!_jsonMode) console.log(`
|
|
315
|
+
${message}
|
|
316
|
+
`);
|
|
317
|
+
}
|
|
318
|
+
function resolveInstanceId(identifier, state) {
|
|
319
|
+
if (state.instances[identifier]) {
|
|
320
|
+
return { ok: true, instanceId: identifier };
|
|
321
|
+
}
|
|
322
|
+
if (isValidRuntime(identifier)) {
|
|
323
|
+
const matches = Object.values(state.instances).filter(
|
|
324
|
+
(inst) => inst.runtime === identifier
|
|
325
|
+
);
|
|
326
|
+
if (matches.length === 1) {
|
|
327
|
+
return { ok: true, instanceId: matches[0].instanceId };
|
|
328
|
+
}
|
|
329
|
+
if (matches.length > 1) {
|
|
330
|
+
const ids = matches.map((m) => m.instanceId).join(", ");
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
code: "TAP_INSTANCE_AMBIGUOUS",
|
|
334
|
+
message: `Multiple ${identifier} instances found: ${ids}. Specify one explicitly.`
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
code: "TAP_INSTANCE_NOT_FOUND",
|
|
341
|
+
message: `Instance not found: ${identifier}`
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function buildInstanceId(runtime, name) {
|
|
345
|
+
return name ? `${runtime}-${name}` : runtime;
|
|
346
|
+
}
|
|
347
|
+
function findPortConflict(state, port, excludeInstanceId) {
|
|
348
|
+
for (const [id, inst] of Object.entries(state.instances)) {
|
|
349
|
+
if (id !== excludeInstanceId && inst.port === port) return id;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/version.ts
|
|
355
|
+
var version = "0.1.0";
|
|
356
|
+
|
|
357
|
+
// src/permissions.ts
|
|
358
|
+
import * as fs4 from "fs";
|
|
359
|
+
import * as path4 from "path";
|
|
360
|
+
import * as os from "os";
|
|
361
|
+
|
|
362
|
+
// src/toml.ts
|
|
363
|
+
function splitLines(content) {
|
|
364
|
+
return content.replace(/\r\n/g, "\n").split("\n");
|
|
365
|
+
}
|
|
366
|
+
function tableHeader(selector) {
|
|
367
|
+
return `[${selector}]`;
|
|
368
|
+
}
|
|
369
|
+
function findTableRange(lines, selector) {
|
|
370
|
+
const header = tableHeader(selector);
|
|
371
|
+
for (let i = 0; i < lines.length; i++) {
|
|
372
|
+
if (lines[i].trim() !== header) continue;
|
|
373
|
+
let end = lines.length;
|
|
374
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
375
|
+
const trimmed = lines[j].trim();
|
|
376
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
377
|
+
end = j;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { start: i, end };
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
function escapeBasicString(value) {
|
|
386
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
387
|
+
}
|
|
388
|
+
function renderValue(value) {
|
|
389
|
+
if (Array.isArray(value)) {
|
|
390
|
+
return `[${value.map((item) => `"${escapeBasicString(item)}"`).join(", ")}]`;
|
|
391
|
+
}
|
|
392
|
+
return `"${escapeBasicString(value)}"`;
|
|
393
|
+
}
|
|
394
|
+
function extractTomlTable(content, selector) {
|
|
395
|
+
const lines = splitLines(content);
|
|
396
|
+
const range = findTableRange(lines, selector);
|
|
397
|
+
if (!range) return null;
|
|
398
|
+
return `${lines.slice(range.start, range.end).join("\n")}
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
function removeTomlTable(content, selector) {
|
|
402
|
+
const lines = splitLines(content);
|
|
403
|
+
const range = findTableRange(lines, selector);
|
|
404
|
+
if (!range) return content;
|
|
405
|
+
const next = [...lines.slice(0, range.start), ...lines.slice(range.end)];
|
|
406
|
+
return `${trimTomlDocument(next.join("\n"))}
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
function replaceTomlTable(content, selector, replacement) {
|
|
410
|
+
const lines = splitLines(content);
|
|
411
|
+
const range = findTableRange(lines, selector);
|
|
412
|
+
const replacementLines = replacement.replace(/\r\n/g, "\n").trimEnd().split("\n");
|
|
413
|
+
if (!range) {
|
|
414
|
+
const doc = trimTomlDocument(content);
|
|
415
|
+
if (!doc) return `${replacement.trimEnd()}
|
|
416
|
+
`;
|
|
417
|
+
return `${doc}
|
|
418
|
+
|
|
419
|
+
${replacement.trimEnd()}
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
const next = [
|
|
423
|
+
...lines.slice(0, range.start),
|
|
424
|
+
...replacementLines,
|
|
425
|
+
...lines.slice(range.end)
|
|
426
|
+
];
|
|
427
|
+
return `${trimTomlDocument(next.join("\n"))}
|
|
428
|
+
`;
|
|
429
|
+
}
|
|
430
|
+
function renderTomlTable(selector, entries, existingContent) {
|
|
431
|
+
const preserved = parseTomlAssignments(existingContent ?? "");
|
|
432
|
+
const merged = { ...preserved, ...entries };
|
|
433
|
+
const lines = [tableHeader(selector)];
|
|
434
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
435
|
+
lines.push(`${key} = ${renderValue(value)}`);
|
|
436
|
+
}
|
|
437
|
+
return `${lines.join("\n")}
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
function parseTomlAssignments(tableContent) {
|
|
441
|
+
const lines = splitLines(tableContent);
|
|
442
|
+
const values = {};
|
|
443
|
+
for (const rawLine of lines) {
|
|
444
|
+
const line = rawLine.trim();
|
|
445
|
+
if (!line || line.startsWith("#") || line.startsWith("[") && line.endsWith("]")) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
|
|
449
|
+
if (!match) continue;
|
|
450
|
+
const [, key, rawValue] = match;
|
|
451
|
+
const value = rawValue.trim();
|
|
452
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
453
|
+
const items = value.slice(1, -1).split(",").map((item) => item.trim()).filter(Boolean).map(unquoteTomlString);
|
|
454
|
+
values[key] = items;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
values[key] = unquoteTomlString(value);
|
|
458
|
+
}
|
|
459
|
+
return values;
|
|
460
|
+
}
|
|
461
|
+
function trimTomlDocument(content) {
|
|
462
|
+
return content.replace(/\s+$/g, "").replace(/\n{3,}/g, "\n\n");
|
|
463
|
+
}
|
|
464
|
+
function unquoteTomlString(value) {
|
|
465
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
466
|
+
const inner = value.slice(1, -1);
|
|
467
|
+
return value.startsWith('"') ? inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\") : inner;
|
|
468
|
+
}
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/permissions.ts
|
|
473
|
+
var CLAUDE_DENY_RULES = [
|
|
474
|
+
"Bash(git push --force:*)",
|
|
475
|
+
"Bash(git push -f:*)",
|
|
476
|
+
"Bash(git push --force-with-lease:*)",
|
|
477
|
+
"Bash(git reset --hard:*)",
|
|
478
|
+
"Bash(git checkout -- .:*)",
|
|
479
|
+
"Bash(git clean -f:*)",
|
|
480
|
+
"Bash(git clean -fd:*)",
|
|
481
|
+
"Bash(git clean -fdx:*)",
|
|
482
|
+
"Bash(git restore --source=:*)",
|
|
483
|
+
"Bash(git branch -D:*)",
|
|
484
|
+
"Bash(git stash drop:*)",
|
|
485
|
+
"Bash(rm -rf:*)"
|
|
486
|
+
];
|
|
487
|
+
function applyClaudePermissions(repoRoot, mode) {
|
|
488
|
+
const warnings = [];
|
|
489
|
+
const claudeDir = path4.join(repoRoot, ".claude");
|
|
490
|
+
const settingsPath = path4.join(claudeDir, "settings.local.json");
|
|
491
|
+
fs4.mkdirSync(claudeDir, { recursive: true });
|
|
492
|
+
let settings = {};
|
|
493
|
+
if (fs4.existsSync(settingsPath)) {
|
|
494
|
+
try {
|
|
495
|
+
settings = JSON.parse(fs4.readFileSync(settingsPath, "utf-8"));
|
|
496
|
+
} catch {
|
|
497
|
+
warnings.push(
|
|
498
|
+
".claude/settings.local.json was invalid JSON. Starting fresh."
|
|
499
|
+
);
|
|
500
|
+
settings = {};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const existingDeny = Array.isArray(settings.deny) ? settings.deny : [];
|
|
504
|
+
if (mode === "full") {
|
|
505
|
+
const tapRuleSet = new Set(CLAUDE_DENY_RULES);
|
|
506
|
+
const cleaned = existingDeny.filter((r) => !tapRuleSet.has(r));
|
|
507
|
+
settings.deny = cleaned;
|
|
508
|
+
const tmp2 = `${settingsPath}.tmp.${process.pid}`;
|
|
509
|
+
fs4.writeFileSync(tmp2, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
510
|
+
fs4.renameSync(tmp2, settingsPath);
|
|
511
|
+
logWarn("Claude: full mode \u2014 tap deny rules removed. Use with caution.");
|
|
512
|
+
warnings.push("Full permission mode: tap deny rules removed.");
|
|
513
|
+
return { applied: true, warnings };
|
|
514
|
+
}
|
|
515
|
+
const newDeny = [.../* @__PURE__ */ new Set([...existingDeny, ...CLAUDE_DENY_RULES])];
|
|
516
|
+
settings.deny = newDeny;
|
|
517
|
+
const tmp = `${settingsPath}.tmp.${process.pid}`;
|
|
518
|
+
fs4.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
519
|
+
fs4.renameSync(tmp, settingsPath);
|
|
520
|
+
logSuccess(
|
|
521
|
+
`Claude: ${CLAUDE_DENY_RULES.length} deny rules applied to .claude/settings.local.json`
|
|
522
|
+
);
|
|
523
|
+
return { applied: true, warnings };
|
|
524
|
+
}
|
|
525
|
+
function findCodexConfigPath() {
|
|
526
|
+
return path4.join(os.homedir(), ".codex", "config.toml");
|
|
527
|
+
}
|
|
528
|
+
function canonicalizeTrustPath(targetPath) {
|
|
529
|
+
let resolved = path4.resolve(targetPath).replace(/\//g, "\\");
|
|
530
|
+
const driveRoot = /^[A-Za-z]:\\$/;
|
|
531
|
+
if (!driveRoot.test(resolved)) {
|
|
532
|
+
resolved = resolved.replace(/\\+$/g, "");
|
|
533
|
+
}
|
|
534
|
+
return resolved.startsWith("\\\\?\\") ? resolved : `\\\\?\\${resolved}`;
|
|
535
|
+
}
|
|
536
|
+
function applyCodexPermissions(repoRoot, commsDir, mode) {
|
|
537
|
+
const warnings = [];
|
|
538
|
+
const configPath = findCodexConfigPath();
|
|
539
|
+
fs4.mkdirSync(path4.dirname(configPath), { recursive: true });
|
|
540
|
+
let content = "";
|
|
541
|
+
if (fs4.existsSync(configPath)) {
|
|
542
|
+
content = fs4.readFileSync(configPath, "utf-8");
|
|
543
|
+
}
|
|
544
|
+
const trustTargets = getCodexWritableRoots(repoRoot, commsDir);
|
|
545
|
+
if (mode === "full") {
|
|
546
|
+
logWarn("Codex: full mode \u2014 setting sandbox to danger-full-access.");
|
|
547
|
+
warnings.push(
|
|
548
|
+
"Full mode: sandbox set to danger-full-access. Use with caution."
|
|
549
|
+
);
|
|
550
|
+
content = replaceTomlTable(
|
|
551
|
+
content,
|
|
552
|
+
"sandbox",
|
|
553
|
+
renderTomlTable(
|
|
554
|
+
"sandbox",
|
|
555
|
+
{ mode: "danger-full-access" },
|
|
556
|
+
extractTomlTable(content, "sandbox")
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
} else {
|
|
560
|
+
content = replaceTomlTable(
|
|
561
|
+
content,
|
|
562
|
+
"sandbox",
|
|
563
|
+
renderTomlTable(
|
|
564
|
+
"sandbox",
|
|
565
|
+
{ mode: "workspace-write", network_access: "full" },
|
|
566
|
+
extractTomlTable(content, "sandbox")
|
|
567
|
+
)
|
|
568
|
+
);
|
|
569
|
+
const forwardSlashRoots = trustTargets.map((r) => r.replace(/\\/g, "/"));
|
|
570
|
+
content = replaceTomlTable(
|
|
571
|
+
content,
|
|
572
|
+
"sandbox_workspace_write",
|
|
573
|
+
renderTomlTable(
|
|
574
|
+
"sandbox_workspace_write",
|
|
575
|
+
{ writable_roots: forwardSlashRoots },
|
|
576
|
+
extractTomlTable(content, "sandbox_workspace_write")
|
|
577
|
+
)
|
|
578
|
+
);
|
|
579
|
+
if (process.platform === "win32") {
|
|
580
|
+
content = replaceTomlTable(
|
|
581
|
+
content,
|
|
582
|
+
"windows",
|
|
583
|
+
renderTomlTable(
|
|
584
|
+
"windows",
|
|
585
|
+
{ sandbox: "elevated" },
|
|
586
|
+
extractTomlTable(content, "windows")
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const target of trustTargets) {
|
|
592
|
+
const selector = `projects.'${canonicalizeTrustPath(target)}'`;
|
|
593
|
+
content = replaceTomlTable(
|
|
594
|
+
content,
|
|
595
|
+
selector,
|
|
596
|
+
renderTomlTable(
|
|
597
|
+
selector,
|
|
598
|
+
{ trust_level: "trusted" },
|
|
599
|
+
extractTomlTable(content, selector)
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const tmp = `${configPath}.tmp.${process.pid}`;
|
|
604
|
+
fs4.writeFileSync(tmp, content, "utf-8");
|
|
605
|
+
fs4.renameSync(tmp, configPath);
|
|
606
|
+
const modeLabel = mode === "full" ? "danger-full-access" : "workspace-write, network=full";
|
|
607
|
+
logSuccess(
|
|
608
|
+
`Codex: sandbox=${modeLabel}, ${trustTargets.length} path(s) trusted`
|
|
609
|
+
);
|
|
610
|
+
return { applied: true, warnings };
|
|
611
|
+
}
|
|
612
|
+
function getCodexWritableRoots(repoRoot, commsDir) {
|
|
613
|
+
const roots = [repoRoot, commsDir];
|
|
614
|
+
const parent = path4.dirname(repoRoot);
|
|
615
|
+
for (let i = 1; i <= 4; i++) {
|
|
616
|
+
const wtPath = path4.join(parent, `hua-wt-${i}`);
|
|
617
|
+
if (fs4.existsSync(wtPath)) roots.push(wtPath);
|
|
618
|
+
}
|
|
619
|
+
return [...new Set(roots.map((r) => path4.resolve(r)))];
|
|
620
|
+
}
|
|
621
|
+
function buildPermissionSummary(mode, repoRoot, commsDir) {
|
|
622
|
+
const trustedPaths = getCodexWritableRoots(repoRoot, commsDir);
|
|
623
|
+
return {
|
|
624
|
+
mode,
|
|
625
|
+
claude: {
|
|
626
|
+
applied: true,
|
|
627
|
+
denyCount: mode === "safe" ? CLAUDE_DENY_RULES.length : 0,
|
|
628
|
+
warnings: mode === "full" ? ["Full mode: tap deny rules removed."] : []
|
|
629
|
+
},
|
|
630
|
+
codex: {
|
|
631
|
+
applied: true,
|
|
632
|
+
trustedPaths,
|
|
633
|
+
warnings: mode === "full" ? ["Full mode: sandbox set to danger-full-access."] : []
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/commands/init.ts
|
|
639
|
+
var COMMS_DIRS = [
|
|
640
|
+
"inbox",
|
|
641
|
+
"reviews",
|
|
642
|
+
"findings",
|
|
643
|
+
"handoff",
|
|
644
|
+
"retros",
|
|
645
|
+
"archive"
|
|
646
|
+
];
|
|
647
|
+
function parsePermissionMode(args) {
|
|
648
|
+
const idx = args.indexOf("--permissions");
|
|
649
|
+
if (idx !== -1 && args[idx + 1]) {
|
|
650
|
+
const value = args[idx + 1];
|
|
651
|
+
if (value === "full" || value === "safe") return value;
|
|
652
|
+
logWarn(`Unknown permission mode: ${value}. Using "safe".`);
|
|
653
|
+
}
|
|
654
|
+
return "safe";
|
|
655
|
+
}
|
|
656
|
+
async function initCommand(args) {
|
|
657
|
+
const repoRoot = findRepoRoot2();
|
|
658
|
+
const commsDir = resolveCommsDir(args, repoRoot);
|
|
659
|
+
const permMode = parsePermissionMode(args);
|
|
660
|
+
if (stateExists(repoRoot) && !args.includes("--force")) {
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
command: "init",
|
|
664
|
+
code: "TAP_ALREADY_INITIALIZED",
|
|
665
|
+
message: "Already initialized. Use --force to re-initialize.",
|
|
666
|
+
warnings: [],
|
|
667
|
+
data: { commsDir, repoRoot }
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
logHeader("@hua-labs/tap init");
|
|
671
|
+
log(`Comms directory: ${commsDir}`);
|
|
672
|
+
for (const dir of COMMS_DIRS) {
|
|
673
|
+
const dirPath = path5.join(commsDir, dir);
|
|
674
|
+
fs5.mkdirSync(dirPath, { recursive: true });
|
|
675
|
+
logSuccess(`Created ${dir}/`);
|
|
676
|
+
}
|
|
677
|
+
const gitignorePath = path5.join(commsDir, ".gitignore");
|
|
678
|
+
if (!fs5.existsSync(gitignorePath)) {
|
|
679
|
+
fs5.writeFileSync(
|
|
680
|
+
gitignorePath,
|
|
681
|
+
["tap.db", ".lock", "*.tmp.*", ".DS_Store"].join("\n") + "\n",
|
|
682
|
+
"utf-8"
|
|
683
|
+
);
|
|
684
|
+
logSuccess("Created .gitignore");
|
|
685
|
+
}
|
|
686
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
687
|
+
const stateDir = config.stateDir;
|
|
688
|
+
fs5.mkdirSync(path5.join(stateDir, "pids"), { recursive: true });
|
|
689
|
+
fs5.mkdirSync(path5.join(stateDir, "logs"), { recursive: true });
|
|
690
|
+
fs5.mkdirSync(path5.join(stateDir, "backups"), { recursive: true });
|
|
691
|
+
const stateDirRel = path5.relative(repoRoot, stateDir);
|
|
692
|
+
logSuccess(`Created ${stateDirRel}/ state directory`);
|
|
693
|
+
const repoGitignore = path5.join(repoRoot, ".gitignore");
|
|
694
|
+
const gitignoreEntries = [
|
|
695
|
+
{ entry: stateDirRel.replace(/\\/g, "/") + "/", label: "tap-comms state" },
|
|
696
|
+
{
|
|
697
|
+
entry: "tap-config.local.json",
|
|
698
|
+
label: "tap-comms local config (machine-specific)"
|
|
699
|
+
}
|
|
700
|
+
];
|
|
701
|
+
if (fs5.existsSync(repoGitignore)) {
|
|
702
|
+
const content = fs5.readFileSync(repoGitignore, "utf-8");
|
|
703
|
+
for (const { entry, label } of gitignoreEntries) {
|
|
704
|
+
if (!content.includes(entry)) {
|
|
705
|
+
fs5.appendFileSync(repoGitignore, `
|
|
706
|
+
# ${label}
|
|
707
|
+
${entry}
|
|
708
|
+
`);
|
|
709
|
+
logSuccess(`Added ${entry} to .gitignore`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const state = createInitialState(commsDir, repoRoot, version);
|
|
714
|
+
saveState(repoRoot, state);
|
|
715
|
+
logSuccess("Created state.json");
|
|
716
|
+
const warnings = [];
|
|
717
|
+
logHeader(`Permissions: ${permMode} mode`);
|
|
718
|
+
const claudeResult = applyClaudePermissions(repoRoot, permMode);
|
|
719
|
+
warnings.push(...claudeResult.warnings);
|
|
720
|
+
const codexResult = applyCodexPermissions(repoRoot, commsDir, permMode);
|
|
721
|
+
warnings.push(...codexResult.warnings);
|
|
722
|
+
const permSummary = buildPermissionSummary(permMode, repoRoot, commsDir);
|
|
723
|
+
if (permMode === "full") {
|
|
724
|
+
logWarn("Full mode: no destructive operation guards. Use with caution.");
|
|
725
|
+
}
|
|
726
|
+
logHeader("Done! Next steps:");
|
|
727
|
+
log("npx @hua-labs/tap add claude # Add Claude runtime");
|
|
728
|
+
log("npx @hua-labs/tap add codex # Add Codex runtime");
|
|
729
|
+
log("npx @hua-labs/tap status # Check status");
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
command: "init",
|
|
733
|
+
code: "TAP_INIT_OK",
|
|
734
|
+
message: "Initialized successfully",
|
|
735
|
+
warnings,
|
|
736
|
+
data: {
|
|
737
|
+
commsDir,
|
|
738
|
+
repoRoot,
|
|
739
|
+
permissions: permSummary
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/adapters/claude.ts
|
|
745
|
+
import * as fs6 from "fs";
|
|
746
|
+
import * as path6 from "path";
|
|
747
|
+
import { execSync } from "child_process";
|
|
748
|
+
var MCP_SERVER_KEY = "tap-comms";
|
|
749
|
+
function findMcpJsonPath(ctx) {
|
|
750
|
+
return path6.join(ctx.repoRoot, ".mcp.json");
|
|
751
|
+
}
|
|
752
|
+
function findClaudeCommand() {
|
|
753
|
+
try {
|
|
754
|
+
execSync("claude --version", { stdio: "pipe" });
|
|
755
|
+
return "claude";
|
|
756
|
+
} catch {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function buildMcpServerEntry(ctx) {
|
|
761
|
+
const localChannels = findLocalChannels(ctx);
|
|
762
|
+
if (!localChannels) return null;
|
|
763
|
+
return {
|
|
764
|
+
type: "stdio",
|
|
765
|
+
command: "npx",
|
|
766
|
+
args: ["bun", localChannels],
|
|
767
|
+
env: { TAP_COMMS_DIR: ctx.commsDir }
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
function findLocalChannels(ctx) {
|
|
771
|
+
const candidates = [
|
|
772
|
+
path6.join(
|
|
773
|
+
ctx.repoRoot,
|
|
774
|
+
"packages",
|
|
775
|
+
"tap-plugin",
|
|
776
|
+
"channels",
|
|
777
|
+
"tap-comms.ts"
|
|
778
|
+
),
|
|
779
|
+
path6.join(
|
|
780
|
+
ctx.repoRoot,
|
|
781
|
+
"node_modules",
|
|
782
|
+
"@hua-labs",
|
|
783
|
+
"channels",
|
|
784
|
+
"tap-comms.ts"
|
|
785
|
+
)
|
|
786
|
+
];
|
|
787
|
+
for (const p of candidates) {
|
|
788
|
+
if (fs6.existsSync(p)) return p;
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
var claudeAdapter = {
|
|
793
|
+
runtime: "claude",
|
|
794
|
+
async probe(ctx) {
|
|
795
|
+
const warnings = [];
|
|
796
|
+
const issues = [];
|
|
797
|
+
const configPath = findMcpJsonPath(ctx);
|
|
798
|
+
const configExists = fs6.existsSync(configPath);
|
|
799
|
+
const runtimeCommand = findClaudeCommand();
|
|
800
|
+
const canWrite = configExists ? (() => {
|
|
801
|
+
try {
|
|
802
|
+
fs6.accessSync(configPath, fs6.constants.W_OK);
|
|
803
|
+
return true;
|
|
804
|
+
} catch {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
})() : true;
|
|
808
|
+
if (!runtimeCommand) {
|
|
809
|
+
warnings.push(
|
|
810
|
+
"Claude CLI not found in PATH. Config will be created but may need manual setup."
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const localChannels = findLocalChannels(ctx);
|
|
814
|
+
if (!localChannels) {
|
|
815
|
+
issues.push(
|
|
816
|
+
"tap-comms MCP server not found locally. Ensure packages/tap-plugin/channels/tap-comms.ts exists. Run from the monorepo root."
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (!fs6.existsSync(ctx.commsDir)) {
|
|
820
|
+
issues.push(
|
|
821
|
+
`Comms directory not found: ${ctx.commsDir}. Run "init" first.`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
installed: true,
|
|
826
|
+
// Claude adapter always "installed" — .mcp.json is per-project
|
|
827
|
+
configPath,
|
|
828
|
+
configExists,
|
|
829
|
+
runtimeCommand,
|
|
830
|
+
version: null,
|
|
831
|
+
canWrite,
|
|
832
|
+
warnings,
|
|
833
|
+
issues
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
async plan(ctx, probe) {
|
|
837
|
+
const configPath = probe.configPath ?? findMcpJsonPath(ctx);
|
|
838
|
+
const conflicts = [];
|
|
839
|
+
const warnings = [];
|
|
840
|
+
const operations = [];
|
|
841
|
+
const ownedArtifacts = [];
|
|
842
|
+
if (probe.configExists) {
|
|
843
|
+
const raw = fs6.readFileSync(configPath, "utf-8");
|
|
844
|
+
try {
|
|
845
|
+
const config = JSON.parse(raw);
|
|
846
|
+
if (config.mcpServers?.[MCP_SERVER_KEY]) {
|
|
847
|
+
conflicts.push(
|
|
848
|
+
`Existing "${MCP_SERVER_KEY}" entry in .mcp.json will be overwritten.`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
} catch {
|
|
852
|
+
warnings.push(
|
|
853
|
+
".mcp.json exists but is not valid JSON. Will be overwritten."
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const serverEntry = buildMcpServerEntry(ctx);
|
|
858
|
+
if (!serverEntry) {
|
|
859
|
+
warnings.push(
|
|
860
|
+
"tap-comms MCP server not found locally. Skipping .mcp.json patch. Run from monorepo root with packages/tap-plugin/channels/ available."
|
|
861
|
+
);
|
|
862
|
+
return {
|
|
863
|
+
runtime: "claude",
|
|
864
|
+
operations: [],
|
|
865
|
+
ownedArtifacts: [],
|
|
866
|
+
backupDir: ensureBackupDir(ctx.stateDir, "claude"),
|
|
867
|
+
restartRequired: false,
|
|
868
|
+
conflicts,
|
|
869
|
+
warnings
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
operations.push({
|
|
873
|
+
type: probe.configExists ? "merge" : "set",
|
|
874
|
+
path: configPath,
|
|
875
|
+
key: `mcpServers.${MCP_SERVER_KEY}`,
|
|
876
|
+
value: serverEntry
|
|
877
|
+
});
|
|
878
|
+
ownedArtifacts.push({
|
|
879
|
+
kind: "json-path",
|
|
880
|
+
path: configPath,
|
|
881
|
+
selector: `mcpServers.${MCP_SERVER_KEY}`
|
|
882
|
+
});
|
|
883
|
+
const backupDir = ensureBackupDir(ctx.stateDir, "claude");
|
|
884
|
+
return {
|
|
885
|
+
runtime: "claude",
|
|
886
|
+
operations,
|
|
887
|
+
ownedArtifacts,
|
|
888
|
+
backupDir,
|
|
889
|
+
restartRequired: true,
|
|
890
|
+
conflicts,
|
|
891
|
+
warnings
|
|
892
|
+
};
|
|
893
|
+
},
|
|
894
|
+
async apply(_ctx, plan) {
|
|
895
|
+
const changedFiles = [];
|
|
896
|
+
const warnings = [];
|
|
897
|
+
let appliedOps = 0;
|
|
898
|
+
for (const op of plan.operations) {
|
|
899
|
+
try {
|
|
900
|
+
if (op.type === "set" || op.type === "merge") {
|
|
901
|
+
let config = {};
|
|
902
|
+
if (fs6.existsSync(op.path)) {
|
|
903
|
+
backupFile(op.path, plan.backupDir);
|
|
904
|
+
const raw = fs6.readFileSync(op.path, "utf-8");
|
|
905
|
+
try {
|
|
906
|
+
config = JSON.parse(raw);
|
|
907
|
+
} catch {
|
|
908
|
+
warnings.push(
|
|
909
|
+
`${op.path} was invalid JSON. Created backup and starting fresh.`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (op.key) {
|
|
914
|
+
setNestedKey(config, op.key, op.value);
|
|
915
|
+
}
|
|
916
|
+
const tmp = `${op.path}.tmp.${process.pid}`;
|
|
917
|
+
fs6.writeFileSync(
|
|
918
|
+
tmp,
|
|
919
|
+
JSON.stringify(config, null, 2) + "\n",
|
|
920
|
+
"utf-8"
|
|
921
|
+
);
|
|
922
|
+
fs6.renameSync(tmp, op.path);
|
|
923
|
+
changedFiles.push(op.path);
|
|
924
|
+
appliedOps++;
|
|
925
|
+
}
|
|
926
|
+
} catch (err) {
|
|
927
|
+
warnings.push(
|
|
928
|
+
`Failed to apply op on ${op.path}: ${err instanceof Error ? err.message : String(err)}`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const lastAppliedHash = changedFiles.length > 0 ? fileHash(changedFiles[0]) : "";
|
|
933
|
+
return {
|
|
934
|
+
success: appliedOps > 0,
|
|
935
|
+
appliedOps,
|
|
936
|
+
backupCreated: true,
|
|
937
|
+
lastAppliedHash,
|
|
938
|
+
ownedArtifacts: plan.ownedArtifacts,
|
|
939
|
+
changedFiles,
|
|
940
|
+
restartRequired: plan.restartRequired,
|
|
941
|
+
warnings
|
|
942
|
+
};
|
|
943
|
+
},
|
|
944
|
+
async verify(ctx, plan) {
|
|
945
|
+
const checks = [];
|
|
946
|
+
const warnings = [];
|
|
947
|
+
const configPath = plan.operations[0]?.path;
|
|
948
|
+
if (configPath) {
|
|
949
|
+
checks.push({
|
|
950
|
+
name: "Config file exists",
|
|
951
|
+
passed: fs6.existsSync(configPath),
|
|
952
|
+
message: fs6.existsSync(configPath) ? void 0 : `${configPath} not found`
|
|
953
|
+
});
|
|
954
|
+
if (fs6.existsSync(configPath)) {
|
|
955
|
+
try {
|
|
956
|
+
const raw = fs6.readFileSync(configPath, "utf-8");
|
|
957
|
+
const config = JSON.parse(raw);
|
|
958
|
+
checks.push({ name: "Config is valid JSON", passed: true });
|
|
959
|
+
const entry = config.mcpServers?.[MCP_SERVER_KEY];
|
|
960
|
+
checks.push({
|
|
961
|
+
name: "tap-comms entry present",
|
|
962
|
+
passed: !!entry,
|
|
963
|
+
message: entry ? void 0 : `mcpServers.${MCP_SERVER_KEY} not found`
|
|
964
|
+
});
|
|
965
|
+
if (entry) {
|
|
966
|
+
const hasCommsDir = entry.env?.TAP_COMMS_DIR === ctx.commsDir;
|
|
967
|
+
checks.push({
|
|
968
|
+
name: "TAP_COMMS_DIR configured",
|
|
969
|
+
passed: hasCommsDir,
|
|
970
|
+
message: hasCommsDir ? void 0 : `Expected ${ctx.commsDir}`
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
} catch {
|
|
974
|
+
checks.push({
|
|
975
|
+
name: "Config is valid JSON",
|
|
976
|
+
passed: false,
|
|
977
|
+
message: "Parse error"
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
checks.push({
|
|
983
|
+
name: "Comms directory exists",
|
|
984
|
+
passed: fs6.existsSync(ctx.commsDir),
|
|
985
|
+
message: fs6.existsSync(ctx.commsDir) ? void 0 : `${ctx.commsDir} not found`
|
|
986
|
+
});
|
|
987
|
+
const cmd = findClaudeCommand();
|
|
988
|
+
checks.push({
|
|
989
|
+
name: "Claude CLI found",
|
|
990
|
+
passed: !!cmd,
|
|
991
|
+
message: cmd ? void 0 : "claude not in PATH (non-blocking)"
|
|
992
|
+
});
|
|
993
|
+
if (!cmd) {
|
|
994
|
+
warnings.push(
|
|
995
|
+
"Claude CLI not in PATH. Config is ready but cannot verify runtime reads it."
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
const ok = checks.filter((c) => c.name !== "Claude CLI found").every((c) => c.passed);
|
|
999
|
+
return { ok, checks, restartRequired: true, warnings };
|
|
1000
|
+
},
|
|
1001
|
+
bridgeMode() {
|
|
1002
|
+
return "native-push";
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
function setNestedKey(obj, keyPath, value) {
|
|
1006
|
+
const keys = keyPath.split(".");
|
|
1007
|
+
let current = obj;
|
|
1008
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
1009
|
+
const key = keys[i];
|
|
1010
|
+
if (typeof current[key] !== "object" || current[key] === null) {
|
|
1011
|
+
current[key] = {};
|
|
1012
|
+
}
|
|
1013
|
+
current = current[key];
|
|
1014
|
+
}
|
|
1015
|
+
current[keys[keys.length - 1]] = value;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/adapters/codex.ts
|
|
1019
|
+
import * as fs9 from "fs";
|
|
1020
|
+
import * as path9 from "path";
|
|
1021
|
+
import { fileURLToPath } from "url";
|
|
1022
|
+
|
|
1023
|
+
// src/artifact-backups.ts
|
|
1024
|
+
import * as crypto2 from "crypto";
|
|
1025
|
+
import * as fs7 from "fs";
|
|
1026
|
+
import * as path7 from "path";
|
|
1027
|
+
function selectorHash(selector) {
|
|
1028
|
+
return crypto2.createHash("sha256").update(selector).digest("hex").slice(0, 12);
|
|
1029
|
+
}
|
|
1030
|
+
function artifactBackupPath(backupDir, kind, selector) {
|
|
1031
|
+
const safeKind = kind.replace(/[^a-z-]/gi, "-");
|
|
1032
|
+
return path7.join(backupDir, `${safeKind}-${selectorHash(selector)}.json`);
|
|
1033
|
+
}
|
|
1034
|
+
function writeArtifactBackup(backupPath, payload) {
|
|
1035
|
+
fs7.mkdirSync(path7.dirname(backupPath), { recursive: true });
|
|
1036
|
+
const tmp = `${backupPath}.tmp.${process.pid}`;
|
|
1037
|
+
fs7.writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
1038
|
+
fs7.renameSync(tmp, backupPath);
|
|
1039
|
+
}
|
|
1040
|
+
function readArtifactBackup(backupPath) {
|
|
1041
|
+
if (!fs7.existsSync(backupPath)) return null;
|
|
1042
|
+
try {
|
|
1043
|
+
const raw = fs7.readFileSync(backupPath, "utf-8");
|
|
1044
|
+
return JSON.parse(raw);
|
|
1045
|
+
} catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// src/adapters/common.ts
|
|
1051
|
+
import * as fs8 from "fs";
|
|
1052
|
+
import * as os2 from "os";
|
|
1053
|
+
import * as path8 from "path";
|
|
1054
|
+
import { spawnSync } from "child_process";
|
|
1055
|
+
function probeCommand(candidates) {
|
|
1056
|
+
for (const candidate of candidates) {
|
|
1057
|
+
const result = spawnSync(candidate, ["--version"], {
|
|
1058
|
+
encoding: "utf-8",
|
|
1059
|
+
shell: process.platform === "win32"
|
|
1060
|
+
});
|
|
1061
|
+
if (result.status === 0) {
|
|
1062
|
+
const version2 = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || null;
|
|
1063
|
+
return { command: candidate, version: version2 };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return { command: null, version: null };
|
|
1067
|
+
}
|
|
1068
|
+
function getHomeDir() {
|
|
1069
|
+
return os2.homedir();
|
|
1070
|
+
}
|
|
1071
|
+
function toForwardSlashPath(filePath) {
|
|
1072
|
+
return path8.resolve(filePath).replace(/\\/g, "/");
|
|
1073
|
+
}
|
|
1074
|
+
function canWriteOrCreate(filePath) {
|
|
1075
|
+
try {
|
|
1076
|
+
if (fs8.existsSync(filePath)) {
|
|
1077
|
+
fs8.accessSync(filePath, fs8.constants.W_OK);
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
const parent = path8.dirname(filePath);
|
|
1081
|
+
fs8.mkdirSync(parent, { recursive: true });
|
|
1082
|
+
fs8.accessSync(parent, fs8.constants.W_OK);
|
|
1083
|
+
return true;
|
|
1084
|
+
} catch {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function findLocalTapCommsSource(ctx) {
|
|
1089
|
+
const candidates = [
|
|
1090
|
+
path8.join(
|
|
1091
|
+
ctx.repoRoot,
|
|
1092
|
+
"packages",
|
|
1093
|
+
"tap-plugin",
|
|
1094
|
+
"channels",
|
|
1095
|
+
"tap-comms.ts"
|
|
1096
|
+
),
|
|
1097
|
+
path8.join(
|
|
1098
|
+
ctx.repoRoot,
|
|
1099
|
+
"node_modules",
|
|
1100
|
+
"@hua-labs",
|
|
1101
|
+
"tap-plugin",
|
|
1102
|
+
"channels",
|
|
1103
|
+
"tap-comms.ts"
|
|
1104
|
+
)
|
|
1105
|
+
];
|
|
1106
|
+
for (const candidate of candidates) {
|
|
1107
|
+
if (fs8.existsSync(candidate)) return candidate;
|
|
1108
|
+
}
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
function findPreferredBunCommand() {
|
|
1112
|
+
const home = getHomeDir();
|
|
1113
|
+
const candidates = process.platform === "win32" ? [path8.join(home, ".bun", "bin", "bun.exe"), "bun", "bun.cmd"] : [path8.join(home, ".bun", "bin", "bun"), "bun"];
|
|
1114
|
+
for (const candidate of candidates) {
|
|
1115
|
+
if (path8.isAbsolute(candidate) && !fs8.existsSync(candidate)) continue;
|
|
1116
|
+
const result = spawnSync(candidate, ["--version"], {
|
|
1117
|
+
encoding: "utf-8",
|
|
1118
|
+
shell: process.platform === "win32"
|
|
1119
|
+
});
|
|
1120
|
+
if (result.status === 0) {
|
|
1121
|
+
return path8.isAbsolute(candidate) ? toForwardSlashPath(candidate) : candidate;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
function buildManagedMcpServerSpec(ctx) {
|
|
1127
|
+
const sourcePath = findLocalTapCommsSource(ctx);
|
|
1128
|
+
const bunCommand = findPreferredBunCommand();
|
|
1129
|
+
const warnings = [];
|
|
1130
|
+
const issues = [];
|
|
1131
|
+
if (sourcePath && bunCommand) {
|
|
1132
|
+
return {
|
|
1133
|
+
command: bunCommand,
|
|
1134
|
+
args: [toForwardSlashPath(sourcePath)],
|
|
1135
|
+
env: {
|
|
1136
|
+
TAP_AGENT_NAME: "<set-per-session>",
|
|
1137
|
+
TAP_COMMS_DIR: toForwardSlashPath(ctx.commsDir)
|
|
1138
|
+
},
|
|
1139
|
+
sourcePath,
|
|
1140
|
+
warnings,
|
|
1141
|
+
issues
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (!sourcePath) {
|
|
1145
|
+
issues.push(
|
|
1146
|
+
"tap-comms MCP server source not found. v1 requires a repo-local tap-plugin/channels installation."
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (!bunCommand) {
|
|
1150
|
+
issues.push("bun is required to run the repo-local tap-comms MCP server.");
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
command: null,
|
|
1154
|
+
args: [],
|
|
1155
|
+
env: {
|
|
1156
|
+
TAP_AGENT_NAME: "<set-per-session>",
|
|
1157
|
+
TAP_COMMS_DIR: toForwardSlashPath(ctx.commsDir)
|
|
1158
|
+
},
|
|
1159
|
+
sourcePath,
|
|
1160
|
+
warnings,
|
|
1161
|
+
issues
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/adapters/codex.ts
|
|
1166
|
+
var MCP_SELECTOR = "mcp_servers.tap-comms";
|
|
1167
|
+
var ENV_SELECTOR = "mcp_servers.tap-comms.env";
|
|
1168
|
+
function findCodexConfigPath2() {
|
|
1169
|
+
return path9.join(getHomeDir(), ".codex", "config.toml");
|
|
1170
|
+
}
|
|
1171
|
+
function canonicalizeTrustPath2(targetPath) {
|
|
1172
|
+
let resolved = path9.resolve(targetPath).replace(/\//g, "\\");
|
|
1173
|
+
const driveRoot = /^[A-Za-z]:\\$/;
|
|
1174
|
+
if (!driveRoot.test(resolved)) {
|
|
1175
|
+
resolved = resolved.replace(/\\+$/g, "");
|
|
1176
|
+
}
|
|
1177
|
+
return resolved.startsWith("\\\\?\\") ? resolved : `\\\\?\\${resolved}`;
|
|
1178
|
+
}
|
|
1179
|
+
function trustSelector(targetPath) {
|
|
1180
|
+
return `projects.'${canonicalizeTrustPath2(targetPath)}'`;
|
|
1181
|
+
}
|
|
1182
|
+
function getTrustTargets(ctx) {
|
|
1183
|
+
const targets = [ctx.repoRoot, process.cwd()];
|
|
1184
|
+
return [...new Set(targets.map((value) => path9.resolve(value)))];
|
|
1185
|
+
}
|
|
1186
|
+
function buildManagedArtifacts(configPath, ctx) {
|
|
1187
|
+
const artifacts = [
|
|
1188
|
+
{ kind: "toml-table", path: configPath, selector: MCP_SELECTOR },
|
|
1189
|
+
{ kind: "toml-table", path: configPath, selector: ENV_SELECTOR }
|
|
1190
|
+
];
|
|
1191
|
+
for (const target of getTrustTargets(ctx)) {
|
|
1192
|
+
artifacts.push({
|
|
1193
|
+
kind: "toml-table",
|
|
1194
|
+
path: configPath,
|
|
1195
|
+
selector: trustSelector(target)
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
return artifacts;
|
|
1199
|
+
}
|
|
1200
|
+
function readConfigOrEmpty(configPath) {
|
|
1201
|
+
if (!fs9.existsSync(configPath)) return "";
|
|
1202
|
+
return fs9.readFileSync(configPath, "utf-8");
|
|
1203
|
+
}
|
|
1204
|
+
function writeTomlFile(filePath, content) {
|
|
1205
|
+
fs9.mkdirSync(path9.dirname(filePath), { recursive: true });
|
|
1206
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
1207
|
+
fs9.writeFileSync(tmp, content, "utf-8");
|
|
1208
|
+
fs9.renameSync(tmp, filePath);
|
|
1209
|
+
}
|
|
1210
|
+
function verifyManagedToml(content, ctx, configPath) {
|
|
1211
|
+
const checks = [];
|
|
1212
|
+
const managed = buildManagedMcpServerSpec(ctx);
|
|
1213
|
+
const mainTable = extractTomlTable(content, MCP_SELECTOR);
|
|
1214
|
+
const envTable = extractTomlTable(content, ENV_SELECTOR);
|
|
1215
|
+
checks.push({
|
|
1216
|
+
name: "Codex config exists",
|
|
1217
|
+
passed: fs9.existsSync(configPath),
|
|
1218
|
+
message: fs9.existsSync(configPath) ? void 0 : `${configPath} not found`
|
|
1219
|
+
});
|
|
1220
|
+
checks.push({
|
|
1221
|
+
name: "tap-comms MCP table present",
|
|
1222
|
+
passed: !!mainTable,
|
|
1223
|
+
message: mainTable ? void 0 : `${MCP_SELECTOR} not found`
|
|
1224
|
+
});
|
|
1225
|
+
checks.push({
|
|
1226
|
+
name: "tap-comms env table present",
|
|
1227
|
+
passed: !!envTable,
|
|
1228
|
+
message: envTable ? void 0 : `${ENV_SELECTOR} not found`
|
|
1229
|
+
});
|
|
1230
|
+
for (const target of getTrustTargets(ctx)) {
|
|
1231
|
+
const selector = trustSelector(target);
|
|
1232
|
+
const trustTable = extractTomlTable(content, selector);
|
|
1233
|
+
checks.push({
|
|
1234
|
+
name: `Trust table present: ${canonicalizeTrustPath2(target)}`,
|
|
1235
|
+
passed: !!trustTable && trustTable.includes('trust_level = "trusted"'),
|
|
1236
|
+
message: trustTable && trustTable.includes('trust_level = "trusted"') ? void 0 : `${selector} missing trust_level = "trusted"`
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
if (mainTable && managed.command) {
|
|
1240
|
+
checks.push({
|
|
1241
|
+
name: "Managed command configured",
|
|
1242
|
+
passed: mainTable.includes(
|
|
1243
|
+
`command = "${managed.command.replace(/\\/g, "\\\\")}"`
|
|
1244
|
+
) && mainTable.includes(
|
|
1245
|
+
`args = ["${managed.args[0]?.replace(/\\/g, "\\\\") ?? ""}"]`
|
|
1246
|
+
),
|
|
1247
|
+
message: "Managed tap-comms command/args do not match expected values"
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
return checks;
|
|
1251
|
+
}
|
|
1252
|
+
var codexAdapter = {
|
|
1253
|
+
runtime: "codex",
|
|
1254
|
+
async probe(ctx) {
|
|
1255
|
+
const warnings = [];
|
|
1256
|
+
const issues = [];
|
|
1257
|
+
const configPath = findCodexConfigPath2();
|
|
1258
|
+
const configExists = fs9.existsSync(configPath);
|
|
1259
|
+
const runtimeProbe = probeCommand(
|
|
1260
|
+
ctx.platform === "win32" ? ["codex", "codex.cmd"] : ["codex"]
|
|
1261
|
+
);
|
|
1262
|
+
if (!runtimeProbe.command) {
|
|
1263
|
+
warnings.push(
|
|
1264
|
+
"Codex CLI not found in PATH. Config can still be written, but runtime verification will be limited."
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
if (!fs9.existsSync(ctx.commsDir)) {
|
|
1268
|
+
issues.push(
|
|
1269
|
+
`Comms directory not found: ${ctx.commsDir}. Run "init" first.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
const managed = buildManagedMcpServerSpec(ctx);
|
|
1273
|
+
warnings.push(...managed.warnings);
|
|
1274
|
+
issues.push(...managed.issues);
|
|
1275
|
+
return {
|
|
1276
|
+
installed: true,
|
|
1277
|
+
configPath,
|
|
1278
|
+
configExists,
|
|
1279
|
+
runtimeCommand: runtimeProbe.command,
|
|
1280
|
+
version: runtimeProbe.version,
|
|
1281
|
+
canWrite: canWriteOrCreate(configPath),
|
|
1282
|
+
warnings,
|
|
1283
|
+
issues
|
|
1284
|
+
};
|
|
1285
|
+
},
|
|
1286
|
+
async plan(ctx, probe) {
|
|
1287
|
+
const configPath = probe.configPath ?? findCodexConfigPath2();
|
|
1288
|
+
const conflicts = [];
|
|
1289
|
+
const warnings = [];
|
|
1290
|
+
const operations = [];
|
|
1291
|
+
const ownedArtifacts = buildManagedArtifacts(configPath, ctx);
|
|
1292
|
+
if (probe.configExists) {
|
|
1293
|
+
const content = readConfigOrEmpty(configPath);
|
|
1294
|
+
if (extractTomlTable(content, MCP_SELECTOR)) {
|
|
1295
|
+
conflicts.push(`Existing ${MCP_SELECTOR} table will be updated.`);
|
|
1296
|
+
}
|
|
1297
|
+
if (extractTomlTable(content, ENV_SELECTOR)) {
|
|
1298
|
+
conflicts.push(`Existing ${ENV_SELECTOR} table will be updated.`);
|
|
1299
|
+
}
|
|
1300
|
+
for (const target of getTrustTargets(ctx)) {
|
|
1301
|
+
const selector = trustSelector(target);
|
|
1302
|
+
if (extractTomlTable(content, selector)) {
|
|
1303
|
+
conflicts.push(`Existing ${selector} table will be updated.`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
for (const artifact of ownedArtifacts) {
|
|
1308
|
+
operations.push({
|
|
1309
|
+
type: probe.configExists ? "merge" : "set",
|
|
1310
|
+
path: configPath,
|
|
1311
|
+
key: artifact.selector
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
runtime: "codex",
|
|
1316
|
+
operations,
|
|
1317
|
+
ownedArtifacts,
|
|
1318
|
+
backupDir: ensureBackupDir(ctx.stateDir, "codex"),
|
|
1319
|
+
restartRequired: true,
|
|
1320
|
+
conflicts,
|
|
1321
|
+
warnings
|
|
1322
|
+
};
|
|
1323
|
+
},
|
|
1324
|
+
async apply(ctx, plan) {
|
|
1325
|
+
const configPath = plan.operations[0]?.path ?? findCodexConfigPath2();
|
|
1326
|
+
const warnings = [];
|
|
1327
|
+
const changedFiles = [];
|
|
1328
|
+
const managed = buildManagedMcpServerSpec(ctx);
|
|
1329
|
+
warnings.push(...managed.warnings);
|
|
1330
|
+
if (managed.issues.length > 0 || !managed.command) {
|
|
1331
|
+
return {
|
|
1332
|
+
success: false,
|
|
1333
|
+
appliedOps: 0,
|
|
1334
|
+
backupCreated: false,
|
|
1335
|
+
lastAppliedHash: "",
|
|
1336
|
+
ownedArtifacts: [],
|
|
1337
|
+
changedFiles,
|
|
1338
|
+
restartRequired: false,
|
|
1339
|
+
warnings: [...managed.warnings, ...managed.issues]
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
const existingContent = readConfigOrEmpty(configPath);
|
|
1343
|
+
if (fs9.existsSync(configPath) && existingContent) {
|
|
1344
|
+
backupFile(configPath, plan.backupDir);
|
|
1345
|
+
}
|
|
1346
|
+
const artifactsWithBackups = plan.ownedArtifacts.map((artifact) => {
|
|
1347
|
+
const previousContent = artifact.kind === "toml-table" ? extractTomlTable(existingContent, artifact.selector) : null;
|
|
1348
|
+
const backupPath = artifactBackupPath(
|
|
1349
|
+
plan.backupDir,
|
|
1350
|
+
artifact.kind,
|
|
1351
|
+
artifact.selector
|
|
1352
|
+
);
|
|
1353
|
+
writeArtifactBackup(backupPath, {
|
|
1354
|
+
kind: "toml-table",
|
|
1355
|
+
selector: artifact.selector,
|
|
1356
|
+
existed: previousContent !== null,
|
|
1357
|
+
content: previousContent ?? void 0
|
|
1358
|
+
});
|
|
1359
|
+
return { ...artifact, backupPath };
|
|
1360
|
+
});
|
|
1361
|
+
let nextContent = existingContent;
|
|
1362
|
+
nextContent = replaceTomlTable(
|
|
1363
|
+
nextContent,
|
|
1364
|
+
MCP_SELECTOR,
|
|
1365
|
+
renderTomlTable(
|
|
1366
|
+
MCP_SELECTOR,
|
|
1367
|
+
{
|
|
1368
|
+
command: managed.command,
|
|
1369
|
+
args: managed.args
|
|
1370
|
+
},
|
|
1371
|
+
extractTomlTable(existingContent, MCP_SELECTOR)
|
|
1372
|
+
)
|
|
1373
|
+
);
|
|
1374
|
+
nextContent = replaceTomlTable(
|
|
1375
|
+
nextContent,
|
|
1376
|
+
ENV_SELECTOR,
|
|
1377
|
+
renderTomlTable(
|
|
1378
|
+
ENV_SELECTOR,
|
|
1379
|
+
managed.env,
|
|
1380
|
+
extractTomlTable(existingContent, ENV_SELECTOR)
|
|
1381
|
+
)
|
|
1382
|
+
);
|
|
1383
|
+
for (const target of getTrustTargets(ctx)) {
|
|
1384
|
+
const selector = trustSelector(target);
|
|
1385
|
+
nextContent = replaceTomlTable(
|
|
1386
|
+
nextContent,
|
|
1387
|
+
selector,
|
|
1388
|
+
renderTomlTable(
|
|
1389
|
+
selector,
|
|
1390
|
+
{ trust_level: "trusted" },
|
|
1391
|
+
extractTomlTable(existingContent, selector)
|
|
1392
|
+
)
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
writeTomlFile(configPath, nextContent);
|
|
1396
|
+
changedFiles.push(configPath);
|
|
1397
|
+
return {
|
|
1398
|
+
success: true,
|
|
1399
|
+
appliedOps: plan.operations.length,
|
|
1400
|
+
backupCreated: true,
|
|
1401
|
+
lastAppliedHash: fileHash(configPath),
|
|
1402
|
+
ownedArtifacts: artifactsWithBackups,
|
|
1403
|
+
changedFiles,
|
|
1404
|
+
restartRequired: true,
|
|
1405
|
+
warnings
|
|
1406
|
+
};
|
|
1407
|
+
},
|
|
1408
|
+
async verify(ctx, plan) {
|
|
1409
|
+
const warnings = [];
|
|
1410
|
+
const configPath = plan.operations[0]?.path ?? findCodexConfigPath2();
|
|
1411
|
+
const content = readConfigOrEmpty(configPath);
|
|
1412
|
+
const runtimeProbe = probeCommand(
|
|
1413
|
+
ctx.platform === "win32" ? ["codex", "codex.cmd"] : ["codex"]
|
|
1414
|
+
);
|
|
1415
|
+
const checks = verifyManagedToml(content, ctx, configPath);
|
|
1416
|
+
checks.push({
|
|
1417
|
+
name: "Comms directory exists",
|
|
1418
|
+
passed: fs9.existsSync(ctx.commsDir),
|
|
1419
|
+
message: fs9.existsSync(ctx.commsDir) ? void 0 : `${ctx.commsDir} not found`
|
|
1420
|
+
});
|
|
1421
|
+
checks.push({
|
|
1422
|
+
name: "Codex CLI found",
|
|
1423
|
+
passed: !!runtimeProbe.command,
|
|
1424
|
+
message: runtimeProbe.command ? void 0 : "codex not in PATH (non-blocking)"
|
|
1425
|
+
});
|
|
1426
|
+
if (!runtimeProbe.command) {
|
|
1427
|
+
warnings.push(
|
|
1428
|
+
"Codex CLI not in PATH. Config is written, but runtime verification is partial."
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
return {
|
|
1432
|
+
ok: checks.filter((check) => check.name !== "Codex CLI found").every((check) => check.passed),
|
|
1433
|
+
checks,
|
|
1434
|
+
restartRequired: true,
|
|
1435
|
+
warnings
|
|
1436
|
+
};
|
|
1437
|
+
},
|
|
1438
|
+
bridgeMode() {
|
|
1439
|
+
return "app-server";
|
|
1440
|
+
},
|
|
1441
|
+
resolveBridgeScript(ctx) {
|
|
1442
|
+
const distDir = path9.dirname(fileURLToPath(import.meta.url));
|
|
1443
|
+
const candidates = [
|
|
1444
|
+
// 1. Relative to bundled CLI (npm install / npx)
|
|
1445
|
+
path9.join(distDir, "bridges", "codex-bridge-runner.mjs"),
|
|
1446
|
+
// 2. Monorepo development — dist inside repo
|
|
1447
|
+
path9.join(
|
|
1448
|
+
ctx.repoRoot,
|
|
1449
|
+
"packages",
|
|
1450
|
+
"tap-comms",
|
|
1451
|
+
"dist",
|
|
1452
|
+
"bridges",
|
|
1453
|
+
"codex-bridge-runner.mjs"
|
|
1454
|
+
),
|
|
1455
|
+
// 3. Source file — dev mode with strip-types
|
|
1456
|
+
path9.join(
|
|
1457
|
+
ctx.repoRoot,
|
|
1458
|
+
"packages",
|
|
1459
|
+
"tap-comms",
|
|
1460
|
+
"src",
|
|
1461
|
+
"bridges",
|
|
1462
|
+
"codex-bridge-runner.ts"
|
|
1463
|
+
)
|
|
1464
|
+
];
|
|
1465
|
+
for (const candidate of candidates) {
|
|
1466
|
+
if (fs9.existsSync(candidate)) return candidate;
|
|
1467
|
+
}
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// src/adapters/gemini.ts
|
|
1473
|
+
import * as fs10 from "fs";
|
|
1474
|
+
import * as path10 from "path";
|
|
1475
|
+
var GEMINI_SELECTOR = "mcpServers.tap-comms";
|
|
1476
|
+
function candidateConfigPaths(ctx) {
|
|
1477
|
+
const home = getHomeDir();
|
|
1478
|
+
return [
|
|
1479
|
+
path10.join(ctx.repoRoot, ".gemini", "settings.json"),
|
|
1480
|
+
path10.join(home, ".gemini", "settings.json"),
|
|
1481
|
+
path10.join(home, ".gemini", "antigravity", "mcp_config.json")
|
|
1482
|
+
];
|
|
1483
|
+
}
|
|
1484
|
+
function chooseGeminiConfigPath(ctx) {
|
|
1485
|
+
const [workspaceConfig, homeConfig, antigravityConfig] = candidateConfigPaths(ctx);
|
|
1486
|
+
if (fs10.existsSync(workspaceConfig)) return workspaceConfig;
|
|
1487
|
+
if (fs10.existsSync(homeConfig)) return homeConfig;
|
|
1488
|
+
if (fs10.existsSync(antigravityConfig)) {
|
|
1489
|
+
const raw = fs10.readFileSync(antigravityConfig, "utf-8").trim();
|
|
1490
|
+
if (raw) {
|
|
1491
|
+
try {
|
|
1492
|
+
JSON.parse(raw);
|
|
1493
|
+
return antigravityConfig;
|
|
1494
|
+
} catch {
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return workspaceConfig;
|
|
1499
|
+
}
|
|
1500
|
+
function readJsonFile(filePath) {
|
|
1501
|
+
if (!fs10.existsSync(filePath)) return {};
|
|
1502
|
+
const raw = fs10.readFileSync(filePath, "utf-8").trim();
|
|
1503
|
+
if (!raw) return {};
|
|
1504
|
+
return JSON.parse(raw);
|
|
1505
|
+
}
|
|
1506
|
+
function setNestedKey2(obj, keyPath, value) {
|
|
1507
|
+
const keys = keyPath.split(".");
|
|
1508
|
+
let current = obj;
|
|
1509
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
1510
|
+
const key = keys[i];
|
|
1511
|
+
if (typeof current[key] !== "object" || current[key] === null) {
|
|
1512
|
+
current[key] = {};
|
|
1513
|
+
}
|
|
1514
|
+
current = current[key];
|
|
1515
|
+
}
|
|
1516
|
+
current[keys[keys.length - 1]] = value;
|
|
1517
|
+
}
|
|
1518
|
+
function readNestedKey(obj, keyPath) {
|
|
1519
|
+
let current = obj;
|
|
1520
|
+
for (const key of keyPath.split(".")) {
|
|
1521
|
+
if (typeof current !== "object" || current === null || !(key in current)) {
|
|
1522
|
+
return void 0;
|
|
1523
|
+
}
|
|
1524
|
+
current = current[key];
|
|
1525
|
+
}
|
|
1526
|
+
return current;
|
|
1527
|
+
}
|
|
1528
|
+
function verifyGeminiConfig(config, configPath, ctx) {
|
|
1529
|
+
const checks = [];
|
|
1530
|
+
const entry = readNestedKey(config, GEMINI_SELECTOR);
|
|
1531
|
+
checks.push({
|
|
1532
|
+
name: "Gemini config exists",
|
|
1533
|
+
passed: fs10.existsSync(configPath),
|
|
1534
|
+
message: fs10.existsSync(configPath) ? void 0 : `${configPath} not found`
|
|
1535
|
+
});
|
|
1536
|
+
checks.push({
|
|
1537
|
+
name: "tap-comms entry present",
|
|
1538
|
+
passed: !!entry,
|
|
1539
|
+
message: entry ? void 0 : `${GEMINI_SELECTOR} not found`
|
|
1540
|
+
});
|
|
1541
|
+
checks.push({
|
|
1542
|
+
name: "Comms directory exists",
|
|
1543
|
+
passed: fs10.existsSync(ctx.commsDir),
|
|
1544
|
+
message: fs10.existsSync(ctx.commsDir) ? void 0 : `${ctx.commsDir} not found`
|
|
1545
|
+
});
|
|
1546
|
+
if (entry?.env && typeof entry.env === "object") {
|
|
1547
|
+
checks.push({
|
|
1548
|
+
name: "TAP_COMMS_DIR configured",
|
|
1549
|
+
passed: entry.env.TAP_COMMS_DIR === ctx.commsDir.replace(/\\/g, "/"),
|
|
1550
|
+
message: `Expected ${ctx.commsDir.replace(/\\/g, "/")}`
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
return checks;
|
|
1554
|
+
}
|
|
1555
|
+
var geminiAdapter = {
|
|
1556
|
+
runtime: "gemini",
|
|
1557
|
+
async probe(ctx) {
|
|
1558
|
+
const warnings = [];
|
|
1559
|
+
const issues = [];
|
|
1560
|
+
const configPath = chooseGeminiConfigPath(ctx);
|
|
1561
|
+
const configExists = fs10.existsSync(configPath);
|
|
1562
|
+
const runtimeProbe = probeCommand(
|
|
1563
|
+
ctx.platform === "win32" ? ["gemini", "gemini.cmd"] : ["gemini"]
|
|
1564
|
+
);
|
|
1565
|
+
if (!runtimeProbe.command) {
|
|
1566
|
+
warnings.push(
|
|
1567
|
+
"Gemini CLI not found in PATH. Config can still be written, but runtime verification will be limited."
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
if (!fs10.existsSync(ctx.commsDir)) {
|
|
1571
|
+
issues.push(`Comms directory not found: ${ctx.commsDir}. Run "init" first.`);
|
|
1572
|
+
}
|
|
1573
|
+
const managed = buildManagedMcpServerSpec(ctx);
|
|
1574
|
+
warnings.push(...managed.warnings);
|
|
1575
|
+
issues.push(...managed.issues);
|
|
1576
|
+
return {
|
|
1577
|
+
installed: true,
|
|
1578
|
+
configPath,
|
|
1579
|
+
configExists,
|
|
1580
|
+
runtimeCommand: runtimeProbe.command,
|
|
1581
|
+
version: runtimeProbe.version,
|
|
1582
|
+
canWrite: canWriteOrCreate(configPath),
|
|
1583
|
+
warnings,
|
|
1584
|
+
issues
|
|
1585
|
+
};
|
|
1586
|
+
},
|
|
1587
|
+
async plan(ctx, probe) {
|
|
1588
|
+
const configPath = probe.configPath ?? chooseGeminiConfigPath(ctx);
|
|
1589
|
+
const conflicts = [];
|
|
1590
|
+
const warnings = [];
|
|
1591
|
+
const operations = [];
|
|
1592
|
+
const ownedArtifacts = [
|
|
1593
|
+
{ kind: "json-path", path: configPath, selector: GEMINI_SELECTOR }
|
|
1594
|
+
];
|
|
1595
|
+
if (probe.configExists) {
|
|
1596
|
+
try {
|
|
1597
|
+
const config = readJsonFile(configPath);
|
|
1598
|
+
if (readNestedKey(config, GEMINI_SELECTOR) !== void 0) {
|
|
1599
|
+
conflicts.push(`Existing ${GEMINI_SELECTOR} entry will be updated.`);
|
|
1600
|
+
}
|
|
1601
|
+
} catch {
|
|
1602
|
+
warnings.push(`${configPath} exists but is not valid JSON. It will be replaced.`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
operations.push({
|
|
1606
|
+
type: probe.configExists ? "merge" : "set",
|
|
1607
|
+
path: configPath,
|
|
1608
|
+
key: GEMINI_SELECTOR
|
|
1609
|
+
});
|
|
1610
|
+
return {
|
|
1611
|
+
runtime: "gemini",
|
|
1612
|
+
operations,
|
|
1613
|
+
ownedArtifacts,
|
|
1614
|
+
backupDir: ensureBackupDir(ctx.stateDir, "gemini"),
|
|
1615
|
+
restartRequired: true,
|
|
1616
|
+
conflicts,
|
|
1617
|
+
warnings
|
|
1618
|
+
};
|
|
1619
|
+
},
|
|
1620
|
+
async apply(ctx, plan) {
|
|
1621
|
+
const configPath = plan.operations[0]?.path ?? chooseGeminiConfigPath(ctx);
|
|
1622
|
+
const warnings = [];
|
|
1623
|
+
const changedFiles = [];
|
|
1624
|
+
const managed = buildManagedMcpServerSpec(ctx);
|
|
1625
|
+
warnings.push(...managed.warnings);
|
|
1626
|
+
if (managed.issues.length > 0 || !managed.command) {
|
|
1627
|
+
return {
|
|
1628
|
+
success: false,
|
|
1629
|
+
appliedOps: 0,
|
|
1630
|
+
backupCreated: false,
|
|
1631
|
+
lastAppliedHash: "",
|
|
1632
|
+
ownedArtifacts: [],
|
|
1633
|
+
changedFiles,
|
|
1634
|
+
restartRequired: false,
|
|
1635
|
+
warnings: [...managed.warnings, ...managed.issues]
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
let config = {};
|
|
1639
|
+
let previousValue = void 0;
|
|
1640
|
+
if (fs10.existsSync(configPath)) {
|
|
1641
|
+
if (fs10.readFileSync(configPath, "utf-8").trim()) {
|
|
1642
|
+
backupFile(configPath, plan.backupDir);
|
|
1643
|
+
}
|
|
1644
|
+
try {
|
|
1645
|
+
config = readJsonFile(configPath);
|
|
1646
|
+
} catch {
|
|
1647
|
+
warnings.push(`${configPath} was invalid JSON. Created backup and starting fresh.`);
|
|
1648
|
+
config = {};
|
|
1649
|
+
}
|
|
1650
|
+
previousValue = readNestedKey(config, GEMINI_SELECTOR);
|
|
1651
|
+
}
|
|
1652
|
+
const artifact = plan.ownedArtifacts[0];
|
|
1653
|
+
const backupPath = artifactBackupPath(plan.backupDir, artifact.kind, artifact.selector);
|
|
1654
|
+
writeArtifactBackup(backupPath, {
|
|
1655
|
+
kind: "json-path",
|
|
1656
|
+
selector: artifact.selector,
|
|
1657
|
+
existed: previousValue !== void 0,
|
|
1658
|
+
value: previousValue
|
|
1659
|
+
});
|
|
1660
|
+
setNestedKey2(config, GEMINI_SELECTOR, {
|
|
1661
|
+
command: managed.command,
|
|
1662
|
+
args: managed.args,
|
|
1663
|
+
env: managed.env
|
|
1664
|
+
});
|
|
1665
|
+
fs10.mkdirSync(path10.dirname(configPath), { recursive: true });
|
|
1666
|
+
const tmp = `${configPath}.tmp.${process.pid}`;
|
|
1667
|
+
fs10.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1668
|
+
fs10.renameSync(tmp, configPath);
|
|
1669
|
+
changedFiles.push(configPath);
|
|
1670
|
+
return {
|
|
1671
|
+
success: true,
|
|
1672
|
+
appliedOps: plan.operations.length,
|
|
1673
|
+
backupCreated: true,
|
|
1674
|
+
lastAppliedHash: fileHash(configPath),
|
|
1675
|
+
ownedArtifacts: [{ ...artifact, backupPath }],
|
|
1676
|
+
changedFiles,
|
|
1677
|
+
restartRequired: true,
|
|
1678
|
+
warnings
|
|
1679
|
+
};
|
|
1680
|
+
},
|
|
1681
|
+
async verify(ctx, plan) {
|
|
1682
|
+
const warnings = [];
|
|
1683
|
+
const configPath = plan.operations[0]?.path ?? chooseGeminiConfigPath(ctx);
|
|
1684
|
+
const runtimeProbe = probeCommand(
|
|
1685
|
+
ctx.platform === "win32" ? ["gemini", "gemini.cmd"] : ["gemini"]
|
|
1686
|
+
);
|
|
1687
|
+
let checks;
|
|
1688
|
+
try {
|
|
1689
|
+
const config = readJsonFile(configPath);
|
|
1690
|
+
checks = verifyGeminiConfig(config, configPath, ctx);
|
|
1691
|
+
} catch {
|
|
1692
|
+
checks = [
|
|
1693
|
+
{
|
|
1694
|
+
name: "Gemini config is valid JSON",
|
|
1695
|
+
passed: false,
|
|
1696
|
+
message: "Parse error"
|
|
1697
|
+
}
|
|
1698
|
+
];
|
|
1699
|
+
}
|
|
1700
|
+
checks.push({
|
|
1701
|
+
name: "Gemini CLI found",
|
|
1702
|
+
passed: !!runtimeProbe.command,
|
|
1703
|
+
message: runtimeProbe.command ? void 0 : "gemini not in PATH (non-blocking)"
|
|
1704
|
+
});
|
|
1705
|
+
if (!runtimeProbe.command) {
|
|
1706
|
+
warnings.push(
|
|
1707
|
+
"Gemini CLI not in PATH. Config is written, but runtime verification is partial."
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
return {
|
|
1711
|
+
ok: checks.filter((check) => check.name !== "Gemini CLI found").every((check) => check.passed),
|
|
1712
|
+
checks,
|
|
1713
|
+
restartRequired: true,
|
|
1714
|
+
warnings
|
|
1715
|
+
};
|
|
1716
|
+
},
|
|
1717
|
+
bridgeMode() {
|
|
1718
|
+
return "polling";
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
// src/adapters/index.ts
|
|
1723
|
+
var adapters = {
|
|
1724
|
+
claude: claudeAdapter,
|
|
1725
|
+
codex: codexAdapter,
|
|
1726
|
+
gemini: geminiAdapter
|
|
1727
|
+
};
|
|
1728
|
+
function getAdapter(runtime) {
|
|
1729
|
+
const adapter = adapters[runtime];
|
|
1730
|
+
if (!adapter) {
|
|
1731
|
+
throw new Error(
|
|
1732
|
+
`Adapter for "${runtime}" is not yet available. Supported: ${Object.keys(adapters).join(", ")}`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
return adapter;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// src/engine/bridge.ts
|
|
1739
|
+
import * as fs12 from "fs";
|
|
1740
|
+
import * as path12 from "path";
|
|
1741
|
+
import { spawn, execSync as execSync3 } from "child_process";
|
|
1742
|
+
|
|
1743
|
+
// src/runtime/resolve-node.ts
|
|
1744
|
+
import * as fs11 from "fs";
|
|
1745
|
+
import * as path11 from "path";
|
|
1746
|
+
import { execSync as execSync2 } from "child_process";
|
|
1747
|
+
function readNodeVersion(repoRoot) {
|
|
1748
|
+
const nvFile = path11.join(repoRoot, ".node-version");
|
|
1749
|
+
if (!fs11.existsSync(nvFile)) return null;
|
|
1750
|
+
try {
|
|
1751
|
+
const raw = fs11.readFileSync(nvFile, "utf-8").trim();
|
|
1752
|
+
return raw.length > 0 ? raw.replace(/^v/, "") : null;
|
|
1753
|
+
} catch {
|
|
1754
|
+
return null;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function fnmCandidateDirs() {
|
|
1758
|
+
if (process.platform === "win32") {
|
|
1759
|
+
return [
|
|
1760
|
+
process.env.FNM_DIR,
|
|
1761
|
+
process.env.APPDATA ? path11.join(process.env.APPDATA, "fnm") : null,
|
|
1762
|
+
process.env.LOCALAPPDATA ? path11.join(process.env.LOCALAPPDATA, "fnm") : null,
|
|
1763
|
+
process.env.USERPROFILE ? path11.join(process.env.USERPROFILE, "scoop", "persist", "fnm") : null
|
|
1764
|
+
].filter(Boolean);
|
|
1765
|
+
}
|
|
1766
|
+
return [
|
|
1767
|
+
process.env.FNM_DIR,
|
|
1768
|
+
process.env.HOME ? path11.join(process.env.HOME, ".local", "share", "fnm") : null,
|
|
1769
|
+
process.env.HOME ? path11.join(process.env.HOME, ".fnm") : null,
|
|
1770
|
+
process.env.XDG_DATA_HOME ? path11.join(process.env.XDG_DATA_HOME, "fnm") : null
|
|
1771
|
+
].filter(Boolean);
|
|
1772
|
+
}
|
|
1773
|
+
function nodeExecutableName() {
|
|
1774
|
+
return process.platform === "win32" ? "node.exe" : "node";
|
|
1775
|
+
}
|
|
1776
|
+
function probeFnmNode(desiredVersion) {
|
|
1777
|
+
const dirs = fnmCandidateDirs();
|
|
1778
|
+
const exe = nodeExecutableName();
|
|
1779
|
+
for (const baseDir of dirs) {
|
|
1780
|
+
const candidate = path11.join(
|
|
1781
|
+
baseDir,
|
|
1782
|
+
"node-versions",
|
|
1783
|
+
`v${desiredVersion}`,
|
|
1784
|
+
"installation",
|
|
1785
|
+
exe
|
|
1786
|
+
);
|
|
1787
|
+
if (!fs11.existsSync(candidate)) continue;
|
|
1788
|
+
try {
|
|
1789
|
+
const v = execSync2(`"${candidate}" --version`, {
|
|
1790
|
+
encoding: "utf-8",
|
|
1791
|
+
timeout: 5e3
|
|
1792
|
+
}).trim();
|
|
1793
|
+
if (v.startsWith(`v${desiredVersion.split(".")[0]}.`)) {
|
|
1794
|
+
return candidate;
|
|
1795
|
+
}
|
|
1796
|
+
} catch {
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return null;
|
|
1800
|
+
}
|
|
1801
|
+
function detectNodeMajorVersion(command) {
|
|
1802
|
+
try {
|
|
1803
|
+
const version2 = execSync2(`"${command}" --version`, {
|
|
1804
|
+
encoding: "utf-8",
|
|
1805
|
+
timeout: 5e3
|
|
1806
|
+
}).trim();
|
|
1807
|
+
const match = version2.match(/^v?(\d+)\./);
|
|
1808
|
+
return match ? parseInt(match[1], 10) : null;
|
|
1809
|
+
} catch {
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
function checkStripTypesSupport(command) {
|
|
1814
|
+
const major = detectNodeMajorVersion(command);
|
|
1815
|
+
if (major !== null && major >= 22) return true;
|
|
1816
|
+
try {
|
|
1817
|
+
execSync2(`"${command}" --experimental-strip-types -e ""`, {
|
|
1818
|
+
timeout: 5e3,
|
|
1819
|
+
stdio: "pipe"
|
|
1820
|
+
});
|
|
1821
|
+
return true;
|
|
1822
|
+
} catch {
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
function findTsxFallback(repoRoot) {
|
|
1827
|
+
const candidates = [
|
|
1828
|
+
path11.join(repoRoot, "node_modules", ".bin", "tsx.exe"),
|
|
1829
|
+
path11.join(repoRoot, "node_modules", ".bin", "tsx.CMD"),
|
|
1830
|
+
path11.join(repoRoot, "node_modules", ".bin", "tsx")
|
|
1831
|
+
];
|
|
1832
|
+
for (const c of candidates) {
|
|
1833
|
+
if (fs11.existsSync(c)) return c;
|
|
1834
|
+
}
|
|
1835
|
+
return null;
|
|
1836
|
+
}
|
|
1837
|
+
function getFnmBinDir(repoRoot) {
|
|
1838
|
+
const desiredVersion = readNodeVersion(repoRoot);
|
|
1839
|
+
if (!desiredVersion) return null;
|
|
1840
|
+
const nodePath = probeFnmNode(desiredVersion);
|
|
1841
|
+
if (!nodePath) return null;
|
|
1842
|
+
return path11.dirname(nodePath);
|
|
1843
|
+
}
|
|
1844
|
+
function resolveNodeRuntime(configCommand, repoRoot) {
|
|
1845
|
+
if (configCommand === "bun" || configCommand.endsWith("bun.exe")) {
|
|
1846
|
+
return {
|
|
1847
|
+
command: configCommand,
|
|
1848
|
+
supportsStripTypes: false,
|
|
1849
|
+
source: "bun",
|
|
1850
|
+
majorVersion: null
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
const desiredVersion = readNodeVersion(repoRoot);
|
|
1854
|
+
if (desiredVersion) {
|
|
1855
|
+
const fnmNode = probeFnmNode(desiredVersion);
|
|
1856
|
+
if (fnmNode) {
|
|
1857
|
+
const major2 = detectNodeMajorVersion(fnmNode);
|
|
1858
|
+
return {
|
|
1859
|
+
command: fnmNode,
|
|
1860
|
+
supportsStripTypes: checkStripTypesSupport(fnmNode),
|
|
1861
|
+
source: "fnm",
|
|
1862
|
+
majorVersion: major2
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
const major = detectNodeMajorVersion(configCommand);
|
|
1867
|
+
if (major !== null) {
|
|
1868
|
+
return {
|
|
1869
|
+
command: configCommand,
|
|
1870
|
+
supportsStripTypes: checkStripTypesSupport(configCommand),
|
|
1871
|
+
source: major === detectNodeMajorVersion("node") ? "path" : "config",
|
|
1872
|
+
majorVersion: major
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const tsx = findTsxFallback(repoRoot);
|
|
1876
|
+
if (tsx) {
|
|
1877
|
+
return {
|
|
1878
|
+
command: tsx,
|
|
1879
|
+
supportsStripTypes: false,
|
|
1880
|
+
source: "tsx-fallback",
|
|
1881
|
+
majorVersion: null
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
command: configCommand,
|
|
1886
|
+
supportsStripTypes: false,
|
|
1887
|
+
source: "path",
|
|
1888
|
+
majorVersion: null
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
function buildRuntimeEnv(repoRoot, baseEnv = process.env) {
|
|
1892
|
+
const fnmBin = getFnmBinDir(repoRoot);
|
|
1893
|
+
if (!fnmBin) return { ...baseEnv };
|
|
1894
|
+
const pathKey = process.platform === "win32" ? "Path" : "PATH";
|
|
1895
|
+
const currentPath = baseEnv[pathKey] ?? baseEnv.PATH ?? "";
|
|
1896
|
+
return {
|
|
1897
|
+
...baseEnv,
|
|
1898
|
+
[pathKey]: `${fnmBin}${path11.delimiter}${currentPath}`
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/engine/bridge.ts
|
|
1903
|
+
function pidFilePath(stateDir, instanceId) {
|
|
1904
|
+
return path12.join(stateDir, "pids", `bridge-${instanceId}.json`);
|
|
1905
|
+
}
|
|
1906
|
+
function logFilePath(stateDir, instanceId) {
|
|
1907
|
+
return path12.join(stateDir, "logs", `bridge-${instanceId}.log`);
|
|
1908
|
+
}
|
|
1909
|
+
function loadBridgeState(stateDir, instanceId) {
|
|
1910
|
+
const pidPath = pidFilePath(stateDir, instanceId);
|
|
1911
|
+
if (!fs12.existsSync(pidPath)) return null;
|
|
1912
|
+
try {
|
|
1913
|
+
const raw = fs12.readFileSync(pidPath, "utf-8");
|
|
1914
|
+
return JSON.parse(raw);
|
|
1915
|
+
} catch {
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
function saveBridgeState(stateDir, instanceId, state) {
|
|
1920
|
+
const pidPath = pidFilePath(stateDir, instanceId);
|
|
1921
|
+
fs12.mkdirSync(path12.dirname(pidPath), { recursive: true });
|
|
1922
|
+
const tmp = `${pidPath}.tmp.${process.pid}`;
|
|
1923
|
+
fs12.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
1924
|
+
fs12.renameSync(tmp, pidPath);
|
|
1925
|
+
}
|
|
1926
|
+
function clearBridgeState(stateDir, instanceId) {
|
|
1927
|
+
const pidPath = pidFilePath(stateDir, instanceId);
|
|
1928
|
+
if (fs12.existsSync(pidPath)) {
|
|
1929
|
+
fs12.unlinkSync(pidPath);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function isProcessAlive(pid) {
|
|
1933
|
+
try {
|
|
1934
|
+
process.kill(pid, 0);
|
|
1935
|
+
return true;
|
|
1936
|
+
} catch {
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function isBridgeRunning(stateDir, instanceId) {
|
|
1941
|
+
const state = loadBridgeState(stateDir, instanceId);
|
|
1942
|
+
if (!state) return false;
|
|
1943
|
+
return isProcessAlive(state.pid);
|
|
1944
|
+
}
|
|
1945
|
+
async function startBridge(options) {
|
|
1946
|
+
const {
|
|
1947
|
+
instanceId,
|
|
1948
|
+
runtime,
|
|
1949
|
+
stateDir,
|
|
1950
|
+
commsDir,
|
|
1951
|
+
bridgeScript,
|
|
1952
|
+
agentName,
|
|
1953
|
+
port
|
|
1954
|
+
} = options;
|
|
1955
|
+
const resolvedAgent = agentName || process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME;
|
|
1956
|
+
if (!resolvedAgent) {
|
|
1957
|
+
throw new Error(
|
|
1958
|
+
`No agent name for ${instanceId} bridge. Set TAP_AGENT_NAME env var or pass --agent-name flag.`
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
if (isBridgeRunning(stateDir, instanceId)) {
|
|
1962
|
+
const existing = loadBridgeState(stateDir, instanceId);
|
|
1963
|
+
throw new Error(
|
|
1964
|
+
`Bridge for ${instanceId} is already running (PID: ${existing.pid})`
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
clearBridgeState(stateDir, instanceId);
|
|
1968
|
+
const logPath = logFilePath(stateDir, instanceId);
|
|
1969
|
+
fs12.mkdirSync(path12.dirname(logPath), { recursive: true });
|
|
1970
|
+
rotateLog(logPath);
|
|
1971
|
+
const logFd = fs12.openSync(logPath, "a");
|
|
1972
|
+
const repoRoot = options.repoRoot ?? path12.resolve(stateDir, "..");
|
|
1973
|
+
const resolved = resolveNodeRuntime(
|
|
1974
|
+
options.runtimeCommand ?? "node",
|
|
1975
|
+
repoRoot
|
|
1976
|
+
);
|
|
1977
|
+
const command = resolved.command;
|
|
1978
|
+
const runtimeEnv = buildRuntimeEnv(repoRoot);
|
|
1979
|
+
const child = spawn(command, [bridgeScript], {
|
|
1980
|
+
detached: true,
|
|
1981
|
+
stdio: ["ignore", logFd, logFd],
|
|
1982
|
+
env: {
|
|
1983
|
+
...runtimeEnv,
|
|
1984
|
+
TAP_COMMS_DIR: commsDir,
|
|
1985
|
+
TAP_BRIDGE_RUNTIME: runtime,
|
|
1986
|
+
TAP_BRIDGE_INSTANCE_ID: instanceId,
|
|
1987
|
+
TAP_AGENT_NAME: resolvedAgent,
|
|
1988
|
+
CODEX_TAP_AGENT_NAME: resolvedAgent,
|
|
1989
|
+
TAP_RESOLVED_NODE: resolved.command,
|
|
1990
|
+
TAP_STRIP_TYPES: resolved.supportsStripTypes ? "1" : "0",
|
|
1991
|
+
...options.appServerUrl ? { CODEX_APP_SERVER_URL: options.appServerUrl } : {},
|
|
1992
|
+
...port != null ? { TAP_BRIDGE_PORT: String(port) } : {},
|
|
1993
|
+
...options.headless?.enabled ? {
|
|
1994
|
+
TAP_HEADLESS: "true",
|
|
1995
|
+
TAP_AGENT_ROLE: options.headless.role,
|
|
1996
|
+
TAP_MAX_REVIEW_ROUNDS: String(options.headless.maxRounds),
|
|
1997
|
+
TAP_QUALITY_FLOOR: options.headless.qualitySeverityFloor
|
|
1998
|
+
} : {},
|
|
1999
|
+
// Bridge script operational flags
|
|
2000
|
+
...options.busyMode ? { TAP_BUSY_MODE: options.busyMode } : {},
|
|
2001
|
+
...options.pollSeconds != null ? { TAP_POLL_SECONDS: String(options.pollSeconds) } : {},
|
|
2002
|
+
...options.reconnectSeconds != null ? { TAP_RECONNECT_SECONDS: String(options.reconnectSeconds) } : {},
|
|
2003
|
+
...options.messageLookbackMinutes != null ? {
|
|
2004
|
+
TAP_MESSAGE_LOOKBACK_MINUTES: String(
|
|
2005
|
+
options.messageLookbackMinutes
|
|
2006
|
+
)
|
|
2007
|
+
} : {},
|
|
2008
|
+
...options.threadId ? { TAP_THREAD_ID: options.threadId } : {},
|
|
2009
|
+
...options.ephemeral ? { TAP_EPHEMERAL: "true" } : {},
|
|
2010
|
+
...options.processExistingMessages ? { TAP_PROCESS_EXISTING: "true" } : {}
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
child.unref();
|
|
2014
|
+
fs12.closeSync(logFd);
|
|
2015
|
+
if (!child.pid) {
|
|
2016
|
+
throw new Error(`Failed to spawn bridge process for ${instanceId}`);
|
|
2017
|
+
}
|
|
2018
|
+
const state = {
|
|
2019
|
+
pid: child.pid,
|
|
2020
|
+
statePath: pidFilePath(stateDir, instanceId),
|
|
2021
|
+
lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
2022
|
+
};
|
|
2023
|
+
saveBridgeState(stateDir, instanceId, state);
|
|
2024
|
+
return state;
|
|
2025
|
+
}
|
|
2026
|
+
async function stopBridge(options) {
|
|
2027
|
+
const { instanceId, stateDir, platform } = options;
|
|
2028
|
+
const state = loadBridgeState(stateDir, instanceId);
|
|
2029
|
+
if (!state) {
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
if (!isProcessAlive(state.pid)) {
|
|
2033
|
+
clearBridgeState(stateDir, instanceId);
|
|
2034
|
+
return false;
|
|
2035
|
+
}
|
|
2036
|
+
try {
|
|
2037
|
+
if (platform === "win32") {
|
|
2038
|
+
execSync3(`taskkill /PID ${state.pid} /F /T`, { stdio: "pipe" });
|
|
2039
|
+
} else {
|
|
2040
|
+
process.kill(state.pid, "SIGTERM");
|
|
2041
|
+
await new Promise((resolve10) => setTimeout(resolve10, 2e3));
|
|
2042
|
+
if (isProcessAlive(state.pid)) {
|
|
2043
|
+
process.kill(state.pid, "SIGKILL");
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
} catch {
|
|
2047
|
+
}
|
|
2048
|
+
clearBridgeState(stateDir, instanceId);
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
function rotateLog(logPath) {
|
|
2052
|
+
if (!fs12.existsSync(logPath)) return;
|
|
2053
|
+
try {
|
|
2054
|
+
const stats = fs12.statSync(logPath);
|
|
2055
|
+
if (stats.size === 0) return;
|
|
2056
|
+
const prevPath = `${logPath}.prev`;
|
|
2057
|
+
fs12.renameSync(logPath, prevPath);
|
|
2058
|
+
} catch {
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
function getHeartbeatAge(stateDir, instanceId) {
|
|
2062
|
+
const state = loadBridgeState(stateDir, instanceId);
|
|
2063
|
+
if (!state?.lastHeartbeat) return null;
|
|
2064
|
+
const heartbeatTime = new Date(state.lastHeartbeat).getTime();
|
|
2065
|
+
if (isNaN(heartbeatTime)) return null;
|
|
2066
|
+
return Math.floor((Date.now() - heartbeatTime) / 1e3);
|
|
2067
|
+
}
|
|
2068
|
+
function getBridgeStatus(stateDir, instanceId) {
|
|
2069
|
+
const state = loadBridgeState(stateDir, instanceId);
|
|
2070
|
+
if (!state) return "stopped";
|
|
2071
|
+
if (!isProcessAlive(state.pid)) {
|
|
2072
|
+
clearBridgeState(stateDir, instanceId);
|
|
2073
|
+
return "stale";
|
|
2074
|
+
}
|
|
2075
|
+
return "running";
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/commands/add.ts
|
|
2079
|
+
async function addCommand(args) {
|
|
2080
|
+
const { positional, flags } = parseArgs(args);
|
|
2081
|
+
const runtimeArg = positional[0];
|
|
2082
|
+
if (!runtimeArg) {
|
|
2083
|
+
return {
|
|
2084
|
+
ok: false,
|
|
2085
|
+
command: "add",
|
|
2086
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2087
|
+
message: "Missing runtime argument. Usage: npx @hua-labs/tap add <claude|codex|gemini> [--name <name>] [--port <port>] [--headless] [--role <role>]",
|
|
2088
|
+
warnings: [],
|
|
2089
|
+
data: {}
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
if (!isValidRuntime(runtimeArg)) {
|
|
2093
|
+
return {
|
|
2094
|
+
ok: false,
|
|
2095
|
+
command: "add",
|
|
2096
|
+
code: "TAP_RUNTIME_UNKNOWN",
|
|
2097
|
+
message: `Unknown runtime: ${runtimeArg}. Available: claude, codex, gemini`,
|
|
2098
|
+
warnings: [],
|
|
2099
|
+
data: {}
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
const runtime = runtimeArg;
|
|
2103
|
+
const instanceName = typeof flags["name"] === "string" ? flags["name"] : void 0;
|
|
2104
|
+
const instanceId = buildInstanceId(runtime, instanceName);
|
|
2105
|
+
const portStr = typeof flags["port"] === "string" ? flags["port"] : void 0;
|
|
2106
|
+
const port = portStr ? parseInt(portStr, 10) : null;
|
|
2107
|
+
const force = flags["force"] === true;
|
|
2108
|
+
const headlessFlag = flags["headless"] === true;
|
|
2109
|
+
const roleArg = typeof flags["role"] === "string" ? flags["role"] : void 0;
|
|
2110
|
+
const validRoles = ["reviewer", "validator", "long-running"];
|
|
2111
|
+
if (roleArg && !validRoles.includes(roleArg)) {
|
|
2112
|
+
return {
|
|
2113
|
+
ok: false,
|
|
2114
|
+
command: "add",
|
|
2115
|
+
runtime,
|
|
2116
|
+
instanceId,
|
|
2117
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2118
|
+
message: `Invalid role: ${roleArg}. Available: ${validRoles.join(", ")}`,
|
|
2119
|
+
warnings: [],
|
|
2120
|
+
data: {}
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
if (headlessFlag && !instanceName) {
|
|
2124
|
+
return {
|
|
2125
|
+
ok: false,
|
|
2126
|
+
command: "add",
|
|
2127
|
+
runtime,
|
|
2128
|
+
instanceId,
|
|
2129
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2130
|
+
message: "--headless requires --name for instance isolation",
|
|
2131
|
+
warnings: [],
|
|
2132
|
+
data: {}
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
const headless = headlessFlag ? {
|
|
2136
|
+
enabled: true,
|
|
2137
|
+
role: roleArg ?? "reviewer",
|
|
2138
|
+
maxRounds: 5,
|
|
2139
|
+
qualitySeverityFloor: "high"
|
|
2140
|
+
} : null;
|
|
2141
|
+
if (portStr && (port === null || isNaN(port))) {
|
|
2142
|
+
return {
|
|
2143
|
+
ok: false,
|
|
2144
|
+
command: "add",
|
|
2145
|
+
runtime,
|
|
2146
|
+
instanceId,
|
|
2147
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2148
|
+
message: `Invalid port: ${portStr}`,
|
|
2149
|
+
warnings: [],
|
|
2150
|
+
data: {}
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
const repoRoot = findRepoRoot2();
|
|
2154
|
+
const state = loadState(repoRoot);
|
|
2155
|
+
if (!state) {
|
|
2156
|
+
return {
|
|
2157
|
+
ok: false,
|
|
2158
|
+
command: "add",
|
|
2159
|
+
runtime,
|
|
2160
|
+
instanceId,
|
|
2161
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2162
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2163
|
+
warnings: [],
|
|
2164
|
+
data: {}
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
if (state.instances[instanceId]?.installed && !force) {
|
|
2168
|
+
return {
|
|
2169
|
+
ok: true,
|
|
2170
|
+
command: "add",
|
|
2171
|
+
runtime,
|
|
2172
|
+
instanceId,
|
|
2173
|
+
code: "TAP_NO_OP",
|
|
2174
|
+
message: `${instanceId} is already installed. Use --force to re-install.`,
|
|
2175
|
+
warnings: [],
|
|
2176
|
+
data: {}
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
if (port !== null) {
|
|
2180
|
+
const conflict = findPortConflict(state, port, instanceId);
|
|
2181
|
+
if (conflict) {
|
|
2182
|
+
return {
|
|
2183
|
+
ok: false,
|
|
2184
|
+
command: "add",
|
|
2185
|
+
runtime,
|
|
2186
|
+
instanceId,
|
|
2187
|
+
code: "TAP_PORT_CONFLICT",
|
|
2188
|
+
message: `Port ${port} is already used by instance "${conflict}".`,
|
|
2189
|
+
warnings: [],
|
|
2190
|
+
data: { conflictingInstance: conflict }
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
logHeader(`@hua-labs/tap add ${instanceId}`);
|
|
2195
|
+
if (instanceName) log(`Instance name: ${instanceName}`);
|
|
2196
|
+
if (port !== null) log(`Port: ${port}`);
|
|
2197
|
+
const ctx = createAdapterContext(state.commsDir, repoRoot);
|
|
2198
|
+
const adapter = getAdapter(runtime);
|
|
2199
|
+
const warnings = [];
|
|
2200
|
+
log("Probing runtime...");
|
|
2201
|
+
const probe = await adapter.probe(ctx);
|
|
2202
|
+
if (!probe.installed) {
|
|
2203
|
+
return {
|
|
2204
|
+
ok: false,
|
|
2205
|
+
command: "add",
|
|
2206
|
+
runtime,
|
|
2207
|
+
instanceId,
|
|
2208
|
+
code: "TAP_RUNTIME_NOT_FOUND",
|
|
2209
|
+
message: `${runtime} runtime not found.`,
|
|
2210
|
+
warnings: probe.warnings,
|
|
2211
|
+
data: { issues: probe.issues }
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
logSuccess(`Found ${runtime} (${probe.runtimeCommand ?? "unknown"})`);
|
|
2215
|
+
if (probe.configPath) log(`Config: ${probe.configPath}`);
|
|
2216
|
+
warnings.push(...probe.warnings);
|
|
2217
|
+
for (const w of probe.warnings) logWarn(w);
|
|
2218
|
+
log("Planning patches...");
|
|
2219
|
+
const plan = await adapter.plan(ctx, probe);
|
|
2220
|
+
warnings.push(...plan.warnings);
|
|
2221
|
+
if (plan.conflicts.length > 0) {
|
|
2222
|
+
logWarn("Conflicts detected:");
|
|
2223
|
+
for (const c of plan.conflicts) logWarn(` ${c}`);
|
|
2224
|
+
}
|
|
2225
|
+
log(`Operations: ${plan.operations.length}`);
|
|
2226
|
+
log(`Artifacts: ${plan.ownedArtifacts.length}`);
|
|
2227
|
+
for (const w of plan.warnings) logWarn(w);
|
|
2228
|
+
if (plan.operations.length === 0) {
|
|
2229
|
+
return {
|
|
2230
|
+
ok: true,
|
|
2231
|
+
command: "add",
|
|
2232
|
+
runtime,
|
|
2233
|
+
instanceId,
|
|
2234
|
+
code: "TAP_NO_OP",
|
|
2235
|
+
message: "No operations to apply. Runtime not configured.",
|
|
2236
|
+
warnings,
|
|
2237
|
+
data: { planOps: 0 }
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
const backupDir = ensureBackupDir(ctx.stateDir, instanceId);
|
|
2241
|
+
log(`Backup dir: ${backupDir}`);
|
|
2242
|
+
log("Applying patches...");
|
|
2243
|
+
const result = await adapter.apply(ctx, plan);
|
|
2244
|
+
warnings.push(...result.warnings);
|
|
2245
|
+
if (!result.success) {
|
|
2246
|
+
return {
|
|
2247
|
+
ok: false,
|
|
2248
|
+
command: "add",
|
|
2249
|
+
runtime,
|
|
2250
|
+
instanceId,
|
|
2251
|
+
code: "TAP_PATCH_FAILED",
|
|
2252
|
+
message: "Failed to apply patches.",
|
|
2253
|
+
warnings,
|
|
2254
|
+
data: { appliedOps: result.appliedOps }
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
logSuccess(`Applied ${result.appliedOps} operation(s)`);
|
|
2258
|
+
for (const f of result.changedFiles) logSuccess(`Modified: ${f}`);
|
|
2259
|
+
for (const w of result.warnings) logWarn(w);
|
|
2260
|
+
log("Verifying...");
|
|
2261
|
+
const verify = await adapter.verify(ctx, plan);
|
|
2262
|
+
warnings.push(...verify.warnings);
|
|
2263
|
+
for (const check of verify.checks) {
|
|
2264
|
+
if (check.passed) {
|
|
2265
|
+
logSuccess(`${check.name}`);
|
|
2266
|
+
} else {
|
|
2267
|
+
logError(`${check.name}: ${check.message ?? "failed"}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
if (!verify.ok) {
|
|
2271
|
+
logWarn(
|
|
2272
|
+
"Verification had failures. Runtime may need manual configuration."
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
let bridge = null;
|
|
2276
|
+
const mode = adapter.bridgeMode();
|
|
2277
|
+
if (mode === "app-server") {
|
|
2278
|
+
const bridgeScript = adapter.resolveBridgeScript?.(ctx);
|
|
2279
|
+
if (!bridgeScript) {
|
|
2280
|
+
logWarn("Bridge script not found. Bridge not started.");
|
|
2281
|
+
warnings.push("Bridge script not found. Run bridge manually.");
|
|
2282
|
+
} else {
|
|
2283
|
+
const agentNameEnv = process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME;
|
|
2284
|
+
if (!agentNameEnv) {
|
|
2285
|
+
logWarn(
|
|
2286
|
+
"No agent name set (TAP_AGENT_NAME). Bridge not started. Use: npx @hua-labs/tap bridge start <instance> --agent-name <name>"
|
|
2287
|
+
);
|
|
2288
|
+
warnings.push("Bridge not auto-started: no agent name available.");
|
|
2289
|
+
} else {
|
|
2290
|
+
const { config: resolvedCfg } = resolveConfig({}, repoRoot);
|
|
2291
|
+
log(`Starting bridge: ${bridgeScript}`);
|
|
2292
|
+
try {
|
|
2293
|
+
bridge = await startBridge({
|
|
2294
|
+
instanceId,
|
|
2295
|
+
runtime,
|
|
2296
|
+
stateDir: ctx.stateDir,
|
|
2297
|
+
commsDir: ctx.commsDir,
|
|
2298
|
+
bridgeScript,
|
|
2299
|
+
platform: ctx.platform,
|
|
2300
|
+
agentName: agentNameEnv,
|
|
2301
|
+
runtimeCommand: resolvedCfg.runtimeCommand,
|
|
2302
|
+
appServerUrl: resolvedCfg.appServerUrl,
|
|
2303
|
+
repoRoot,
|
|
2304
|
+
port: port ?? void 0,
|
|
2305
|
+
headless
|
|
2306
|
+
});
|
|
2307
|
+
logSuccess(`Bridge started (PID: ${bridge.pid})`);
|
|
2308
|
+
} catch (err) {
|
|
2309
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2310
|
+
logWarn(`Bridge not started: ${msg}`);
|
|
2311
|
+
warnings.push(`Bridge not started: ${msg}`);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
const instanceState = {
|
|
2317
|
+
instanceId,
|
|
2318
|
+
runtime,
|
|
2319
|
+
agentName: null,
|
|
2320
|
+
port,
|
|
2321
|
+
installed: true,
|
|
2322
|
+
configPath: probe.configPath ?? "",
|
|
2323
|
+
bridgeMode: mode,
|
|
2324
|
+
restartRequired: result.restartRequired,
|
|
2325
|
+
ownedArtifacts: result.ownedArtifacts,
|
|
2326
|
+
backupPath: backupDir,
|
|
2327
|
+
lastAppliedHash: result.lastAppliedHash,
|
|
2328
|
+
lastVerifiedAt: verify.ok ? (/* @__PURE__ */ new Date()).toISOString() : null,
|
|
2329
|
+
bridge,
|
|
2330
|
+
headless,
|
|
2331
|
+
warnings: [...result.warnings, ...verify.warnings]
|
|
2332
|
+
};
|
|
2333
|
+
const newState = updateInstanceState(state, instanceId, instanceState);
|
|
2334
|
+
saveState(repoRoot, newState);
|
|
2335
|
+
logSuccess("State saved");
|
|
2336
|
+
if (result.restartRequired) {
|
|
2337
|
+
logWarn(`Restart ${runtime} to pick up the new configuration.`);
|
|
2338
|
+
}
|
|
2339
|
+
logHeader("Done!");
|
|
2340
|
+
return {
|
|
2341
|
+
ok: true,
|
|
2342
|
+
command: "add",
|
|
2343
|
+
runtime,
|
|
2344
|
+
instanceId,
|
|
2345
|
+
code: "TAP_ADD_OK",
|
|
2346
|
+
message: `${instanceId} configured`,
|
|
2347
|
+
warnings,
|
|
2348
|
+
data: {
|
|
2349
|
+
appliedOps: result.appliedOps,
|
|
2350
|
+
restartRequired: result.restartRequired,
|
|
2351
|
+
changedFiles: result.changedFiles,
|
|
2352
|
+
verified: verify.ok
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// src/commands/status.ts
|
|
2358
|
+
function resolveStatus(inst, stateDir) {
|
|
2359
|
+
if (!inst.installed) return "not installed";
|
|
2360
|
+
switch (inst.bridgeMode) {
|
|
2361
|
+
case "native-push":
|
|
2362
|
+
case "polling":
|
|
2363
|
+
return inst.lastVerifiedAt ? "active" : "configured";
|
|
2364
|
+
case "app-server":
|
|
2365
|
+
if (inst.bridge && isBridgeRunning(stateDir, inst.instanceId)) {
|
|
2366
|
+
return "active";
|
|
2367
|
+
}
|
|
2368
|
+
if (inst.bridge) {
|
|
2369
|
+
inst.bridge = null;
|
|
2370
|
+
}
|
|
2371
|
+
return inst.lastVerifiedAt ? "configured" : "installed";
|
|
2372
|
+
default:
|
|
2373
|
+
return "installed";
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
function instanceStatusLine(inst, status) {
|
|
2377
|
+
const bridgeInfo = inst.bridge ? ` (pid: ${inst.bridge.pid})` : "";
|
|
2378
|
+
const mode = inst.bridgeMode;
|
|
2379
|
+
const portStr = inst.port ? ` port:${inst.port}` : "";
|
|
2380
|
+
const restart = inst.restartRequired ? " [restart required]" : "";
|
|
2381
|
+
const warns = inst.warnings.length > 0 ? ` [${inst.warnings.length} warning(s)]` : "";
|
|
2382
|
+
return `${inst.instanceId.padEnd(20)} ${inst.runtime.padEnd(8)} ${status.padEnd(14)} ${mode.padEnd(14)}${bridgeInfo}${portStr}${restart}${warns}`;
|
|
2383
|
+
}
|
|
2384
|
+
async function statusCommand(_args) {
|
|
2385
|
+
const repoRoot = findRepoRoot2();
|
|
2386
|
+
const state = loadState(repoRoot);
|
|
2387
|
+
if (!state) {
|
|
2388
|
+
return {
|
|
2389
|
+
ok: false,
|
|
2390
|
+
command: "status",
|
|
2391
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2392
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2393
|
+
warnings: [],
|
|
2394
|
+
data: {}
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
logHeader("@hua-labs/tap status");
|
|
2398
|
+
log(`Version: ${state.packageVersion}`);
|
|
2399
|
+
log(`Comms dir: ${state.commsDir}`);
|
|
2400
|
+
log(`Repo root: ${state.repoRoot}`);
|
|
2401
|
+
log(`Schema: v${state.schemaVersion}`);
|
|
2402
|
+
log(`Updated: ${state.updatedAt}`);
|
|
2403
|
+
const installed = getInstalledInstances(state);
|
|
2404
|
+
const { config: resolvedCfg } = resolveConfig({}, repoRoot);
|
|
2405
|
+
const stateDir = resolvedCfg.stateDir;
|
|
2406
|
+
const instances = {};
|
|
2407
|
+
const bridgesBefore = installed.map((id) => state.instances[id]?.bridge);
|
|
2408
|
+
if (installed.length === 0) {
|
|
2409
|
+
log("");
|
|
2410
|
+
log("No instances installed.");
|
|
2411
|
+
log("Run: npx @hua-labs/tap add <claude|codex|gemini>");
|
|
2412
|
+
} else {
|
|
2413
|
+
log("");
|
|
2414
|
+
log(
|
|
2415
|
+
`${"Instance".padEnd(20)} ${"Runtime".padEnd(8)} ${"Status".padEnd(14)} ${"Bridge Mode".padEnd(14)} Details`
|
|
2416
|
+
);
|
|
2417
|
+
log(
|
|
2418
|
+
`${"\u2500".repeat(20)} ${"\u2500".repeat(8)} ${"\u2500".repeat(14)} ${"\u2500".repeat(14)} ${"\u2500".repeat(20)}`
|
|
2419
|
+
);
|
|
2420
|
+
for (const id of installed) {
|
|
2421
|
+
const inst = state.instances[id];
|
|
2422
|
+
if (inst) {
|
|
2423
|
+
const status = resolveStatus(inst, stateDir);
|
|
2424
|
+
log(instanceStatusLine(inst, status));
|
|
2425
|
+
if (inst.warnings.length > 0) {
|
|
2426
|
+
for (const w of inst.warnings) {
|
|
2427
|
+
logWarn(` ${w}`);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
instances[id] = {
|
|
2431
|
+
status,
|
|
2432
|
+
runtime: inst.runtime,
|
|
2433
|
+
bridgeMode: inst.bridgeMode,
|
|
2434
|
+
bridge: inst.bridge,
|
|
2435
|
+
port: inst.port,
|
|
2436
|
+
warnings: inst.warnings
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
const bridgesAfter = installed.map((id) => state.instances[id]?.bridge);
|
|
2442
|
+
const staleCleared = bridgesBefore.some((b, i) => b !== bridgesAfter[i]);
|
|
2443
|
+
if (staleCleared) {
|
|
2444
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2445
|
+
saveState(repoRoot, state);
|
|
2446
|
+
}
|
|
2447
|
+
log("");
|
|
2448
|
+
return {
|
|
2449
|
+
ok: true,
|
|
2450
|
+
command: "status",
|
|
2451
|
+
code: "TAP_STATUS_OK",
|
|
2452
|
+
message: `${installed.length} instance(s) installed`,
|
|
2453
|
+
warnings: [],
|
|
2454
|
+
data: {
|
|
2455
|
+
version: state.packageVersion,
|
|
2456
|
+
commsDir: state.commsDir,
|
|
2457
|
+
repoRoot: state.repoRoot,
|
|
2458
|
+
instances
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/engine/rollback.ts
|
|
2464
|
+
import * as fs13 from "fs";
|
|
2465
|
+
async function rollbackRuntime(_instanceId, runtimeState) {
|
|
2466
|
+
const errors = [];
|
|
2467
|
+
const restoredFiles = [];
|
|
2468
|
+
let restoredCount = 0;
|
|
2469
|
+
for (const artifact of runtimeState.ownedArtifacts) {
|
|
2470
|
+
try {
|
|
2471
|
+
const result = rollbackArtifact(artifact);
|
|
2472
|
+
if (result.restored) {
|
|
2473
|
+
restoredCount++;
|
|
2474
|
+
restoredFiles.push(artifact.path);
|
|
2475
|
+
}
|
|
2476
|
+
if (result.error) {
|
|
2477
|
+
errors.push(result.error);
|
|
2478
|
+
}
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
errors.push(
|
|
2481
|
+
`Failed to rollback ${artifact.path}#${artifact.selector}: ${err instanceof Error ? err.message : String(err)}`
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
return {
|
|
2486
|
+
success: errors.length === 0,
|
|
2487
|
+
restoredCount,
|
|
2488
|
+
restoredFiles,
|
|
2489
|
+
errors
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
function rollbackArtifact(artifact) {
|
|
2493
|
+
if (!fs13.existsSync(artifact.path)) {
|
|
2494
|
+
return { restored: false, error: `File not found: ${artifact.path}` };
|
|
2495
|
+
}
|
|
2496
|
+
switch (artifact.kind) {
|
|
2497
|
+
case "json-path":
|
|
2498
|
+
return rollbackJsonPath(artifact);
|
|
2499
|
+
case "toml-table":
|
|
2500
|
+
return rollbackTomlTable(artifact);
|
|
2501
|
+
case "file":
|
|
2502
|
+
return rollbackFile(artifact);
|
|
2503
|
+
default:
|
|
2504
|
+
return {
|
|
2505
|
+
restored: false,
|
|
2506
|
+
error: `Unknown artifact kind: ${artifact.kind}`
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
function rollbackJsonPath(artifact) {
|
|
2511
|
+
const raw = fs13.readFileSync(artifact.path, "utf-8");
|
|
2512
|
+
let config;
|
|
2513
|
+
try {
|
|
2514
|
+
config = JSON.parse(raw);
|
|
2515
|
+
} catch {
|
|
2516
|
+
return { restored: false, error: `Invalid JSON: ${artifact.path}` };
|
|
2517
|
+
}
|
|
2518
|
+
const backup = artifact.backupPath ? readArtifactBackup(artifact.backupPath) : null;
|
|
2519
|
+
if (backup?.kind === "json-path" && backup.selector === artifact.selector) {
|
|
2520
|
+
if (backup.existed) {
|
|
2521
|
+
setNestedKey3(config, artifact.selector, backup.value);
|
|
2522
|
+
} else {
|
|
2523
|
+
deleteNestedKey(config, artifact.selector);
|
|
2524
|
+
cleanEmptyParents(config, artifact.selector);
|
|
2525
|
+
}
|
|
2526
|
+
} else {
|
|
2527
|
+
const removed = deleteNestedKey(config, artifact.selector);
|
|
2528
|
+
if (!removed) {
|
|
2529
|
+
return {
|
|
2530
|
+
restored: false,
|
|
2531
|
+
error: `Key not found: ${artifact.selector} in ${artifact.path}`
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
cleanEmptyParents(config, artifact.selector);
|
|
2535
|
+
}
|
|
2536
|
+
const tmp = `${artifact.path}.tmp.${process.pid}`;
|
|
2537
|
+
fs13.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2538
|
+
fs13.renameSync(tmp, artifact.path);
|
|
2539
|
+
return { restored: true };
|
|
2540
|
+
}
|
|
2541
|
+
function rollbackTomlTable(artifact) {
|
|
2542
|
+
const content = fs13.readFileSync(artifact.path, "utf-8");
|
|
2543
|
+
const backup = artifact.backupPath ? readArtifactBackup(artifact.backupPath) : null;
|
|
2544
|
+
if (backup?.kind === "toml-table" && backup.selector === artifact.selector) {
|
|
2545
|
+
const nextContent = backup.existed ? replaceTomlTable(content, artifact.selector, backup.content ?? "") : removeTomlTable(content, artifact.selector);
|
|
2546
|
+
const tmp2 = `${artifact.path}.tmp.${process.pid}`;
|
|
2547
|
+
fs13.writeFileSync(tmp2, nextContent, "utf-8");
|
|
2548
|
+
fs13.renameSync(tmp2, artifact.path);
|
|
2549
|
+
return { restored: true };
|
|
2550
|
+
}
|
|
2551
|
+
if (!extractTomlTable(content, artifact.selector)) {
|
|
2552
|
+
return {
|
|
2553
|
+
restored: false,
|
|
2554
|
+
error: `TOML table not found: ${artifact.selector}`
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
const tmp = `${artifact.path}.tmp.${process.pid}`;
|
|
2558
|
+
fs13.writeFileSync(tmp, removeTomlTable(content, artifact.selector), "utf-8");
|
|
2559
|
+
fs13.renameSync(tmp, artifact.path);
|
|
2560
|
+
return { restored: true };
|
|
2561
|
+
}
|
|
2562
|
+
function rollbackFile(artifact) {
|
|
2563
|
+
if (fs13.existsSync(artifact.path)) {
|
|
2564
|
+
fs13.unlinkSync(artifact.path);
|
|
2565
|
+
return { restored: true };
|
|
2566
|
+
}
|
|
2567
|
+
return { restored: false, error: `File not found: ${artifact.path}` };
|
|
2568
|
+
}
|
|
2569
|
+
function deleteNestedKey(obj, keyPath) {
|
|
2570
|
+
const keys = keyPath.split(".");
|
|
2571
|
+
let current = obj;
|
|
2572
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
2573
|
+
const key = keys[i];
|
|
2574
|
+
if (typeof current[key] !== "object" || current[key] === null) {
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
2577
|
+
current = current[key];
|
|
2578
|
+
}
|
|
2579
|
+
const lastKey = keys[keys.length - 1];
|
|
2580
|
+
if (!(lastKey in current)) return false;
|
|
2581
|
+
delete current[lastKey];
|
|
2582
|
+
return true;
|
|
2583
|
+
}
|
|
2584
|
+
function setNestedKey3(obj, keyPath, value) {
|
|
2585
|
+
const keys = keyPath.split(".");
|
|
2586
|
+
let current = obj;
|
|
2587
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
2588
|
+
const key = keys[i];
|
|
2589
|
+
if (typeof current[key] !== "object" || current[key] === null) {
|
|
2590
|
+
current[key] = {};
|
|
2591
|
+
}
|
|
2592
|
+
current = current[key];
|
|
2593
|
+
}
|
|
2594
|
+
current[keys[keys.length - 1]] = value;
|
|
2595
|
+
}
|
|
2596
|
+
function cleanEmptyParents(obj, keyPath) {
|
|
2597
|
+
const keys = keyPath.split(".");
|
|
2598
|
+
for (let depth = keys.length - 2; depth >= 0; depth--) {
|
|
2599
|
+
let current = obj;
|
|
2600
|
+
for (let i = 0; i < depth; i++) {
|
|
2601
|
+
current = current[keys[i]];
|
|
2602
|
+
if (!current) return;
|
|
2603
|
+
}
|
|
2604
|
+
const key = keys[depth];
|
|
2605
|
+
const value = current[key];
|
|
2606
|
+
if (typeof value === "object" && value !== null && Object.keys(value).length === 0) {
|
|
2607
|
+
delete current[key];
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// src/commands/remove.ts
|
|
2613
|
+
async function removeCommand(args) {
|
|
2614
|
+
const identifier = args.find((a) => !a.startsWith("-"));
|
|
2615
|
+
if (!identifier) {
|
|
2616
|
+
return {
|
|
2617
|
+
ok: false,
|
|
2618
|
+
command: "remove",
|
|
2619
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2620
|
+
message: "Missing instance argument. Usage: npx @hua-labs/tap remove <instance>",
|
|
2621
|
+
warnings: [],
|
|
2622
|
+
data: {}
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
const repoRoot = findRepoRoot2();
|
|
2626
|
+
const state = loadState(repoRoot);
|
|
2627
|
+
if (!state) {
|
|
2628
|
+
return {
|
|
2629
|
+
ok: false,
|
|
2630
|
+
command: "remove",
|
|
2631
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2632
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2633
|
+
warnings: [],
|
|
2634
|
+
data: {}
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
const resolved = resolveInstanceId(identifier, state);
|
|
2638
|
+
if (!resolved.ok) {
|
|
2639
|
+
return {
|
|
2640
|
+
ok: false,
|
|
2641
|
+
command: "remove",
|
|
2642
|
+
code: resolved.code,
|
|
2643
|
+
message: resolved.message,
|
|
2644
|
+
warnings: [],
|
|
2645
|
+
data: {}
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
const instanceId = resolved.instanceId;
|
|
2649
|
+
const instance = state.instances[instanceId];
|
|
2650
|
+
if (!instance?.installed) {
|
|
2651
|
+
return {
|
|
2652
|
+
ok: true,
|
|
2653
|
+
command: "remove",
|
|
2654
|
+
instanceId,
|
|
2655
|
+
code: "TAP_NO_OP",
|
|
2656
|
+
message: `${instanceId} is not installed.`,
|
|
2657
|
+
warnings: [],
|
|
2658
|
+
data: {}
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
logHeader(`@hua-labs/tap remove ${instanceId}`);
|
|
2662
|
+
if (instance.bridge) {
|
|
2663
|
+
const ctx = createAdapterContext(state.commsDir, repoRoot);
|
|
2664
|
+
const stopped = await stopBridge({
|
|
2665
|
+
instanceId,
|
|
2666
|
+
stateDir: ctx.stateDir,
|
|
2667
|
+
platform: ctx.platform
|
|
2668
|
+
});
|
|
2669
|
+
if (stopped) {
|
|
2670
|
+
logSuccess(`Bridge for ${instanceId} stopped`);
|
|
2671
|
+
} else {
|
|
2672
|
+
log(`No running bridge for ${instanceId}`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const result = await rollbackRuntime(instanceId, instance);
|
|
2676
|
+
if (result.success) {
|
|
2677
|
+
logSuccess(`Rolled back ${result.restoredCount} artifact(s)`);
|
|
2678
|
+
for (const f of result.restoredFiles) logSuccess(`Restored: ${f}`);
|
|
2679
|
+
const newState = removeInstanceState(state, instanceId);
|
|
2680
|
+
saveState(repoRoot, newState);
|
|
2681
|
+
logSuccess("State updated");
|
|
2682
|
+
logHeader("Done!");
|
|
2683
|
+
return {
|
|
2684
|
+
ok: true,
|
|
2685
|
+
command: "remove",
|
|
2686
|
+
instanceId,
|
|
2687
|
+
runtime: instance.runtime,
|
|
2688
|
+
code: "TAP_REMOVE_OK",
|
|
2689
|
+
message: `${instanceId} removed successfully`,
|
|
2690
|
+
warnings: [],
|
|
2691
|
+
data: {
|
|
2692
|
+
restoredCount: result.restoredCount,
|
|
2693
|
+
restoredFiles: result.restoredFiles
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
for (const e of result.errors) logError(e);
|
|
2698
|
+
return {
|
|
2699
|
+
ok: false,
|
|
2700
|
+
command: "remove",
|
|
2701
|
+
instanceId,
|
|
2702
|
+
runtime: instance.runtime,
|
|
2703
|
+
code: "TAP_ROLLBACK_FAILED",
|
|
2704
|
+
message: "Rollback had errors. State preserved for retry.",
|
|
2705
|
+
warnings: result.errors,
|
|
2706
|
+
data: { restoredCount: result.restoredCount }
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// src/commands/bridge.ts
|
|
2711
|
+
import * as path13 from "path";
|
|
2712
|
+
function formatAge(seconds) {
|
|
2713
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
2714
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
2715
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m ago`;
|
|
2716
|
+
}
|
|
2717
|
+
var BRIDGE_HELP = `
|
|
2718
|
+
Usage:
|
|
2719
|
+
tap-comms bridge <subcommand> [instance] [options]
|
|
2720
|
+
|
|
2721
|
+
Subcommands:
|
|
2722
|
+
start <instance> Start bridge for an instance (e.g. codex, codex-reviewer)
|
|
2723
|
+
stop <instance> Stop bridge for an instance
|
|
2724
|
+
stop Stop all running bridges
|
|
2725
|
+
status Show bridge status for all instances
|
|
2726
|
+
status <instance> Show bridge status for a specific instance
|
|
2727
|
+
|
|
2728
|
+
Options:
|
|
2729
|
+
--agent-name <name> Agent identity for bridge (or set TAP_AGENT_NAME env)
|
|
2730
|
+
--busy-mode <steer|wait> How to handle active turns (default: steer)
|
|
2731
|
+
--poll-seconds <n> Inbox poll interval (default: 5)
|
|
2732
|
+
--reconnect-seconds <n> Reconnect delay after disconnect (default: 5)
|
|
2733
|
+
--message-lookback-minutes <n> Process messages from last N minutes (default: 10)
|
|
2734
|
+
--thread-id <id> Resume specific thread
|
|
2735
|
+
--ephemeral Use ephemeral thread (no persistence)
|
|
2736
|
+
--process-existing-messages Process all existing inbox messages
|
|
2737
|
+
|
|
2738
|
+
Examples:
|
|
2739
|
+
npx @hua-labs/tap bridge start codex --agent-name myAgent
|
|
2740
|
+
npx @hua-labs/tap bridge start codex-reviewer --agent-name reviewer --busy-mode steer
|
|
2741
|
+
npx @hua-labs/tap bridge stop codex
|
|
2742
|
+
npx @hua-labs/tap bridge stop
|
|
2743
|
+
npx @hua-labs/tap bridge status
|
|
2744
|
+
`.trim();
|
|
2745
|
+
async function bridgeStart(identifier, agentName, flags = {}) {
|
|
2746
|
+
const repoRoot = findRepoRoot2();
|
|
2747
|
+
const state = loadState(repoRoot);
|
|
2748
|
+
if (!state) {
|
|
2749
|
+
return {
|
|
2750
|
+
ok: false,
|
|
2751
|
+
command: "bridge",
|
|
2752
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2753
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2754
|
+
warnings: [],
|
|
2755
|
+
data: {}
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
const resolved = resolveInstanceId(identifier, state);
|
|
2759
|
+
if (!resolved.ok) {
|
|
2760
|
+
return {
|
|
2761
|
+
ok: false,
|
|
2762
|
+
command: "bridge",
|
|
2763
|
+
code: resolved.code,
|
|
2764
|
+
message: resolved.message,
|
|
2765
|
+
warnings: [],
|
|
2766
|
+
data: {}
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
const instanceId = resolved.instanceId;
|
|
2770
|
+
const instance = state.instances[instanceId];
|
|
2771
|
+
if (!instance?.installed) {
|
|
2772
|
+
return {
|
|
2773
|
+
ok: false,
|
|
2774
|
+
command: "bridge",
|
|
2775
|
+
instanceId,
|
|
2776
|
+
runtime: instance?.runtime,
|
|
2777
|
+
code: "TAP_INSTANCE_NOT_FOUND",
|
|
2778
|
+
message: `${instanceId} is not installed. Run: npx @hua-labs/tap add ${instance?.runtime ?? identifier}`,
|
|
2779
|
+
warnings: [],
|
|
2780
|
+
data: {}
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
const adapter = getAdapter(instance.runtime);
|
|
2784
|
+
const mode = adapter.bridgeMode();
|
|
2785
|
+
if (mode !== "app-server") {
|
|
2786
|
+
return {
|
|
2787
|
+
ok: true,
|
|
2788
|
+
command: "bridge",
|
|
2789
|
+
instanceId,
|
|
2790
|
+
runtime: instance.runtime,
|
|
2791
|
+
code: "TAP_NO_OP",
|
|
2792
|
+
message: `${instanceId} uses ${mode} mode \u2014 no bridge needed.`,
|
|
2793
|
+
warnings: [],
|
|
2794
|
+
data: { bridgeMode: mode }
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
const ctx = createAdapterContext(state.commsDir, repoRoot);
|
|
2798
|
+
const bridgeScript = adapter.resolveBridgeScript?.(ctx);
|
|
2799
|
+
if (!bridgeScript) {
|
|
2800
|
+
return {
|
|
2801
|
+
ok: false,
|
|
2802
|
+
command: "bridge",
|
|
2803
|
+
instanceId,
|
|
2804
|
+
runtime: instance.runtime,
|
|
2805
|
+
code: "TAP_BRIDGE_SCRIPT_MISSING",
|
|
2806
|
+
message: `Bridge script not found for ${instanceId}. Ensure the runtime is properly configured.`,
|
|
2807
|
+
warnings: [],
|
|
2808
|
+
data: {}
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
const { config: resolvedConfig } = resolveConfig({}, repoRoot);
|
|
2812
|
+
const runtimeCommand = resolvedConfig.runtimeCommand;
|
|
2813
|
+
const appServerUrl = resolvedConfig.appServerUrl;
|
|
2814
|
+
logHeader(`@hua-labs/tap bridge start ${instanceId}`);
|
|
2815
|
+
log(`Bridge script: ${bridgeScript}`);
|
|
2816
|
+
log(`Bridge mode: ${mode}`);
|
|
2817
|
+
log(`Runtime cmd: ${runtimeCommand}`);
|
|
2818
|
+
log(`App server: ${appServerUrl}`);
|
|
2819
|
+
if (instance.port) log(`Port: ${instance.port}`);
|
|
2820
|
+
const willBeHeadless = flags["headless"] === true || instance.headless?.enabled;
|
|
2821
|
+
if (willBeHeadless) {
|
|
2822
|
+
const role = (typeof flags["role"] === "string" ? flags["role"] : null) ?? instance.headless?.role ?? "reviewer";
|
|
2823
|
+
log(`Headless: ${role}`);
|
|
2824
|
+
}
|
|
2825
|
+
try {
|
|
2826
|
+
const busyModeRaw = flags["busy-mode"];
|
|
2827
|
+
if (busyModeRaw !== void 0 && busyModeRaw !== "steer" && busyModeRaw !== "wait") {
|
|
2828
|
+
return {
|
|
2829
|
+
ok: false,
|
|
2830
|
+
command: "bridge",
|
|
2831
|
+
instanceId,
|
|
2832
|
+
runtime: instance.runtime,
|
|
2833
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2834
|
+
message: `Invalid --busy-mode: ${String(busyModeRaw)}. Must be "steer" or "wait".`,
|
|
2835
|
+
warnings: [],
|
|
2836
|
+
data: {}
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const busyMode = busyModeRaw;
|
|
2840
|
+
const pollSeconds = typeof flags["poll-seconds"] === "string" ? parseInt(flags["poll-seconds"], 10) : void 0;
|
|
2841
|
+
const reconnectSeconds = typeof flags["reconnect-seconds"] === "string" ? parseInt(flags["reconnect-seconds"], 10) : void 0;
|
|
2842
|
+
const messageLookbackMinutes = typeof flags["message-lookback-minutes"] === "string" ? parseInt(flags["message-lookback-minutes"], 10) : void 0;
|
|
2843
|
+
const threadId = typeof flags["thread-id"] === "string" ? flags["thread-id"] : void 0;
|
|
2844
|
+
const ephemeral = flags["ephemeral"] === true;
|
|
2845
|
+
const processExistingMessages = flags["process-existing-messages"] === true;
|
|
2846
|
+
const headlessFlag = flags["headless"] === true;
|
|
2847
|
+
const roleArg = typeof flags["role"] === "string" ? flags["role"] : void 0;
|
|
2848
|
+
const validRoles = ["reviewer", "validator", "long-running"];
|
|
2849
|
+
if (roleArg && !validRoles.includes(roleArg)) {
|
|
2850
|
+
return {
|
|
2851
|
+
ok: false,
|
|
2852
|
+
command: "bridge",
|
|
2853
|
+
instanceId,
|
|
2854
|
+
runtime: instance.runtime,
|
|
2855
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
2856
|
+
message: `Invalid --role: ${roleArg}. Must be: ${validRoles.join(", ")}`,
|
|
2857
|
+
warnings: [],
|
|
2858
|
+
data: {}
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
const headless = headlessFlag ? {
|
|
2862
|
+
enabled: true,
|
|
2863
|
+
role: roleArg ?? "reviewer",
|
|
2864
|
+
maxRounds: 5,
|
|
2865
|
+
qualitySeverityFloor: "high"
|
|
2866
|
+
} : instance.headless;
|
|
2867
|
+
const bridge = await startBridge({
|
|
2868
|
+
instanceId,
|
|
2869
|
+
runtime: instance.runtime,
|
|
2870
|
+
stateDir: ctx.stateDir,
|
|
2871
|
+
commsDir: ctx.commsDir,
|
|
2872
|
+
bridgeScript,
|
|
2873
|
+
platform: ctx.platform,
|
|
2874
|
+
agentName,
|
|
2875
|
+
runtimeCommand,
|
|
2876
|
+
appServerUrl,
|
|
2877
|
+
repoRoot,
|
|
2878
|
+
port: instance.port ?? void 0,
|
|
2879
|
+
headless,
|
|
2880
|
+
busyMode,
|
|
2881
|
+
pollSeconds,
|
|
2882
|
+
reconnectSeconds,
|
|
2883
|
+
messageLookbackMinutes,
|
|
2884
|
+
threadId,
|
|
2885
|
+
ephemeral,
|
|
2886
|
+
processExistingMessages
|
|
2887
|
+
});
|
|
2888
|
+
logSuccess(`Bridge started (PID: ${bridge.pid})`);
|
|
2889
|
+
log(`Log: ${path13.join(ctx.stateDir, "logs", `bridge-${instanceId}.log`)}`);
|
|
2890
|
+
const updated = { ...instance, bridge };
|
|
2891
|
+
const newState = updateInstanceState(state, instanceId, updated);
|
|
2892
|
+
saveState(repoRoot, newState);
|
|
2893
|
+
return {
|
|
2894
|
+
ok: true,
|
|
2895
|
+
command: "bridge",
|
|
2896
|
+
instanceId,
|
|
2897
|
+
runtime: instance.runtime,
|
|
2898
|
+
code: "TAP_BRIDGE_START_OK",
|
|
2899
|
+
message: `Bridge for ${instanceId} started (PID: ${bridge.pid})`,
|
|
2900
|
+
warnings: [],
|
|
2901
|
+
data: { pid: bridge.pid }
|
|
2902
|
+
};
|
|
2903
|
+
} catch (err) {
|
|
2904
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2905
|
+
logError(msg);
|
|
2906
|
+
return {
|
|
2907
|
+
ok: false,
|
|
2908
|
+
command: "bridge",
|
|
2909
|
+
instanceId,
|
|
2910
|
+
runtime: instance.runtime,
|
|
2911
|
+
code: "TAP_BRIDGE_START_FAILED",
|
|
2912
|
+
message: msg,
|
|
2913
|
+
warnings: [],
|
|
2914
|
+
data: {}
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
async function bridgeStopOne(identifier) {
|
|
2919
|
+
const repoRoot = findRepoRoot2();
|
|
2920
|
+
const state = loadState(repoRoot);
|
|
2921
|
+
if (!state) {
|
|
2922
|
+
return {
|
|
2923
|
+
ok: false,
|
|
2924
|
+
command: "bridge",
|
|
2925
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2926
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2927
|
+
warnings: [],
|
|
2928
|
+
data: {}
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
const resolved = resolveInstanceId(identifier, state);
|
|
2932
|
+
if (!resolved.ok) {
|
|
2933
|
+
return {
|
|
2934
|
+
ok: false,
|
|
2935
|
+
command: "bridge",
|
|
2936
|
+
code: resolved.code,
|
|
2937
|
+
message: resolved.message,
|
|
2938
|
+
warnings: [],
|
|
2939
|
+
data: {}
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
const instanceId = resolved.instanceId;
|
|
2943
|
+
const ctx = createAdapterContext(state.commsDir, repoRoot);
|
|
2944
|
+
logHeader(`@hua-labs/tap bridge stop ${instanceId}`);
|
|
2945
|
+
const stopped = await stopBridge({
|
|
2946
|
+
instanceId,
|
|
2947
|
+
stateDir: ctx.stateDir,
|
|
2948
|
+
platform: ctx.platform
|
|
2949
|
+
});
|
|
2950
|
+
if (stopped) {
|
|
2951
|
+
logSuccess(`Bridge for ${instanceId} stopped`);
|
|
2952
|
+
const instance2 = state.instances[instanceId];
|
|
2953
|
+
if (instance2) {
|
|
2954
|
+
const updated = { ...instance2, bridge: null };
|
|
2955
|
+
const newState = updateInstanceState(state, instanceId, updated);
|
|
2956
|
+
saveState(repoRoot, newState);
|
|
2957
|
+
}
|
|
2958
|
+
return {
|
|
2959
|
+
ok: true,
|
|
2960
|
+
command: "bridge",
|
|
2961
|
+
instanceId,
|
|
2962
|
+
code: "TAP_BRIDGE_STOP_OK",
|
|
2963
|
+
message: `Bridge for ${instanceId} stopped`,
|
|
2964
|
+
warnings: [],
|
|
2965
|
+
data: {}
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
log(`No running bridge for ${instanceId}`);
|
|
2969
|
+
const instance = state.instances[instanceId];
|
|
2970
|
+
if (instance?.bridge) {
|
|
2971
|
+
const updated = { ...instance, bridge: null };
|
|
2972
|
+
const newState = updateInstanceState(state, instanceId, updated);
|
|
2973
|
+
saveState(repoRoot, newState);
|
|
2974
|
+
}
|
|
2975
|
+
return {
|
|
2976
|
+
ok: true,
|
|
2977
|
+
command: "bridge",
|
|
2978
|
+
instanceId,
|
|
2979
|
+
code: "TAP_BRIDGE_NOT_RUNNING",
|
|
2980
|
+
message: `No running bridge for ${instanceId}`,
|
|
2981
|
+
warnings: [],
|
|
2982
|
+
data: {}
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
async function bridgeStopAll() {
|
|
2986
|
+
const repoRoot = findRepoRoot2();
|
|
2987
|
+
const state = loadState(repoRoot);
|
|
2988
|
+
if (!state) {
|
|
2989
|
+
return {
|
|
2990
|
+
ok: false,
|
|
2991
|
+
command: "bridge",
|
|
2992
|
+
code: "TAP_NOT_INITIALIZED",
|
|
2993
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
2994
|
+
warnings: [],
|
|
2995
|
+
data: {}
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
const ctx = createAdapterContext(state.commsDir, repoRoot);
|
|
2999
|
+
const instanceIds = Object.keys(state.instances);
|
|
3000
|
+
const stopped = [];
|
|
3001
|
+
logHeader("@hua-labs/tap bridge stop (all)");
|
|
3002
|
+
let stateChanged = false;
|
|
3003
|
+
for (const instanceId of instanceIds) {
|
|
3004
|
+
const didStop = await stopBridge({
|
|
3005
|
+
instanceId,
|
|
3006
|
+
stateDir: ctx.stateDir,
|
|
3007
|
+
platform: ctx.platform
|
|
3008
|
+
});
|
|
3009
|
+
if (didStop) {
|
|
3010
|
+
logSuccess(`Stopped bridge for ${instanceId}`);
|
|
3011
|
+
stopped.push(instanceId);
|
|
3012
|
+
}
|
|
3013
|
+
const instance = state.instances[instanceId];
|
|
3014
|
+
if (instance?.bridge) {
|
|
3015
|
+
state.instances[instanceId] = { ...instance, bridge: null };
|
|
3016
|
+
stateChanged = true;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
if (stateChanged) {
|
|
3020
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3021
|
+
saveState(repoRoot, state);
|
|
3022
|
+
}
|
|
3023
|
+
const message = stopped.length > 0 ? `Stopped ${stopped.length} bridge(s): ${stopped.join(", ")}` : "No running bridges found";
|
|
3024
|
+
log(message);
|
|
3025
|
+
return {
|
|
3026
|
+
ok: true,
|
|
3027
|
+
command: "bridge",
|
|
3028
|
+
code: stopped.length > 0 ? "TAP_BRIDGE_STOP_OK" : "TAP_BRIDGE_NOT_RUNNING",
|
|
3029
|
+
message,
|
|
3030
|
+
warnings: [],
|
|
3031
|
+
data: { stopped }
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
function bridgeStatusAll() {
|
|
3035
|
+
const repoRoot = findRepoRoot2();
|
|
3036
|
+
const state = loadState(repoRoot);
|
|
3037
|
+
if (!state) {
|
|
3038
|
+
return {
|
|
3039
|
+
ok: false,
|
|
3040
|
+
command: "bridge",
|
|
3041
|
+
code: "TAP_NOT_INITIALIZED",
|
|
3042
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
3043
|
+
warnings: [],
|
|
3044
|
+
data: {}
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
const { config: resolvedCfg } = resolveConfig({}, repoRoot);
|
|
3048
|
+
const stateDir = resolvedCfg.stateDir;
|
|
3049
|
+
const instanceIds = Object.keys(state.instances);
|
|
3050
|
+
const bridges = {};
|
|
3051
|
+
logHeader("@hua-labs/tap bridge status");
|
|
3052
|
+
log(
|
|
3053
|
+
`${"Instance".padEnd(20)} ${"Runtime".padEnd(8)} ${"Status".padEnd(10)} ${"PID".padEnd(8)} ${"Port".padEnd(6)} ${"Last Heartbeat"}`
|
|
3054
|
+
);
|
|
3055
|
+
log(
|
|
3056
|
+
`${"\u2500".repeat(20)} ${"\u2500".repeat(8)} ${"\u2500".repeat(10)} ${"\u2500".repeat(8)} ${"\u2500".repeat(6)} ${"\u2500".repeat(20)}`
|
|
3057
|
+
);
|
|
3058
|
+
for (const instanceId of instanceIds) {
|
|
3059
|
+
const inst = state.instances[instanceId];
|
|
3060
|
+
if (!inst?.installed) continue;
|
|
3061
|
+
if (inst.bridgeMode !== "app-server") {
|
|
3062
|
+
log(
|
|
3063
|
+
`${instanceId.padEnd(20)} ${inst.runtime.padEnd(8)} ${"n/a".padEnd(10)} ${"-".padEnd(8)} ${"-".padEnd(6)} ${inst.bridgeMode} mode`
|
|
3064
|
+
);
|
|
3065
|
+
bridges[instanceId] = {
|
|
3066
|
+
status: "n/a",
|
|
3067
|
+
runtime: inst.runtime,
|
|
3068
|
+
pid: null,
|
|
3069
|
+
port: inst.port,
|
|
3070
|
+
lastHeartbeat: null
|
|
3071
|
+
};
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
const status = getBridgeStatus(stateDir, instanceId);
|
|
3075
|
+
const bridgeState = loadBridgeState(stateDir, instanceId);
|
|
3076
|
+
const age = getHeartbeatAge(stateDir, instanceId);
|
|
3077
|
+
const pid = bridgeState?.pid ?? null;
|
|
3078
|
+
const heartbeat = bridgeState?.lastHeartbeat ?? null;
|
|
3079
|
+
const pidStr = pid ? String(pid) : "-";
|
|
3080
|
+
const portStr = inst.port ? String(inst.port) : "-";
|
|
3081
|
+
const ageStr = age !== null ? formatAge(age) : "-";
|
|
3082
|
+
const statusColor = status === "running" ? "running" : status === "stale" ? "stale!" : "stopped";
|
|
3083
|
+
log(
|
|
3084
|
+
`${instanceId.padEnd(20)} ${inst.runtime.padEnd(8)} ${statusColor.padEnd(10)} ${pidStr.padEnd(8)} ${portStr.padEnd(6)} ${ageStr}`
|
|
3085
|
+
);
|
|
3086
|
+
bridges[instanceId] = {
|
|
3087
|
+
status,
|
|
3088
|
+
runtime: inst.runtime,
|
|
3089
|
+
pid,
|
|
3090
|
+
port: inst.port,
|
|
3091
|
+
lastHeartbeat: heartbeat
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
if (instanceIds.length === 0) {
|
|
3095
|
+
log("No instances installed.");
|
|
3096
|
+
}
|
|
3097
|
+
log("");
|
|
3098
|
+
return {
|
|
3099
|
+
ok: true,
|
|
3100
|
+
command: "bridge",
|
|
3101
|
+
code: "TAP_BRIDGE_STATUS_OK",
|
|
3102
|
+
message: `${instanceIds.length} instance(s) checked`,
|
|
3103
|
+
warnings: [],
|
|
3104
|
+
data: { bridges }
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
function bridgeStatusOne(identifier) {
|
|
3108
|
+
const repoRoot = findRepoRoot2();
|
|
3109
|
+
const state = loadState(repoRoot);
|
|
3110
|
+
if (!state) {
|
|
3111
|
+
return {
|
|
3112
|
+
ok: false,
|
|
3113
|
+
command: "bridge",
|
|
3114
|
+
code: "TAP_NOT_INITIALIZED",
|
|
3115
|
+
message: "Not initialized. Run: npx @hua-labs/tap init",
|
|
3116
|
+
warnings: [],
|
|
3117
|
+
data: {}
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
const resolved = resolveInstanceId(identifier, state);
|
|
3121
|
+
if (!resolved.ok) {
|
|
3122
|
+
return {
|
|
3123
|
+
ok: false,
|
|
3124
|
+
command: "bridge",
|
|
3125
|
+
code: resolved.code,
|
|
3126
|
+
message: resolved.message,
|
|
3127
|
+
warnings: [],
|
|
3128
|
+
data: {}
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
const instanceId = resolved.instanceId;
|
|
3132
|
+
const inst = state.instances[instanceId];
|
|
3133
|
+
if (!inst?.installed) {
|
|
3134
|
+
return {
|
|
3135
|
+
ok: false,
|
|
3136
|
+
command: "bridge",
|
|
3137
|
+
instanceId,
|
|
3138
|
+
code: "TAP_INSTANCE_NOT_FOUND",
|
|
3139
|
+
message: `${instanceId} is not installed.`,
|
|
3140
|
+
warnings: [],
|
|
3141
|
+
data: {}
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
logHeader(`@hua-labs/tap bridge status ${instanceId}`);
|
|
3145
|
+
log(`Instance: ${instanceId}`);
|
|
3146
|
+
log(`Runtime: ${inst.runtime}`);
|
|
3147
|
+
log(`Bridge mode: ${inst.bridgeMode}`);
|
|
3148
|
+
if (inst.port) log(`Port: ${inst.port}`);
|
|
3149
|
+
if (inst.bridgeMode !== "app-server") {
|
|
3150
|
+
log(`Status: n/a (${inst.bridgeMode} mode)`);
|
|
3151
|
+
log("");
|
|
3152
|
+
return {
|
|
3153
|
+
ok: true,
|
|
3154
|
+
command: "bridge",
|
|
3155
|
+
instanceId,
|
|
3156
|
+
runtime: inst.runtime,
|
|
3157
|
+
code: "TAP_BRIDGE_STATUS_OK",
|
|
3158
|
+
message: `${instanceId} bridge: n/a (${inst.bridgeMode} mode)`,
|
|
3159
|
+
warnings: [],
|
|
3160
|
+
data: {
|
|
3161
|
+
status: "n/a",
|
|
3162
|
+
bridgeMode: inst.bridgeMode,
|
|
3163
|
+
pid: null,
|
|
3164
|
+
port: inst.port,
|
|
3165
|
+
lastHeartbeat: null
|
|
3166
|
+
}
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
const { config: resolvedCfg2 } = resolveConfig({}, repoRoot);
|
|
3170
|
+
const stateDir = resolvedCfg2.stateDir;
|
|
3171
|
+
const status = getBridgeStatus(stateDir, instanceId);
|
|
3172
|
+
const bridgeState = loadBridgeState(stateDir, instanceId);
|
|
3173
|
+
const age = getHeartbeatAge(stateDir, instanceId);
|
|
3174
|
+
log(`Status: ${status}`);
|
|
3175
|
+
if (bridgeState) {
|
|
3176
|
+
log(`PID: ${bridgeState.pid}`);
|
|
3177
|
+
log(
|
|
3178
|
+
`Heartbeat: ${bridgeState.lastHeartbeat}${age !== null ? ` (${formatAge(age)})` : ""}`
|
|
3179
|
+
);
|
|
3180
|
+
log(
|
|
3181
|
+
`Log: ${path13.join(stateDir, "logs", `bridge-${instanceId}.log`)}`
|
|
3182
|
+
);
|
|
3183
|
+
}
|
|
3184
|
+
log("");
|
|
3185
|
+
return {
|
|
3186
|
+
ok: true,
|
|
3187
|
+
command: "bridge",
|
|
3188
|
+
instanceId,
|
|
3189
|
+
runtime: inst.runtime,
|
|
3190
|
+
code: "TAP_BRIDGE_STATUS_OK",
|
|
3191
|
+
message: `${instanceId} bridge: ${status}`,
|
|
3192
|
+
warnings: [],
|
|
3193
|
+
data: {
|
|
3194
|
+
status,
|
|
3195
|
+
bridgeMode: inst.bridgeMode,
|
|
3196
|
+
pid: bridgeState?.pid ?? null,
|
|
3197
|
+
port: inst.port,
|
|
3198
|
+
lastHeartbeat: bridgeState?.lastHeartbeat ?? null
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
async function bridgeCommand(args) {
|
|
3203
|
+
const { positional, flags } = parseArgs(args);
|
|
3204
|
+
const subcommand = positional[0];
|
|
3205
|
+
const identifierArg = positional[1];
|
|
3206
|
+
const agentName = typeof flags["agent-name"] === "string" ? flags["agent-name"] : void 0;
|
|
3207
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
3208
|
+
log(BRIDGE_HELP);
|
|
3209
|
+
return {
|
|
3210
|
+
ok: true,
|
|
3211
|
+
command: "bridge",
|
|
3212
|
+
code: "TAP_NO_OP",
|
|
3213
|
+
message: BRIDGE_HELP,
|
|
3214
|
+
warnings: [],
|
|
3215
|
+
data: {}
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
switch (subcommand) {
|
|
3219
|
+
case "start": {
|
|
3220
|
+
if (!identifierArg) {
|
|
3221
|
+
return {
|
|
3222
|
+
ok: false,
|
|
3223
|
+
command: "bridge",
|
|
3224
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
3225
|
+
message: "Missing instance. Usage: npx @hua-labs/tap bridge start <instance>",
|
|
3226
|
+
warnings: [],
|
|
3227
|
+
data: {}
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
return bridgeStart(identifierArg, agentName, flags);
|
|
3231
|
+
}
|
|
3232
|
+
case "stop": {
|
|
3233
|
+
if (!identifierArg) {
|
|
3234
|
+
return bridgeStopAll();
|
|
3235
|
+
}
|
|
3236
|
+
return bridgeStopOne(identifierArg);
|
|
3237
|
+
}
|
|
3238
|
+
case "status": {
|
|
3239
|
+
if (identifierArg) {
|
|
3240
|
+
return bridgeStatusOne(identifierArg);
|
|
3241
|
+
}
|
|
3242
|
+
return bridgeStatusAll();
|
|
3243
|
+
}
|
|
3244
|
+
default:
|
|
3245
|
+
return {
|
|
3246
|
+
ok: false,
|
|
3247
|
+
command: "bridge",
|
|
3248
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
3249
|
+
message: `Unknown bridge subcommand: ${subcommand}. Use: start, stop, status`,
|
|
3250
|
+
warnings: [],
|
|
3251
|
+
data: {}
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// src/commands/serve.ts
|
|
3257
|
+
import * as fs14 from "fs";
|
|
3258
|
+
import * as path14 from "path";
|
|
3259
|
+
import { execSync as execSync4, spawn as spawn2 } from "child_process";
|
|
3260
|
+
function findServerEntry(repoRoot) {
|
|
3261
|
+
const candidates = [
|
|
3262
|
+
path14.join(repoRoot, "packages", "tap-plugin", "channels", "tap-comms.ts"),
|
|
3263
|
+
path14.join(
|
|
3264
|
+
repoRoot,
|
|
3265
|
+
"node_modules",
|
|
3266
|
+
"@hua-labs",
|
|
3267
|
+
"tap-plugin",
|
|
3268
|
+
"channels",
|
|
3269
|
+
"tap-comms.ts"
|
|
3270
|
+
)
|
|
3271
|
+
];
|
|
3272
|
+
for (const p of candidates) {
|
|
3273
|
+
if (fs14.existsSync(p)) return p;
|
|
3274
|
+
}
|
|
3275
|
+
return null;
|
|
3276
|
+
}
|
|
3277
|
+
function isBunInstalled() {
|
|
3278
|
+
try {
|
|
3279
|
+
execSync4("bun --version", { stdio: "pipe" });
|
|
3280
|
+
return true;
|
|
3281
|
+
} catch {
|
|
3282
|
+
return false;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
async function serveCommand(args) {
|
|
3286
|
+
const repoRoot = findRepoRoot2();
|
|
3287
|
+
let commsDir;
|
|
3288
|
+
const commsDirIdx = args.indexOf("--comms-dir");
|
|
3289
|
+
if (commsDirIdx !== -1 && args[commsDirIdx + 1]) {
|
|
3290
|
+
commsDir = path14.resolve(args[commsDirIdx + 1]);
|
|
3291
|
+
}
|
|
3292
|
+
if (!commsDir && process.env.TAP_COMMS_DIR) {
|
|
3293
|
+
commsDir = process.env.TAP_COMMS_DIR;
|
|
3294
|
+
}
|
|
3295
|
+
if (!commsDir) {
|
|
3296
|
+
const state = loadState(repoRoot);
|
|
3297
|
+
if (state) {
|
|
3298
|
+
commsDir = state.commsDir;
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
if (!commsDir) {
|
|
3302
|
+
return {
|
|
3303
|
+
ok: false,
|
|
3304
|
+
command: "serve",
|
|
3305
|
+
code: "TAP_NOT_INITIALIZED",
|
|
3306
|
+
message: "Cannot determine comms directory. Set TAP_COMMS_DIR env var, use --comms-dir, or run 'init' first.",
|
|
3307
|
+
warnings: [],
|
|
3308
|
+
data: {}
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
if (!isBunInstalled()) {
|
|
3312
|
+
return {
|
|
3313
|
+
ok: false,
|
|
3314
|
+
command: "serve",
|
|
3315
|
+
code: "TAP_SERVE_BUN_REQUIRED",
|
|
3316
|
+
message: "bun is required to run the tap-comms MCP server. Install: https://bun.sh",
|
|
3317
|
+
warnings: [],
|
|
3318
|
+
data: {}
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
const serverEntry = findServerEntry(repoRoot);
|
|
3322
|
+
if (!serverEntry) {
|
|
3323
|
+
return {
|
|
3324
|
+
ok: false,
|
|
3325
|
+
command: "serve",
|
|
3326
|
+
code: "TAP_SERVE_NO_SERVER",
|
|
3327
|
+
message: "tap-comms MCP server not found. Run from a repo with packages/tap-plugin/channels/.",
|
|
3328
|
+
warnings: [],
|
|
3329
|
+
data: {}
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
const child = spawn2("bun", [serverEntry], {
|
|
3333
|
+
stdio: "inherit",
|
|
3334
|
+
env: {
|
|
3335
|
+
...process.env,
|
|
3336
|
+
TAP_COMMS_DIR: commsDir
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
return new Promise((resolve10) => {
|
|
3340
|
+
child.on("error", (err) => {
|
|
3341
|
+
resolve10({
|
|
3342
|
+
ok: false,
|
|
3343
|
+
command: "serve",
|
|
3344
|
+
code: "TAP_INTERNAL_ERROR",
|
|
3345
|
+
message: `Failed to start MCP server: ${err.message}`,
|
|
3346
|
+
warnings: [],
|
|
3347
|
+
data: {}
|
|
3348
|
+
});
|
|
3349
|
+
});
|
|
3350
|
+
child.on("exit", (code) => {
|
|
3351
|
+
resolve10({
|
|
3352
|
+
ok: code === 0,
|
|
3353
|
+
command: "serve",
|
|
3354
|
+
code: code === 0 ? "TAP_SERVE_OK" : "TAP_INTERNAL_ERROR",
|
|
3355
|
+
message: code === 0 ? "MCP server stopped" : `MCP server exited with code ${code}`,
|
|
3356
|
+
warnings: [],
|
|
3357
|
+
data: { exitCode: code }
|
|
3358
|
+
});
|
|
3359
|
+
});
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
// src/commands/init-worktree.ts
|
|
3364
|
+
import * as fs15 from "fs";
|
|
3365
|
+
import * as path15 from "path";
|
|
3366
|
+
import { execSync as execSync5 } from "child_process";
|
|
3367
|
+
var INIT_WORKTREE_HELP = `
|
|
3368
|
+
Usage:
|
|
3369
|
+
tap-comms init-worktree [options]
|
|
3370
|
+
|
|
3371
|
+
Options:
|
|
3372
|
+
--path <dir> Worktree directory (required, e.g. ../hua-wt-3)
|
|
3373
|
+
--branch <name> Branch name to create (default: derived from path)
|
|
3374
|
+
--base <ref> Base ref for new branch (default: origin/main)
|
|
3375
|
+
--mission <file> Mission file to associate (e.g. m74-feature.md)
|
|
3376
|
+
--comms-dir <path> Override comms directory
|
|
3377
|
+
--skip-install Skip pnpm install step
|
|
3378
|
+
--help, -h Show help
|
|
3379
|
+
|
|
3380
|
+
Examples:
|
|
3381
|
+
npx @hua-labs/tap init-worktree --path ../hua-wt-3 --branch feat/my-feature
|
|
3382
|
+
npx @hua-labs/tap init-worktree --path ../hua-wt-4 --branch fix/bug --mission m74-fix.md
|
|
3383
|
+
`.trim();
|
|
3384
|
+
function warn(warnings, message) {
|
|
3385
|
+
logWarn(message);
|
|
3386
|
+
warnings.push(message);
|
|
3387
|
+
}
|
|
3388
|
+
function run(cmd, opts) {
|
|
3389
|
+
try {
|
|
3390
|
+
return execSync5(cmd, {
|
|
3391
|
+
cwd: opts?.cwd,
|
|
3392
|
+
encoding: "utf-8",
|
|
3393
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3394
|
+
timeout: 12e4
|
|
3395
|
+
}).trim();
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
if (opts?.ignoreError) return "";
|
|
3398
|
+
throw err;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
function toAbsolute(p) {
|
|
3402
|
+
const resolved = path15.resolve(p);
|
|
3403
|
+
return resolved.replace(/\\/g, "/");
|
|
3404
|
+
}
|
|
3405
|
+
function probeBun(candidate) {
|
|
3406
|
+
try {
|
|
3407
|
+
const out = execSync5(`"${candidate}" --version`, {
|
|
3408
|
+
encoding: "utf-8",
|
|
3409
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3410
|
+
timeout: 5e3
|
|
3411
|
+
}).trim();
|
|
3412
|
+
return /^\d+\.\d+/.test(out);
|
|
3413
|
+
} catch {
|
|
3414
|
+
return false;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
function findBun() {
|
|
3418
|
+
const candidates = process.platform === "win32" ? ["bun.exe", "bun"] : ["bun"];
|
|
3419
|
+
for (const name of candidates) {
|
|
3420
|
+
try {
|
|
3421
|
+
const out = execSync5(
|
|
3422
|
+
process.platform === "win32" ? `where ${name}` : `which ${name}`,
|
|
3423
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5e3 }
|
|
3424
|
+
).trim();
|
|
3425
|
+
for (const line of out.split(/\r?\n/)) {
|
|
3426
|
+
const trimmed = line.trim();
|
|
3427
|
+
if (trimmed && probeBun(trimmed)) return trimmed;
|
|
3428
|
+
}
|
|
3429
|
+
} catch {
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
3433
|
+
const bunHome = path15.join(
|
|
3434
|
+
home,
|
|
3435
|
+
".bun",
|
|
3436
|
+
"bin",
|
|
3437
|
+
process.platform === "win32" ? "bun.exe" : "bun"
|
|
3438
|
+
);
|
|
3439
|
+
if (fs15.existsSync(bunHome) && probeBun(bunHome)) return bunHome;
|
|
3440
|
+
return null;
|
|
3441
|
+
}
|
|
3442
|
+
function step1CreateWorktree(opts) {
|
|
3443
|
+
log("Step 1/9: Creating worktree...");
|
|
3444
|
+
if (fs15.existsSync(opts.worktreePath)) {
|
|
3445
|
+
logWarn(`Directory already exists: ${opts.worktreePath}`);
|
|
3446
|
+
try {
|
|
3447
|
+
run("git rev-parse --git-dir", { cwd: opts.worktreePath });
|
|
3448
|
+
logWarn("Already a git worktree. Continuing...");
|
|
3449
|
+
return true;
|
|
3450
|
+
} catch {
|
|
3451
|
+
logError("Directory exists but is not a git worktree.");
|
|
3452
|
+
return false;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
try {
|
|
3456
|
+
run(
|
|
3457
|
+
`git worktree add "${opts.worktreePath}" -b ${opts.branch} ${opts.base}`,
|
|
3458
|
+
{ cwd: opts.repoRoot }
|
|
3459
|
+
);
|
|
3460
|
+
logSuccess(`Worktree created: ${opts.worktreePath}`);
|
|
3461
|
+
logSuccess(`Branch: ${opts.branch} (from ${opts.base})`);
|
|
3462
|
+
} catch {
|
|
3463
|
+
try {
|
|
3464
|
+
run(`git worktree add "${opts.worktreePath}" ${opts.branch}`, {
|
|
3465
|
+
cwd: opts.repoRoot
|
|
3466
|
+
});
|
|
3467
|
+
logSuccess(`Worktree created with existing branch: ${opts.branch}`);
|
|
3468
|
+
} catch (err) {
|
|
3469
|
+
logError(
|
|
3470
|
+
`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`
|
|
3471
|
+
);
|
|
3472
|
+
return false;
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
return true;
|
|
3476
|
+
}
|
|
3477
|
+
function step2MergeMain(opts, warnings) {
|
|
3478
|
+
log("Step 2/9: Merging origin/main...");
|
|
3479
|
+
try {
|
|
3480
|
+
run("git fetch origin main", { cwd: opts.worktreePath });
|
|
3481
|
+
} catch {
|
|
3482
|
+
warn(warnings, "Could not fetch origin/main. Skipping merge.");
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
try {
|
|
3486
|
+
const behind = run("git rev-list --count HEAD..origin/main", {
|
|
3487
|
+
cwd: opts.worktreePath
|
|
3488
|
+
});
|
|
3489
|
+
if (behind === "0") {
|
|
3490
|
+
logSuccess("Already up to date with origin/main.");
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
run("git merge origin/main --no-edit -X theirs", {
|
|
3494
|
+
cwd: opts.worktreePath
|
|
3495
|
+
});
|
|
3496
|
+
logSuccess("Merged origin/main.");
|
|
3497
|
+
} catch {
|
|
3498
|
+
warn(
|
|
3499
|
+
warnings,
|
|
3500
|
+
"Merge had issues. You may need to resolve conflicts manually."
|
|
3501
|
+
);
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
function step3CopyPermissions(opts, warnings) {
|
|
3505
|
+
log("Step 3/9: Copying permissions...");
|
|
3506
|
+
const srcSettings = path15.join(
|
|
3507
|
+
opts.repoRoot,
|
|
3508
|
+
".claude",
|
|
3509
|
+
"settings.local.json"
|
|
3510
|
+
);
|
|
3511
|
+
const destDir = path15.join(opts.worktreePath, ".claude");
|
|
3512
|
+
const destSettings = path15.join(destDir, "settings.local.json");
|
|
3513
|
+
if (!fs15.existsSync(srcSettings)) {
|
|
3514
|
+
warn(
|
|
3515
|
+
warnings,
|
|
3516
|
+
"No .claude/settings.local.json found in main repo. Skipping."
|
|
3517
|
+
);
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
3520
|
+
fs15.mkdirSync(destDir, { recursive: true });
|
|
3521
|
+
fs15.copyFileSync(srcSettings, destSettings);
|
|
3522
|
+
logSuccess("Copied settings.local.json");
|
|
3523
|
+
try {
|
|
3524
|
+
run("git update-index --skip-worktree .claude/settings.local.json", {
|
|
3525
|
+
cwd: opts.worktreePath
|
|
3526
|
+
});
|
|
3527
|
+
logSuccess("Marked skip-worktree");
|
|
3528
|
+
} catch {
|
|
3529
|
+
warn(warnings, "Could not set skip-worktree. File may show as modified.");
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
function step4GenerateMcpJson(opts, warnings) {
|
|
3533
|
+
log("Step 4/9: Generating .mcp.json...");
|
|
3534
|
+
const bunPath = findBun();
|
|
3535
|
+
if (!bunPath) {
|
|
3536
|
+
warn(warnings, "bun not found. .mcp.json not generated.");
|
|
3537
|
+
warn(
|
|
3538
|
+
warnings,
|
|
3539
|
+
"Install bun (https://bun.sh) and re-run, or create .mcp.json manually."
|
|
3540
|
+
);
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
const wtAbs = toAbsolute(opts.worktreePath);
|
|
3544
|
+
const bunAbs = toAbsolute(bunPath);
|
|
3545
|
+
const commsAbs = toAbsolute(opts.commsDir);
|
|
3546
|
+
const channelEntry = path15.join(
|
|
3547
|
+
wtAbs,
|
|
3548
|
+
"packages/tap-plugin/channels/tap-comms.ts"
|
|
3549
|
+
);
|
|
3550
|
+
const mcpConfig = {
|
|
3551
|
+
mcpServers: {
|
|
3552
|
+
"tap-comms": {
|
|
3553
|
+
command: bunAbs,
|
|
3554
|
+
args: [channelEntry],
|
|
3555
|
+
cwd: wtAbs,
|
|
3556
|
+
env: {
|
|
3557
|
+
TAP_COMMS_DIR: commsAbs,
|
|
3558
|
+
TAP_AGENT_NAME: "unnamed"
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
};
|
|
3563
|
+
const mcpPath = path15.join(opts.worktreePath, ".mcp.json");
|
|
3564
|
+
fs15.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
|
|
3565
|
+
logSuccess(`.mcp.json generated (absolute paths + cwd)`);
|
|
3566
|
+
log(` bun: ${bunAbs}`);
|
|
3567
|
+
log(` comms: ${commsAbs}`);
|
|
3568
|
+
}
|
|
3569
|
+
function step5Install(opts, warnings) {
|
|
3570
|
+
if (opts.skipInstall) {
|
|
3571
|
+
log("Step 5/9: Skipping pnpm install (--skip-install).");
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
log("Step 5/9: Installing dependencies...");
|
|
3575
|
+
try {
|
|
3576
|
+
run("pnpm install --prefer-offline", { cwd: opts.worktreePath });
|
|
3577
|
+
logSuccess("Dependencies installed.");
|
|
3578
|
+
} catch {
|
|
3579
|
+
warn(
|
|
3580
|
+
warnings,
|
|
3581
|
+
"pnpm install failed. Try running manually in the worktree."
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
function step6BuildEslintPlugin(opts, warnings) {
|
|
3586
|
+
if (opts.skipInstall) {
|
|
3587
|
+
log("Step 6/9: Skipping eslint plugin build (--skip-install).");
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
log("Step 6/9: Building eslint-plugin-i18n...");
|
|
3591
|
+
try {
|
|
3592
|
+
run("pnpm build --filter @hua-labs/eslint-plugin-i18n", {
|
|
3593
|
+
cwd: opts.worktreePath
|
|
3594
|
+
});
|
|
3595
|
+
logSuccess("eslint-plugin-i18n built.");
|
|
3596
|
+
} catch {
|
|
3597
|
+
warn(warnings, "eslint-plugin-i18n build failed. Non-blocking.");
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
function step7VerifyComms(opts, warnings) {
|
|
3601
|
+
log("Step 7/9: Verifying comms directory...");
|
|
3602
|
+
if (!fs15.existsSync(opts.commsDir)) {
|
|
3603
|
+
warn(warnings, `Comms directory not found: ${opts.commsDir}`);
|
|
3604
|
+
warn(warnings, "Create it or run: npx @hua-labs/tap init");
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
const requiredDirs = ["inbox", "findings", "reviews", "letters"];
|
|
3608
|
+
for (const dir of requiredDirs) {
|
|
3609
|
+
const dirPath = path15.join(opts.commsDir, dir);
|
|
3610
|
+
if (!fs15.existsSync(dirPath)) {
|
|
3611
|
+
fs15.mkdirSync(dirPath, { recursive: true });
|
|
3612
|
+
logSuccess(`Created ${dir}/`);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
logSuccess(`Comms verified: ${opts.commsDir}`);
|
|
3616
|
+
}
|
|
3617
|
+
function step8VerifyBun(warnings) {
|
|
3618
|
+
log("Step 8/9: Verifying bun...");
|
|
3619
|
+
const bunPath = findBun();
|
|
3620
|
+
if (!bunPath) {
|
|
3621
|
+
warn(warnings, "bun not found in PATH.");
|
|
3622
|
+
warn(warnings, "Install: curl -fsSL https://bun.sh/install | bash");
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
try {
|
|
3626
|
+
const version2 = run(`"${bunPath}" --version`);
|
|
3627
|
+
logSuccess(`bun ${version2} found: ${bunPath}`);
|
|
3628
|
+
} catch {
|
|
3629
|
+
warn(warnings, "bun found but version check failed.");
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
function step9Ready(opts) {
|
|
3633
|
+
logHeader("Ready!");
|
|
3634
|
+
log(`Worktree: ${toAbsolute(opts.worktreePath)}`);
|
|
3635
|
+
log(`Branch: ${opts.branch}`);
|
|
3636
|
+
log(`Comms: ${toAbsolute(opts.commsDir)}`);
|
|
3637
|
+
if (opts.mission) log(`Mission: ${opts.mission}`);
|
|
3638
|
+
log("");
|
|
3639
|
+
log("Next steps:");
|
|
3640
|
+
log(` cd ${opts.worktreePath}`);
|
|
3641
|
+
log(" claude # Start Claude Code session");
|
|
3642
|
+
log("");
|
|
3643
|
+
}
|
|
3644
|
+
async function initWorktreeCommand(args) {
|
|
3645
|
+
const { flags } = parseArgs(args);
|
|
3646
|
+
if (flags["help"] === true || flags["h"] === true) {
|
|
3647
|
+
log(INIT_WORKTREE_HELP);
|
|
3648
|
+
return {
|
|
3649
|
+
ok: true,
|
|
3650
|
+
command: "init-worktree",
|
|
3651
|
+
code: "TAP_NO_OP",
|
|
3652
|
+
message: "init-worktree help",
|
|
3653
|
+
warnings: [],
|
|
3654
|
+
data: {}
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
3657
|
+
const worktreePath = typeof flags["path"] === "string" ? flags["path"] : void 0;
|
|
3658
|
+
if (!worktreePath) {
|
|
3659
|
+
return {
|
|
3660
|
+
ok: false,
|
|
3661
|
+
command: "init-worktree",
|
|
3662
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
3663
|
+
message: "Missing --path. Usage: npx @hua-labs/tap init-worktree --path ../hua-wt-3",
|
|
3664
|
+
warnings: [],
|
|
3665
|
+
data: {}
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
const repoRoot = findRepoRoot2();
|
|
3669
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
3670
|
+
const branch = typeof flags["branch"] === "string" ? flags["branch"] : path15.basename(path15.resolve(worktreePath));
|
|
3671
|
+
const base = typeof flags["base"] === "string" ? flags["base"] : "origin/main";
|
|
3672
|
+
const mission = typeof flags["mission"] === "string" ? flags["mission"] : void 0;
|
|
3673
|
+
const commsDir = typeof flags["comms-dir"] === "string" ? flags["comms-dir"] : config.commsDir;
|
|
3674
|
+
const skipInstall = flags["skip-install"] === true;
|
|
3675
|
+
const opts = {
|
|
3676
|
+
worktreePath: path15.resolve(worktreePath),
|
|
3677
|
+
branch,
|
|
3678
|
+
base,
|
|
3679
|
+
mission,
|
|
3680
|
+
commsDir: path15.resolve(commsDir),
|
|
3681
|
+
skipInstall,
|
|
3682
|
+
repoRoot
|
|
3683
|
+
};
|
|
3684
|
+
logHeader(`@hua-labs/tap init-worktree`);
|
|
3685
|
+
log(`Path: ${opts.worktreePath}`);
|
|
3686
|
+
log(`Branch: ${opts.branch}`);
|
|
3687
|
+
log(`Base: ${opts.base}`);
|
|
3688
|
+
log(`Comms: ${opts.commsDir}`);
|
|
3689
|
+
if (mission) log(`Mission: ${mission}`);
|
|
3690
|
+
log("");
|
|
3691
|
+
const warnings = [];
|
|
3692
|
+
const created = step1CreateWorktree(opts);
|
|
3693
|
+
if (!created) {
|
|
3694
|
+
return {
|
|
3695
|
+
ok: false,
|
|
3696
|
+
command: "init-worktree",
|
|
3697
|
+
code: "TAP_PATCH_FAILED",
|
|
3698
|
+
message: "Failed to create worktree.",
|
|
3699
|
+
warnings,
|
|
3700
|
+
data: {}
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
step2MergeMain(opts, warnings);
|
|
3704
|
+
step3CopyPermissions(opts, warnings);
|
|
3705
|
+
step4GenerateMcpJson(opts, warnings);
|
|
3706
|
+
step5Install(opts, warnings);
|
|
3707
|
+
step6BuildEslintPlugin(opts, warnings);
|
|
3708
|
+
step7VerifyComms(opts, warnings);
|
|
3709
|
+
step8VerifyBun(warnings);
|
|
3710
|
+
step9Ready(opts);
|
|
3711
|
+
return {
|
|
3712
|
+
ok: true,
|
|
3713
|
+
command: "init-worktree",
|
|
3714
|
+
code: "TAP_INIT_OK",
|
|
3715
|
+
message: `Worktree initialized: ${opts.worktreePath}`,
|
|
3716
|
+
warnings,
|
|
3717
|
+
data: {
|
|
3718
|
+
path: opts.worktreePath,
|
|
3719
|
+
branch: opts.branch,
|
|
3720
|
+
commsDir: opts.commsDir
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
// src/engine/dashboard.ts
|
|
3726
|
+
import * as fs16 from "fs";
|
|
3727
|
+
import * as path16 from "path";
|
|
3728
|
+
import { execSync as execSync6 } from "child_process";
|
|
3729
|
+
function collectAgents(commsDir) {
|
|
3730
|
+
const heartbeatsPath = path16.join(commsDir, "heartbeats.json");
|
|
3731
|
+
if (!fs16.existsSync(heartbeatsPath)) return [];
|
|
3732
|
+
try {
|
|
3733
|
+
const raw = fs16.readFileSync(heartbeatsPath, "utf-8");
|
|
3734
|
+
const data = JSON.parse(raw);
|
|
3735
|
+
return Object.entries(data).map(([name, info]) => ({
|
|
3736
|
+
name: info.agent ?? name,
|
|
3737
|
+
status: info.status ?? null,
|
|
3738
|
+
lastActivity: info.lastActivity ?? info.timestamp ?? null,
|
|
3739
|
+
joinedAt: info.joinedAt ?? null
|
|
3740
|
+
}));
|
|
3741
|
+
} catch {
|
|
3742
|
+
return [];
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
function collectBridges(repoRoot) {
|
|
3746
|
+
const state = loadState(repoRoot);
|
|
3747
|
+
const { config } = resolveConfig({}, repoRoot);
|
|
3748
|
+
const stateDir = config.stateDir;
|
|
3749
|
+
const bridges = [];
|
|
3750
|
+
if (state) {
|
|
3751
|
+
for (const [id, inst] of Object.entries(state.instances)) {
|
|
3752
|
+
if (!inst?.installed) continue;
|
|
3753
|
+
if (inst.bridgeMode !== "app-server") continue;
|
|
3754
|
+
const instanceId = id;
|
|
3755
|
+
const status = getBridgeStatus(stateDir, instanceId);
|
|
3756
|
+
const bridgeState = loadBridgeState(stateDir, instanceId);
|
|
3757
|
+
const age = getHeartbeatAge(stateDir, instanceId);
|
|
3758
|
+
bridges.push({
|
|
3759
|
+
instanceId: id,
|
|
3760
|
+
runtime: inst.runtime,
|
|
3761
|
+
status,
|
|
3762
|
+
pid: bridgeState?.pid ?? null,
|
|
3763
|
+
port: inst.port ?? null,
|
|
3764
|
+
heartbeatAge: age,
|
|
3765
|
+
headless: inst.headless?.enabled ?? false
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
const tmpDir = path16.join(repoRoot, ".tmp");
|
|
3770
|
+
if (fs16.existsSync(tmpDir)) {
|
|
3771
|
+
try {
|
|
3772
|
+
const dirs = fs16.readdirSync(tmpDir).filter((d) => d.startsWith("codex-app-server-bridge"));
|
|
3773
|
+
for (const dir of dirs) {
|
|
3774
|
+
const daemonPath = path16.join(tmpDir, dir, "bridge-daemon.json");
|
|
3775
|
+
if (!fs16.existsSync(daemonPath)) continue;
|
|
3776
|
+
try {
|
|
3777
|
+
const raw = fs16.readFileSync(daemonPath, "utf-8");
|
|
3778
|
+
const daemon = JSON.parse(raw);
|
|
3779
|
+
const alreadyCovered = bridges.some(
|
|
3780
|
+
(b) => b.pid === daemon.pid && b.pid !== null
|
|
3781
|
+
);
|
|
3782
|
+
if (alreadyCovered) continue;
|
|
3783
|
+
const agentFile = path16.join(tmpDir, dir, "agent-name.txt");
|
|
3784
|
+
const agentName = fs16.existsSync(agentFile) ? fs16.readFileSync(agentFile, "utf-8").trim() : dir;
|
|
3785
|
+
const running = daemon.pid ? isProcessAlive(daemon.pid) : false;
|
|
3786
|
+
const portMatch = daemon.appServerUrl?.match(/:(\d+)/);
|
|
3787
|
+
const port = portMatch ? parseInt(portMatch[1], 10) : null;
|
|
3788
|
+
bridges.push({
|
|
3789
|
+
instanceId: agentName,
|
|
3790
|
+
runtime: "codex",
|
|
3791
|
+
status: running ? "running" : "stale",
|
|
3792
|
+
pid: daemon.pid ?? null,
|
|
3793
|
+
port,
|
|
3794
|
+
heartbeatAge: null,
|
|
3795
|
+
headless: false
|
|
3796
|
+
});
|
|
3797
|
+
} catch {
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
} catch {
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
return bridges;
|
|
3804
|
+
}
|
|
3805
|
+
function collectPRs() {
|
|
3806
|
+
try {
|
|
3807
|
+
const output = execSync6(
|
|
3808
|
+
"gh pr list --state all --limit 10 --json number,title,author,state,url",
|
|
3809
|
+
{ encoding: "utf-8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] }
|
|
3810
|
+
);
|
|
3811
|
+
const prs = JSON.parse(output);
|
|
3812
|
+
return prs.map((pr) => ({
|
|
3813
|
+
number: pr.number,
|
|
3814
|
+
title: pr.title,
|
|
3815
|
+
author: pr.author.login,
|
|
3816
|
+
state: pr.state,
|
|
3817
|
+
url: pr.url
|
|
3818
|
+
}));
|
|
3819
|
+
} catch {
|
|
3820
|
+
return [];
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
function collectWarnings(bridges, agents) {
|
|
3824
|
+
const warnings = [];
|
|
3825
|
+
for (const bridge of bridges) {
|
|
3826
|
+
if (bridge.status === "stale") {
|
|
3827
|
+
warnings.push({
|
|
3828
|
+
level: "warn",
|
|
3829
|
+
message: `Bridge ${bridge.instanceId} is stale (PID ${bridge.pid} dead)`
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
if (bridge.status === "running" && bridge.heartbeatAge !== null && bridge.heartbeatAge > 60) {
|
|
3833
|
+
warnings.push({
|
|
3834
|
+
level: "warn",
|
|
3835
|
+
message: `Bridge ${bridge.instanceId} heartbeat stale (${bridge.heartbeatAge}s ago)`
|
|
3836
|
+
});
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
if (bridges.length === 0) {
|
|
3840
|
+
warnings.push({
|
|
3841
|
+
level: "warn",
|
|
3842
|
+
message: "No bridges configured"
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
if (agents.length === 0) {
|
|
3846
|
+
warnings.push({
|
|
3847
|
+
level: "warn",
|
|
3848
|
+
message: "No agent heartbeats found"
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
return warnings;
|
|
3852
|
+
}
|
|
3853
|
+
function collectDashboardSnapshot(repoRoot, commsDirOverride) {
|
|
3854
|
+
const { config } = resolveConfig(
|
|
3855
|
+
commsDirOverride ? { commsDir: commsDirOverride } : {},
|
|
3856
|
+
repoRoot
|
|
3857
|
+
);
|
|
3858
|
+
const resolved = config;
|
|
3859
|
+
const agents = collectAgents(resolved.commsDir);
|
|
3860
|
+
const bridges = collectBridges(resolved.repoRoot);
|
|
3861
|
+
const prs = collectPRs();
|
|
3862
|
+
const warnings = collectWarnings(bridges, agents);
|
|
3863
|
+
return {
|
|
3864
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3865
|
+
repoRoot: resolved.repoRoot,
|
|
3866
|
+
commsDir: resolved.commsDir,
|
|
3867
|
+
agents,
|
|
3868
|
+
bridges,
|
|
3869
|
+
prs,
|
|
3870
|
+
warnings
|
|
3871
|
+
};
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
// src/commands/dashboard.ts
|
|
3875
|
+
function formatAge2(seconds) {
|
|
3876
|
+
if (seconds === null) return "-";
|
|
3877
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
3878
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
3879
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m ago`;
|
|
3880
|
+
}
|
|
3881
|
+
function formatStatus(status) {
|
|
3882
|
+
switch (status) {
|
|
3883
|
+
case "running":
|
|
3884
|
+
return "running";
|
|
3885
|
+
case "stale":
|
|
3886
|
+
return "stale!";
|
|
3887
|
+
case "stopped":
|
|
3888
|
+
return "stopped";
|
|
3889
|
+
case "MERGED":
|
|
3890
|
+
return "merged";
|
|
3891
|
+
case "OPEN":
|
|
3892
|
+
return "open";
|
|
3893
|
+
case "CLOSED":
|
|
3894
|
+
return "closed";
|
|
3895
|
+
default:
|
|
3896
|
+
return status;
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
function truncate(str, len) {
|
|
3900
|
+
return str.length > len ? str.slice(0, len - 1) + "\u2026" : str;
|
|
3901
|
+
}
|
|
3902
|
+
function renderSnapshot(snapshot) {
|
|
3903
|
+
logHeader("tap dashboard");
|
|
3904
|
+
log(`Time: ${snapshot.generatedAt}`);
|
|
3905
|
+
log(`Repo: ${snapshot.repoRoot}`);
|
|
3906
|
+
log(`Comms: ${snapshot.commsDir}`);
|
|
3907
|
+
log("");
|
|
3908
|
+
log("\u2500\u2500 Agents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
3909
|
+
if (snapshot.agents.length === 0) {
|
|
3910
|
+
log(" (no heartbeats)");
|
|
3911
|
+
} else {
|
|
3912
|
+
for (const agent of snapshot.agents) {
|
|
3913
|
+
const activity = agent.lastActivity ? formatAge2(
|
|
3914
|
+
Math.floor(
|
|
3915
|
+
(Date.now() - new Date(agent.lastActivity).getTime()) / 1e3
|
|
3916
|
+
)
|
|
3917
|
+
) : "unknown";
|
|
3918
|
+
const status = agent.status ?? "unknown";
|
|
3919
|
+
log(` ${agent.name.padEnd(12)} ${status.padEnd(10)} active ${activity}`);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
log("");
|
|
3923
|
+
log("\u2500\u2500 Bridges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
3924
|
+
if (snapshot.bridges.length === 0) {
|
|
3925
|
+
log(" (none)");
|
|
3926
|
+
} else {
|
|
3927
|
+
log(
|
|
3928
|
+
` ${"Instance".padEnd(20)} ${"Status".padEnd(10)} ${"PID".padEnd(8)} ${"Port".padEnd(6)} ${"Heartbeat"}`
|
|
3929
|
+
);
|
|
3930
|
+
log(
|
|
3931
|
+
` ${"\u2500".repeat(20)} ${"\u2500".repeat(10)} ${"\u2500".repeat(8)} ${"\u2500".repeat(6)} ${"\u2500".repeat(12)}`
|
|
3932
|
+
);
|
|
3933
|
+
for (const b of snapshot.bridges) {
|
|
3934
|
+
const headlessTag = b.headless ? " [H]" : "";
|
|
3935
|
+
log(
|
|
3936
|
+
` ${truncate(b.instanceId + headlessTag, 20).padEnd(20)} ${formatStatus(b.status).padEnd(10)} ${(b.pid ? String(b.pid) : "-").padEnd(8)} ${(b.port ? String(b.port) : "-").padEnd(6)} ${formatAge2(b.heartbeatAge)}`
|
|
3937
|
+
);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
log("");
|
|
3941
|
+
log("\u2500\u2500 PRs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
3942
|
+
if (snapshot.prs.length === 0) {
|
|
3943
|
+
log(" (gh CLI unavailable or no PRs)");
|
|
3944
|
+
} else {
|
|
3945
|
+
for (const pr of snapshot.prs) {
|
|
3946
|
+
const icon = pr.state === "MERGED" ? "+" : pr.state === "OPEN" ? "~" : "x";
|
|
3947
|
+
log(
|
|
3948
|
+
` ${icon} #${String(pr.number).padEnd(5)} ${formatStatus(pr.state).padEnd(8)} ${truncate(pr.title, 50)}`
|
|
3949
|
+
);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
log("");
|
|
3953
|
+
log("\u2500\u2500 Warnings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
3954
|
+
if (snapshot.warnings.length === 0) {
|
|
3955
|
+
log(" [OK] no warnings");
|
|
3956
|
+
} else {
|
|
3957
|
+
for (const w of snapshot.warnings) {
|
|
3958
|
+
const prefix = w.level === "error" ? "[ERR]" : "[WARN]";
|
|
3959
|
+
log(` ${prefix} ${w.message}`);
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
async function dashboardCommand(args) {
|
|
3964
|
+
const { flags } = parseArgs(args);
|
|
3965
|
+
const jsonMode = flags["json"] === true;
|
|
3966
|
+
const watchMode = flags["watch"] === true;
|
|
3967
|
+
const intervalStr = typeof flags["interval"] === "string" ? flags["interval"] : "5";
|
|
3968
|
+
const intervalSeconds = Math.max(2, parseInt(intervalStr, 10) || 5);
|
|
3969
|
+
const commsDirOverride = typeof flags["comms-dir"] === "string" ? flags["comms-dir"] : void 0;
|
|
3970
|
+
const repoRoot = findRepoRoot2();
|
|
3971
|
+
if (watchMode) {
|
|
3972
|
+
const run2 = () => {
|
|
3973
|
+
const snapshot2 = collectDashboardSnapshot(repoRoot, commsDirOverride);
|
|
3974
|
+
if (jsonMode) {
|
|
3975
|
+
console.log(JSON.stringify(snapshot2, null, 2));
|
|
3976
|
+
} else {
|
|
3977
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
3978
|
+
renderSnapshot(snapshot2);
|
|
3979
|
+
log("");
|
|
3980
|
+
log(` Refreshing every ${intervalSeconds}s \u2014 Ctrl+C to exit`);
|
|
3981
|
+
}
|
|
3982
|
+
};
|
|
3983
|
+
run2();
|
|
3984
|
+
const timer = setInterval(run2, intervalSeconds * 1e3);
|
|
3985
|
+
const cleanup = () => {
|
|
3986
|
+
clearInterval(timer);
|
|
3987
|
+
process.exit(0);
|
|
3988
|
+
};
|
|
3989
|
+
process.on("SIGINT", cleanup);
|
|
3990
|
+
process.on("SIGTERM", cleanup);
|
|
3991
|
+
await new Promise(() => {
|
|
3992
|
+
});
|
|
3993
|
+
return {
|
|
3994
|
+
ok: true,
|
|
3995
|
+
command: "unknown",
|
|
3996
|
+
code: "TAP_NO_OP",
|
|
3997
|
+
message: "Watch mode ended",
|
|
3998
|
+
warnings: [],
|
|
3999
|
+
data: {}
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
const snapshot = collectDashboardSnapshot(repoRoot, commsDirOverride);
|
|
4003
|
+
if (jsonMode) {
|
|
4004
|
+
console.log(JSON.stringify(snapshot, null, 2));
|
|
4005
|
+
} else {
|
|
4006
|
+
renderSnapshot(snapshot);
|
|
4007
|
+
}
|
|
4008
|
+
return {
|
|
4009
|
+
ok: true,
|
|
4010
|
+
command: "dashboard",
|
|
4011
|
+
code: "TAP_STATUS_OK",
|
|
4012
|
+
message: `Dashboard: ${snapshot.bridges.length} bridge(s), ${snapshot.agents.length} agent(s), ${snapshot.prs.length} PR(s)`,
|
|
4013
|
+
warnings: snapshot.warnings.map((w) => w.message),
|
|
4014
|
+
data: snapshot
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
// src/output.ts
|
|
4019
|
+
function emitResult(result, jsonMode) {
|
|
4020
|
+
if (jsonMode) {
|
|
4021
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
if (result.ok) {
|
|
4025
|
+
logSuccess(result.message);
|
|
4026
|
+
} else {
|
|
4027
|
+
logError(result.message);
|
|
4028
|
+
}
|
|
4029
|
+
for (const w of result.warnings) {
|
|
4030
|
+
logWarn(w);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
function exitCode(result) {
|
|
4034
|
+
return result.ok ? 0 : 1;
|
|
4035
|
+
}
|
|
4036
|
+
function extractJsonFlag(args) {
|
|
4037
|
+
const jsonMode = args.includes("--json");
|
|
4038
|
+
const cleanArgs = args.filter((a) => a !== "--json");
|
|
4039
|
+
return { jsonMode, cleanArgs };
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
// src/cli.ts
|
|
4043
|
+
var HELP = `
|
|
4044
|
+
@hua-labs/tap \u2014 Cross-model AI agent communication setup
|
|
4045
|
+
|
|
4046
|
+
Usage:
|
|
4047
|
+
tap-comms <command> [options]
|
|
4048
|
+
|
|
4049
|
+
Commands:
|
|
4050
|
+
init Initialize comms directory and state
|
|
4051
|
+
init-worktree Set up a new git worktree with tap-comms
|
|
4052
|
+
add <runtime> Add a runtime instance (claude, codex, gemini)
|
|
4053
|
+
remove <instance> Remove an instance and rollback config
|
|
4054
|
+
status Show installed instances and bridge status
|
|
4055
|
+
bridge <sub> [inst] Manage bridges (start, stop, status)
|
|
4056
|
+
dashboard Show unified ops dashboard
|
|
4057
|
+
serve Start tap-comms MCP server (stdio)
|
|
4058
|
+
version Show version
|
|
4059
|
+
|
|
4060
|
+
Options:
|
|
4061
|
+
--help, -h Show help
|
|
4062
|
+
--json Machine-readable JSON output
|
|
4063
|
+
--comms-dir <path> Override comms directory path
|
|
4064
|
+
|
|
4065
|
+
Examples:
|
|
4066
|
+
npx @hua-labs/tap init
|
|
4067
|
+
npx @hua-labs/tap init-worktree --path ../hua-wt-3 --branch feat/my-feature
|
|
4068
|
+
npx @hua-labs/tap add claude
|
|
4069
|
+
npx @hua-labs/tap add codex --name reviewer --port 4501
|
|
4070
|
+
npx @hua-labs/tap status
|
|
4071
|
+
`.trim();
|
|
4072
|
+
function normalizeCommandName(command) {
|
|
4073
|
+
switch (command) {
|
|
4074
|
+
case "init":
|
|
4075
|
+
case "init-worktree":
|
|
4076
|
+
case "add":
|
|
4077
|
+
case "remove":
|
|
4078
|
+
case "status":
|
|
4079
|
+
case "bridge":
|
|
4080
|
+
case "dashboard":
|
|
4081
|
+
case "serve":
|
|
4082
|
+
return command;
|
|
4083
|
+
default:
|
|
4084
|
+
return "unknown";
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
async function main() {
|
|
4088
|
+
const rawArgs = process.argv.slice(2);
|
|
4089
|
+
const { jsonMode, cleanArgs } = extractJsonFlag(rawArgs);
|
|
4090
|
+
setJsonMode(jsonMode);
|
|
4091
|
+
const command = cleanArgs[0];
|
|
4092
|
+
if (!command || command === "--help" || command === "-h") {
|
|
4093
|
+
if (jsonMode) {
|
|
4094
|
+
console.log(JSON.stringify({ help: HELP }));
|
|
4095
|
+
} else {
|
|
4096
|
+
console.log(HELP);
|
|
4097
|
+
}
|
|
4098
|
+
process.exit(0);
|
|
4099
|
+
}
|
|
4100
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
4101
|
+
if (jsonMode) {
|
|
4102
|
+
console.log(JSON.stringify({ version }));
|
|
4103
|
+
} else {
|
|
4104
|
+
console.log(`@hua-labs/tap v${version}`);
|
|
4105
|
+
}
|
|
4106
|
+
process.exit(0);
|
|
4107
|
+
}
|
|
4108
|
+
const commandArgs = cleanArgs.slice(1);
|
|
4109
|
+
let result;
|
|
4110
|
+
try {
|
|
4111
|
+
switch (command) {
|
|
4112
|
+
case "init":
|
|
4113
|
+
result = await initCommand(commandArgs);
|
|
4114
|
+
break;
|
|
4115
|
+
case "init-worktree":
|
|
4116
|
+
result = await initWorktreeCommand(commandArgs);
|
|
4117
|
+
break;
|
|
4118
|
+
case "add":
|
|
4119
|
+
result = await addCommand(commandArgs);
|
|
4120
|
+
break;
|
|
4121
|
+
case "remove":
|
|
4122
|
+
result = await removeCommand(commandArgs);
|
|
4123
|
+
break;
|
|
4124
|
+
case "status":
|
|
4125
|
+
result = await statusCommand(commandArgs);
|
|
4126
|
+
break;
|
|
4127
|
+
case "bridge":
|
|
4128
|
+
result = await bridgeCommand(commandArgs);
|
|
4129
|
+
break;
|
|
4130
|
+
case "dashboard":
|
|
4131
|
+
result = await dashboardCommand(commandArgs);
|
|
4132
|
+
break;
|
|
4133
|
+
case "serve": {
|
|
4134
|
+
const serveResult = await serveCommand(commandArgs);
|
|
4135
|
+
if (!serveResult.ok) {
|
|
4136
|
+
emitResult(serveResult, jsonMode);
|
|
4137
|
+
}
|
|
4138
|
+
process.exit(exitCode(serveResult));
|
|
4139
|
+
break;
|
|
4140
|
+
}
|
|
4141
|
+
default:
|
|
4142
|
+
result = {
|
|
4143
|
+
ok: false,
|
|
4144
|
+
command: "unknown",
|
|
4145
|
+
code: "TAP_INVALID_ARGUMENT",
|
|
4146
|
+
message: `Unknown command: ${command}`,
|
|
4147
|
+
warnings: [],
|
|
4148
|
+
data: { requestedCommand: command }
|
|
4149
|
+
};
|
|
4150
|
+
}
|
|
4151
|
+
} catch (err) {
|
|
4152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4153
|
+
result = {
|
|
4154
|
+
ok: false,
|
|
4155
|
+
command: normalizeCommandName(command),
|
|
4156
|
+
code: "TAP_INTERNAL_ERROR",
|
|
4157
|
+
message,
|
|
4158
|
+
warnings: [],
|
|
4159
|
+
data: command ? { requestedCommand: command } : {}
|
|
4160
|
+
};
|
|
4161
|
+
}
|
|
4162
|
+
emitResult(result, jsonMode);
|
|
4163
|
+
process.exit(exitCode(result));
|
|
4164
|
+
}
|
|
4165
|
+
main();
|
|
4166
|
+
//# sourceMappingURL=cli.mjs.map
|