tabctl 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.
@@ -0,0 +1,493 @@
1
+ "use strict";
2
+ /**
3
+ * Meta command handlers: version, ping, setup, skill, policy, history, undo
4
+ * These commands don't operate on tab data and are relatively self-contained.
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.runSetup = runSetup;
11
+ exports.runSkillInstall = runSkillInstall;
12
+ exports.runVersion = runVersion;
13
+ exports.runPolicy = runPolicy;
14
+ exports.runHistory = runHistory;
15
+ exports.runUndo = runUndo;
16
+ exports.runPing = runPing;
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const readline_1 = __importDefault(require("readline"));
21
+ const node_child_process_1 = require("node:child_process");
22
+ const constants_1 = require("../constants");
23
+ const output_1 = require("../output");
24
+ const client_1 = require("../client");
25
+ const policy_1 = require("../policy");
26
+ const profiles_1 = require("../../../shared/profiles");
27
+ const extension_sync_1 = require("../../../shared/extension-sync");
28
+ // ============================================================================
29
+ // Setup Command
30
+ // ============================================================================
31
+ function resolveBrowser(value) {
32
+ if (typeof value !== "string") {
33
+ return null;
34
+ }
35
+ const trimmed = value.trim().toLowerCase();
36
+ if (trimmed === "edge" || trimmed === "chrome") {
37
+ return trimmed;
38
+ }
39
+ return null;
40
+ }
41
+ function resolveExtensionId(options, required) {
42
+ const raw = typeof options["extension-id"] === "string"
43
+ ? String(options["extension-id"])
44
+ : (process.env.TABCTL_EXTENSION_ID || "");
45
+ const value = raw.trim().toLowerCase();
46
+ if (!value) {
47
+ if (!required)
48
+ return null;
49
+ (0, output_1.errorOut)("Missing --extension-id (or TABCTL_EXTENSION_ID)");
50
+ }
51
+ if (!constants_1.EXTENSION_ID_PATTERN.test(value)) {
52
+ (0, output_1.errorOut)(`Extension ID looks unusual: ${raw}`);
53
+ }
54
+ return value;
55
+ }
56
+ async function promptExtensionId(browser) {
57
+ const maxAttempts = 3;
58
+ const extPage = browser === "chrome" ? "chrome://extensions" : "edge://extensions";
59
+ const instructions = [
60
+ "",
61
+ "Next steps:",
62
+ ` 1. Open ${extPage}`,
63
+ " 2. Enable Developer mode",
64
+ ' 3. Click "Load unpacked" and select the path above',
65
+ " 4. Copy the extension ID shown on the extensions page",
66
+ "",
67
+ ].join("\n");
68
+ process.stderr.write(instructions);
69
+ // Collect lines from stdin and provide them on demand
70
+ const lines = [];
71
+ let closed = false;
72
+ let waiting = null;
73
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stderr, terminal: false });
74
+ rl.on("line", (line) => {
75
+ if (waiting) {
76
+ const cb = waiting;
77
+ waiting = null;
78
+ cb(line.trim());
79
+ }
80
+ else {
81
+ lines.push(line.trim());
82
+ }
83
+ });
84
+ rl.on("close", () => {
85
+ closed = true;
86
+ if (waiting) {
87
+ const cb = waiting;
88
+ waiting = null;
89
+ cb(null);
90
+ }
91
+ });
92
+ const nextLine = (prompt) => {
93
+ process.stderr.write(prompt);
94
+ if (lines.length > 0) {
95
+ return Promise.resolve(lines.shift());
96
+ }
97
+ if (closed)
98
+ return Promise.resolve(null);
99
+ return new Promise((resolve) => { waiting = resolve; });
100
+ };
101
+ try {
102
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
103
+ const raw = await nextLine("Paste the extension ID: ");
104
+ if (raw === null) {
105
+ (0, output_1.errorOut)("No input received (stdin closed).");
106
+ }
107
+ const value = raw.toLowerCase();
108
+ if (constants_1.EXTENSION_ID_PATTERN.test(value)) {
109
+ return value;
110
+ }
111
+ const remaining = maxAttempts - attempt;
112
+ if (remaining > 0) {
113
+ process.stderr.write(`Invalid extension ID (expected 32 lowercase a-p characters). ${remaining} attempt(s) remaining.\n`);
114
+ }
115
+ else {
116
+ (0, output_1.errorOut)("Invalid extension ID after 3 attempts.");
117
+ }
118
+ }
119
+ }
120
+ finally {
121
+ rl.close();
122
+ }
123
+ // unreachable due to errorOut, but satisfies TypeScript
124
+ return "";
125
+ }
126
+ function resolveNodePath(options) {
127
+ const raw = typeof options.node === "string"
128
+ ? String(options.node)
129
+ : (process.env.TABCTL_NODE || process.execPath || "");
130
+ const value = raw.trim();
131
+ if (!value) {
132
+ (0, output_1.errorOut)("Node binary not found. Set --node or TABCTL_NODE.");
133
+ }
134
+ if (!path_1.default.isAbsolute(value)) {
135
+ (0, output_1.errorOut)(`Node path must be absolute: ${value}`);
136
+ }
137
+ try {
138
+ fs_1.default.accessSync(value, fs_1.default.constants.X_OK);
139
+ }
140
+ catch {
141
+ (0, output_1.errorOut)(`Node binary not executable: ${value}`);
142
+ }
143
+ return value;
144
+ }
145
+ function resolveHostPath() {
146
+ const root = path_1.default.resolve(__dirname, "../../..");
147
+ const hostPath = path_1.default.join(root, "host", "host.js");
148
+ if (!fs_1.default.existsSync(hostPath)) {
149
+ (0, output_1.errorOut)(`Host script not found at ${hostPath}. Run: npm run build`);
150
+ }
151
+ return hostPath;
152
+ }
153
+ function resolveManifestDir(browser) {
154
+ const home = os_1.default.homedir();
155
+ if (!home) {
156
+ (0, output_1.errorOut)("Home directory not found.");
157
+ }
158
+ if (browser === "edge") {
159
+ return path_1.default.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
160
+ }
161
+ return path_1.default.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
162
+ }
163
+ function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
164
+ fs_1.default.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
165
+ const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.sh");
166
+ const escapedNode = nodePath.replace(/"/g, "\\\"");
167
+ const escapedHost = hostPath.replace(/"/g, "\\\"");
168
+ const lines = [
169
+ "#!/usr/bin/env bash",
170
+ "set -euo pipefail",
171
+ ];
172
+ if (profileName) {
173
+ lines.push(`export TABCTL_PROFILE="${profileName}"`);
174
+ }
175
+ lines.push(`exec \"${escapedNode}\" \"${escapedHost}\"`);
176
+ lines.push("");
177
+ const wrapper = lines.join("\n");
178
+ fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
179
+ fs_1.default.chmodSync(wrapperPath, 0o700);
180
+ return wrapperPath;
181
+ }
182
+ async function runSetup(options, prettyOutput) {
183
+ if (process.platform !== "darwin") {
184
+ (0, output_1.errorOut)("tabctl setup is only supported on macOS.");
185
+ }
186
+ const browser = resolveBrowser(options.browser);
187
+ if (!browser) {
188
+ (0, output_1.errorOut)("Missing or invalid --browser (edge|chrome)");
189
+ }
190
+ const nodePath = resolveNodePath(options);
191
+ const hostPath = resolveHostPath();
192
+ // Sync extension to stable path (before extensionId so interactive mode can show it)
193
+ const config = (0, constants_1.resolveConfig)();
194
+ let extensionSync;
195
+ try {
196
+ extensionSync = (0, extension_sync_1.syncExtension)(config.baseDataDir);
197
+ }
198
+ catch {
199
+ extensionSync = null;
200
+ }
201
+ // Resolve extension ID: non-interactive if provided, interactive otherwise
202
+ let extensionId = resolveExtensionId(options, false);
203
+ if (!extensionId) {
204
+ // Interactive mode
205
+ if (extensionSync?.extensionDir) {
206
+ process.stderr.write(`\nExtension synced to: ${extensionSync.extensionDir}\n`);
207
+ try {
208
+ const pbcopy = (0, node_child_process_1.spawn)("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
209
+ pbcopy.stdin.end(extensionSync.extensionDir);
210
+ pbcopy.on("exit", (code) => {
211
+ if (code === 0)
212
+ process.stderr.write("(Path copied to clipboard)\n");
213
+ });
214
+ }
215
+ catch {
216
+ // clipboard copy is best-effort
217
+ }
218
+ }
219
+ extensionId = await promptExtensionId(browser);
220
+ }
221
+ // Profile name: --name flag or browser type
222
+ const profileName = typeof options.name === "string" && options.name.trim()
223
+ ? options.name.trim().toLowerCase()
224
+ : browser;
225
+ try {
226
+ (0, profiles_1.validateProfileName)(profileName);
227
+ }
228
+ catch (err) {
229
+ (0, output_1.errorOut)(err.message);
230
+ }
231
+ // Profile data dir (use baseDataDir to avoid nesting under another profile)
232
+ const profileDataDir = path_1.default.join(config.baseDataDir, "profiles", profileName);
233
+ fs_1.default.mkdirSync(profileDataDir, { recursive: true, mode: 0o700 });
234
+ // Write profile-specific wrapper
235
+ const wrapperPath = writeWrapper(nodePath, hostPath, profileName, profileDataDir);
236
+ // Resolve manifest directory: custom user-data-dir or system-wide
237
+ const rawUserDataDir = typeof options["user-data-dir"] === "string"
238
+ ? options["user-data-dir"].trim()
239
+ : "";
240
+ const userDataDir = rawUserDataDir ? path_1.default.resolve(rawUserDataDir) : "";
241
+ const manifestDir = userDataDir
242
+ ? path_1.default.join(userDataDir, "NativeMessagingHosts")
243
+ : resolveManifestDir(browser);
244
+ fs_1.default.mkdirSync(manifestDir, { recursive: true });
245
+ const manifestPath = path_1.default.join(manifestDir, `${constants_1.HOST_NAME}.json`);
246
+ const manifest = {
247
+ name: constants_1.HOST_NAME,
248
+ description: constants_1.HOST_DESCRIPTION,
249
+ path: wrapperPath,
250
+ type: "stdio",
251
+ allowed_origins: [`chrome-extension://${extensionId}/`],
252
+ };
253
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
254
+ // Register profile
255
+ const profileEntry = {
256
+ browser,
257
+ extensionId,
258
+ nodePath,
259
+ hostPath,
260
+ dataDir: profileDataDir,
261
+ };
262
+ if (userDataDir) {
263
+ profileEntry.userDataDir = userDataDir;
264
+ }
265
+ const registry = (0, profiles_1.addProfile)(profileName, profileEntry);
266
+ (0, output_1.printJson)({
267
+ ok: true,
268
+ action: "setup",
269
+ data: {
270
+ profileName,
271
+ browser,
272
+ extensionId,
273
+ manifestPath,
274
+ hostPath,
275
+ nodePath,
276
+ wrapperPath,
277
+ dataDir: profileDataDir,
278
+ ...(userDataDir ? { userDataDir } : {}),
279
+ isDefault: registry.default === profileName,
280
+ extensionDir: extensionSync?.extensionDir || null,
281
+ extensionSynced: extensionSync?.synced || false,
282
+ },
283
+ }, prettyOutput);
284
+ }
285
+ // ============================================================================
286
+ // Skill Command
287
+ // ============================================================================
288
+ function formatCliArgValue(value) {
289
+ const raw = String(value);
290
+ if (!raw) {
291
+ return raw;
292
+ }
293
+ if (/[\s"]/g.test(raw)) {
294
+ const escaped = raw.replace(/"/g, "\\\"");
295
+ return `"${escaped}"`;
296
+ }
297
+ return raw;
298
+ }
299
+ function resolveProjectRoot() {
300
+ try {
301
+ return fs_1.default.realpathSync(process.cwd());
302
+ }
303
+ catch {
304
+ return path_1.default.resolve(process.cwd());
305
+ }
306
+ }
307
+ function resolveConfigHome() {
308
+ return process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), ".config");
309
+ }
310
+ function resolveSkillTargetDir(globalInstall) {
311
+ if (globalInstall) {
312
+ return path_1.default.join(resolveConfigHome(), "opencode", "skills", constants_1.SKILL_NAME);
313
+ }
314
+ return path_1.default.join(resolveProjectRoot(), ".opencode", "skills", constants_1.SKILL_NAME);
315
+ }
316
+ function runSkillsCli(args) {
317
+ const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe" });
318
+ if (result.error) {
319
+ (0, output_1.errorOut)(`Failed to run skills CLI: ${result.error.message}`);
320
+ }
321
+ if (typeof result.status === "number" && result.status !== 0) {
322
+ const stderr = result.stderr ? result.stderr.toString().trim() : "";
323
+ const stdout = result.stdout ? result.stdout.toString().trim() : "";
324
+ const detail = stderr || stdout;
325
+ const message = detail ? `skills CLI failed: ${detail}` : `skills CLI exited with status ${result.status}`;
326
+ (0, output_1.errorOut)(message);
327
+ }
328
+ }
329
+ function runSkillInstall(options, prettyOutput) {
330
+ const globalInstall = options.global === true;
331
+ const installTarget = resolveSkillTargetDir(globalInstall);
332
+ const agents = Array.isArray(options.agent)
333
+ ? options.agent.filter((value) => typeof value === "string" && value.trim())
334
+ : [];
335
+ const args = ["add", constants_1.SKILL_REPO, "--skill", constants_1.SKILL_NAME];
336
+ if (agents.length > 0) {
337
+ for (const agent of agents) {
338
+ args.push("-a", agent);
339
+ }
340
+ }
341
+ if (globalInstall) {
342
+ args.push("-g");
343
+ }
344
+ const hintAgents = agents.length > 0 ? agents.map((agent) => `-a ${formatCliArgValue(agent)}`).join(" ") : "";
345
+ const installHintParts = ["npx skills add", formatCliArgValue(constants_1.SKILL_REPO), "--skill", constants_1.SKILL_NAME];
346
+ if (hintAgents) {
347
+ installHintParts.push(hintAgents);
348
+ }
349
+ if (globalInstall) {
350
+ installHintParts.push("-g");
351
+ }
352
+ const installHint = installHintParts.join(" ").trim();
353
+ runSkillsCli(args);
354
+ (0, output_1.printJson)({
355
+ ok: true,
356
+ data: {
357
+ name: constants_1.SKILL_NAME,
358
+ targetDir: installTarget,
359
+ scope: globalInstall ? "global" : "project",
360
+ installHint,
361
+ tool: "skills",
362
+ },
363
+ }, prettyOutput);
364
+ }
365
+ // ============================================================================
366
+ // Version Command
367
+ // ============================================================================
368
+ function runVersion(prettyOutput) {
369
+ (0, output_1.printJson)({
370
+ ok: true,
371
+ data: {
372
+ version: constants_1.VERSION,
373
+ baseVersion: constants_1.BASE_VERSION,
374
+ gitSha: constants_1.GIT_SHA,
375
+ dirty: constants_1.DIRTY,
376
+ component: "cli",
377
+ },
378
+ }, prettyOutput);
379
+ }
380
+ // ============================================================================
381
+ // Policy Command
382
+ // ============================================================================
383
+ function runPolicy(options, policyContext, prettyOutput) {
384
+ const policyPath = (0, policy_1.defaultPolicyPath)();
385
+ if (options.init) {
386
+ if (fs_1.default.existsSync(policyPath)) {
387
+ (0, output_1.printJson)({
388
+ ok: true,
389
+ data: {
390
+ status: "exists",
391
+ path: policyPath,
392
+ },
393
+ }, prettyOutput);
394
+ return;
395
+ }
396
+ const dir = path_1.default.dirname(policyPath);
397
+ fs_1.default.mkdirSync(dir, { recursive: true });
398
+ fs_1.default.writeFileSync(policyPath, JSON.stringify((0, policy_1.defaultPolicyTemplate)(), null, 2), "utf8");
399
+ (0, output_1.printJson)({
400
+ ok: true,
401
+ data: {
402
+ status: "created",
403
+ path: policyPath,
404
+ },
405
+ }, prettyOutput);
406
+ return;
407
+ }
408
+ const policySummary = (0, policy_1.summarizePolicy)(policyContext.policy, policyContext.path);
409
+ (0, output_1.printJson)({
410
+ ok: true,
411
+ data: {
412
+ ...policySummary,
413
+ path: policyPath,
414
+ },
415
+ }, prettyOutput);
416
+ }
417
+ // ============================================================================
418
+ // History Command
419
+ // ============================================================================
420
+ async function runHistory(options, prettyOutput) {
421
+ const params = {
422
+ limit: options.limit ? Number(options.limit) : undefined,
423
+ };
424
+ const response = await (0, client_1.sendRequest)({
425
+ id: (0, client_1.createRequestId)(),
426
+ action: "history",
427
+ params,
428
+ client: {
429
+ component: "cli",
430
+ version: constants_1.VERSION,
431
+ baseVersion: constants_1.BASE_VERSION,
432
+ gitSha: constants_1.GIT_SHA,
433
+ dirty: constants_1.DIRTY,
434
+ },
435
+ });
436
+ (0, output_1.printJson)(response, prettyOutput);
437
+ if (!response.ok) {
438
+ process.exit(1);
439
+ }
440
+ }
441
+ // ============================================================================
442
+ // Undo Command
443
+ // ============================================================================
444
+ async function runUndo(options, prettyOutput) {
445
+ const positionalTxid = options._[0];
446
+ const flagTxid = options.txid;
447
+ const useLatest = options.latest === true;
448
+ if (useLatest && (positionalTxid || flagTxid)) {
449
+ (0, output_1.errorOut)("--latest cannot be combined with a txid argument or --txid");
450
+ }
451
+ const txid = positionalTxid || flagTxid;
452
+ const params = {
453
+ txid,
454
+ latest: useLatest,
455
+ };
456
+ const response = await (0, client_1.sendRequest)({
457
+ id: (0, client_1.createRequestId)(),
458
+ action: "undo",
459
+ params,
460
+ client: {
461
+ component: "cli",
462
+ version: constants_1.VERSION,
463
+ baseVersion: constants_1.BASE_VERSION,
464
+ gitSha: constants_1.GIT_SHA,
465
+ dirty: constants_1.DIRTY,
466
+ },
467
+ });
468
+ (0, output_1.printJson)(response, prettyOutput);
469
+ if (!response.ok) {
470
+ process.exit(1);
471
+ }
472
+ }
473
+ // ============================================================================
474
+ // Ping Command
475
+ // ============================================================================
476
+ async function runPing(prettyOutput) {
477
+ const response = await (0, client_1.sendRequest)({
478
+ id: (0, client_1.createRequestId)(),
479
+ action: "ping",
480
+ params: {},
481
+ client: {
482
+ component: "cli",
483
+ version: constants_1.VERSION,
484
+ baseVersion: constants_1.BASE_VERSION,
485
+ gitSha: constants_1.GIT_SHA,
486
+ dirty: constants_1.DIRTY,
487
+ },
488
+ });
489
+ (0, output_1.printJson)(response, prettyOutput);
490
+ if (!response.ok) {
491
+ process.exit(1);
492
+ }
493
+ }