tabctl 0.3.0 → 0.4.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 CHANGED
@@ -25,7 +25,7 @@ If you haven't run `npm link`, you can always use `node ./cli/tabctl.js` instead
25
25
 
26
26
  ### 2. Set up your browser
27
27
 
28
- Run the interactive setup — it syncs the extension, tells you where to load it, and prompts for the extension ID:
28
+ Run setup — it syncs the extension, tells you where to load it, and auto-derives the extension ID:
29
29
 
30
30
  <!-- test: "setup explicit --extension-id overrides auto-derived ID" -->
31
31
  ```bash
@@ -36,11 +36,11 @@ This will:
36
36
  1. Copy the extension to a stable location (`~/.local/state/tabctl/extension/`)
37
37
  2. Print the path (and copy it to your clipboard)
38
38
  3. Ask you to load it as an unpacked extension in `chrome://extensions`
39
- 4. Prompt you to paste the extension ID
39
+ 4. Auto-derive the extension ID from the installed path
40
40
 
41
41
  > **Edge?** Use `--browser edge` and load from `edge://extensions` instead.
42
42
 
43
- If you already know your extension ID, skip the interactive flow:
43
+ If you need to override the auto-derived ID (e.g. for a custom extension path):
44
44
 
45
45
  <!-- test: "setup writes native host manifest for chrome" -->
46
46
  ```bash
@@ -63,8 +63,10 @@ tabctl list # see your open tabs
63
63
  | Command | Description |
64
64
  |---------|-------------|
65
65
  | `tabctl list` | List open tabs and groups |
66
+ | `tabctl open --url <url> --group <name>` | Open tabs into a group (reuses existing, skips duplicates) |
66
67
  | `tabctl analyze` | Find stale or duplicate tabs |
67
68
  | `tabctl inspect --tab <id>` | Extract page metadata or CSS selectors |
69
+ | `tabctl group-gather` | Merge duplicate groups with the same name |
68
70
  | `tabctl close --tab <id>` | Close tabs with full undo support |
69
71
  | `tabctl report` | Generate reports in JSON, Markdown, or CSV |
70
72
  | `tabctl undo` | Revert the last action |
@@ -167,10 +169,10 @@ tabctl supports multiple browser profiles. Each profile connects to a different
167
169
  <!-- test: "setup writes native host manifest", "setup writes native host manifest for chrome", "setup --name creates custom-named profile", "profile-list with multiple profiles shows all", "profile-switch success updates default", "--profile flag overrides active profile" -->
168
170
  ```bash
169
171
  # Setup for Edge
170
- tabctl setup --browser edge --extension-id <edge-id>
172
+ tabctl setup --browser edge
171
173
 
172
174
  # Setup for Chrome (with custom name)
173
- tabctl setup --browser chrome --extension-id <chrome-id> --name chrome-work
175
+ tabctl setup --browser chrome --name chrome-work
174
176
 
175
177
  # List profiles
176
178
  tabctl profile-list
@@ -6,7 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createRequestId = createRequestId;
7
7
  exports.sendRequest = sendRequest;
8
8
  exports.fetchSnapshot = fetchSnapshot;
9
- const net_1 = __importDefault(require("net"));
9
+ exports.sendFireAndForget = sendFireAndForget;
10
+ const node_net_1 = __importDefault(require("node:net"));
10
11
  const constants_1 = require("./constants");
11
12
  function createRequestId() {
12
13
  return `req-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
@@ -14,7 +15,7 @@ function createRequestId() {
14
15
  function sendRequest(payload, onProgress) {
15
16
  return new Promise((resolve, reject) => {
16
17
  const { socketPath } = (0, constants_1.resolveConfig)();
17
- const client = net_1.default.createConnection(socketPath);
18
+ const client = node_net_1.default.createConnection(socketPath);
18
19
  let buffer = "";
19
20
  client.on("connect", () => {
20
21
  client.write(`${JSON.stringify(payload)}\n`);
@@ -60,3 +61,23 @@ async function fetchSnapshot() {
60
61
  }
61
62
  return response.data;
62
63
  }
64
+ /** Send a request without waiting for a response (fire-and-forget). */
65
+ function sendFireAndForget(payload) {
66
+ try {
67
+ const { socketPath } = (0, constants_1.resolveConfig)();
68
+ const client = node_net_1.default.createConnection(socketPath);
69
+ client.on("connect", () => {
70
+ client.write(`${JSON.stringify(payload)}\n`);
71
+ // Unref after write so Node can exit without waiting for response
72
+ client.unref();
73
+ const timer = setTimeout(() => { client.end(); client.destroy(); }, 200);
74
+ timer.unref();
75
+ });
76
+ client.on("error", () => {
77
+ // Silently ignore — this is best-effort
78
+ });
79
+ }
80
+ catch {
81
+ // Silently ignore
82
+ }
83
+ }
@@ -4,7 +4,7 @@
4
4
  * Re-exports all command handlers and parameter builders.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.buildUndoParams = exports.buildHistoryParams = exports.buildScreenshotParams = exports.buildReportParams = exports.buildCloseParams = exports.buildArchiveParams = exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupAssignParams = exports.buildGroupUngroupParams = exports.buildGroupUpdateParams = exports.buildOpenParams = exports.buildRefreshParams = exports.buildFocusParams = exports.buildInspectParams = exports.buildAnalyzeParams = exports.runProfileRemove = exports.runProfileSwitch = exports.runProfileShow = exports.runProfileList = exports.runGroupList = exports.runList = exports.runPing = exports.runUndo = exports.runHistory = exports.runPolicy = exports.runVersion = exports.runSkillInstall = exports.runSetup = void 0;
7
+ exports.buildUndoParams = exports.buildHistoryParams = exports.buildScreenshotParams = exports.buildReportParams = exports.buildCloseParams = exports.buildArchiveParams = exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupGatherParams = exports.buildGroupAssignParams = exports.buildGroupUngroupParams = exports.buildGroupUpdateParams = exports.buildOpenParams = exports.buildRefreshParams = exports.buildFocusParams = exports.buildInspectParams = exports.buildAnalyzeParams = exports.runProfileRemove = exports.runProfileSwitch = exports.runProfileShow = exports.runProfileList = exports.runGroupList = exports.runList = exports.runPing = exports.runUndo = exports.runHistory = exports.runPolicy = exports.runVersion = exports.runSkillInstall = exports.runSetup = void 0;
8
8
  // Setup command
9
9
  var setup_1 = require("./setup");
10
10
  Object.defineProperty(exports, "runSetup", { enumerable: true, get: function () { return setup_1.runSetup; } });
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "buildOpenParams", { enumerable: true, get: funct
36
36
  Object.defineProperty(exports, "buildGroupUpdateParams", { enumerable: true, get: function () { return params_1.buildGroupUpdateParams; } });
37
37
  Object.defineProperty(exports, "buildGroupUngroupParams", { enumerable: true, get: function () { return params_1.buildGroupUngroupParams; } });
38
38
  Object.defineProperty(exports, "buildGroupAssignParams", { enumerable: true, get: function () { return params_1.buildGroupAssignParams; } });
39
+ Object.defineProperty(exports, "buildGroupGatherParams", { enumerable: true, get: function () { return params_1.buildGroupGatherParams; } });
39
40
  Object.defineProperty(exports, "buildMoveTabParams", { enumerable: true, get: function () { return params_1.buildMoveTabParams; } });
40
41
  Object.defineProperty(exports, "buildMoveGroupParams", { enumerable: true, get: function () { return params_1.buildMoveGroupParams; } });
41
42
  Object.defineProperty(exports, "buildMergeWindowParams", { enumerable: true, get: function () { return params_1.buildMergeWindowParams; } });
@@ -15,9 +15,9 @@ exports.runPolicy = runPolicy;
15
15
  exports.runHistory = runHistory;
16
16
  exports.runUndo = runUndo;
17
17
  exports.runPing = runPing;
18
- const fs_1 = __importDefault(require("fs"));
19
- const os_1 = __importDefault(require("os"));
20
- const path_1 = __importDefault(require("path"));
18
+ const node_fs_1 = __importDefault(require("node:fs"));
19
+ const node_os_1 = __importDefault(require("node:os"));
20
+ const node_path_1 = __importDefault(require("node:path"));
21
21
  const node_child_process_1 = require("node:child_process");
22
22
  const constants_1 = require("../constants");
23
23
  const output_1 = require("../output");
@@ -31,23 +31,23 @@ Object.defineProperty(exports, "runSetup", { enumerable: true, get: function ()
31
31
  // ============================================================================
32
32
  function resolveProjectRoot() {
33
33
  try {
34
- return fs_1.default.realpathSync(process.cwd());
34
+ return node_fs_1.default.realpathSync(process.cwd());
35
35
  }
36
36
  catch {
37
- return path_1.default.resolve(process.cwd());
37
+ return node_path_1.default.resolve(process.cwd());
38
38
  }
39
39
  }
40
40
  function resolveConfigHome() {
41
- return process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), ".config");
41
+ return process.env.XDG_CONFIG_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".config");
42
42
  }
43
43
  function resolveSkillTargetDir(globalInstall) {
44
44
  if (globalInstall) {
45
- return path_1.default.join(resolveConfigHome(), "opencode", "skills", constants_1.SKILL_NAME);
45
+ return node_path_1.default.join(resolveConfigHome(), "opencode", "skills", constants_1.SKILL_NAME);
46
46
  }
47
- return path_1.default.join(resolveProjectRoot(), ".opencode", "skills", constants_1.SKILL_NAME);
47
+ return node_path_1.default.join(resolveProjectRoot(), ".opencode", "skills", constants_1.SKILL_NAME);
48
48
  }
49
49
  function runSkillsCli(args) {
50
- const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe" });
50
+ const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe", shell: process.platform === "win32" });
51
51
  if (result.error) {
52
52
  (0, output_1.errorOut)(`Failed to run skills CLI: ${result.error.message}`);
53
53
  }
@@ -116,7 +116,7 @@ function runVersion(prettyOutput) {
116
116
  function runPolicy(options, policyContext, prettyOutput) {
117
117
  const policyPath = (0, policy_1.defaultPolicyPath)();
118
118
  if (options.init) {
119
- if (fs_1.default.existsSync(policyPath)) {
119
+ if (node_fs_1.default.existsSync(policyPath)) {
120
120
  (0, output_1.printJson)({
121
121
  ok: true,
122
122
  data: {
@@ -126,9 +126,9 @@ function runPolicy(options, policyContext, prettyOutput) {
126
126
  }, prettyOutput);
127
127
  return;
128
128
  }
129
- const dir = path_1.default.dirname(policyPath);
130
- fs_1.default.mkdirSync(dir, { recursive: true });
131
- fs_1.default.writeFileSync(policyPath, JSON.stringify((0, policy_1.defaultPolicyTemplate)(), null, 2), "utf8");
129
+ const dir = node_path_1.default.dirname(policyPath);
130
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
131
+ node_fs_1.default.writeFileSync(policyPath, JSON.stringify((0, policy_1.defaultPolicyTemplate)(), null, 2), "utf8");
132
132
  (0, output_1.printJson)({
133
133
  ok: true,
134
134
  data: {
@@ -166,6 +166,7 @@ async function runHistory(options, prettyOutput) {
166
166
  dirty: constants_1.DIRTY,
167
167
  },
168
168
  });
169
+ (0, output_1.emitVersionWarnings)(response, "history");
169
170
  (0, output_1.printJson)(response, prettyOutput);
170
171
  if (!response.ok) {
171
172
  process.exit(1);
@@ -198,6 +199,7 @@ async function runUndo(options, prettyOutput) {
198
199
  dirty: constants_1.DIRTY,
199
200
  },
200
201
  });
202
+ (0, output_1.emitVersionWarnings)(response, "undo");
201
203
  (0, output_1.printJson)(response, prettyOutput);
202
204
  if (!response.ok) {
203
205
  process.exit(1);
@@ -219,6 +221,7 @@ async function runPing(prettyOutput) {
219
221
  dirty: constants_1.DIRTY,
220
222
  },
221
223
  });
224
+ (0, output_1.emitVersionWarnings)(response, "ping");
222
225
  (0, output_1.printJson)(response, prettyOutput);
223
226
  if (!response.ok) {
224
227
  process.exit(1);
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.buildGroupUpdateParams = buildGroupUpdateParams;
7
7
  exports.buildGroupUngroupParams = buildGroupUngroupParams;
8
8
  exports.buildGroupAssignParams = buildGroupAssignParams;
9
+ exports.buildGroupGatherParams = buildGroupGatherParams;
9
10
  const params_1 = require("./params");
10
11
  function buildGroupUpdateParams(options) {
11
12
  const windowValue = (0, params_1.parseWindowScope)(options.window, { allowNew: false });
@@ -38,3 +39,10 @@ function buildGroupAssignParams(options) {
38
39
  collapsed: options.collapsed === true ? true : options.expanded === true ? false : undefined,
39
40
  };
40
41
  }
42
+ function buildGroupGatherParams(options) {
43
+ const windowValue = (0, params_1.parseWindowScope)(options.window, { allowNew: false });
44
+ return {
45
+ windowId: windowValue,
46
+ groupTitle: options.group,
47
+ };
48
+ }
@@ -7,7 +7,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupUngroupParams = exports.buildGroupAssignParams = exports.buildGroupUpdateParams = void 0;
10
+ exports.buildMergeWindowParams = exports.buildMoveGroupParams = exports.buildMoveTabParams = exports.buildGroupGatherParams = exports.buildGroupUngroupParams = exports.buildGroupAssignParams = exports.buildGroupUpdateParams = void 0;
11
11
  exports.buildAnalyzeParams = buildAnalyzeParams;
12
12
  exports.buildInspectParams = buildInspectParams;
13
13
  exports.buildFocusParams = buildFocusParams;
@@ -20,7 +20,7 @@ exports.buildScreenshotParams = buildScreenshotParams;
20
20
  exports.parseWindowScope = parseWindowScope;
21
21
  exports.buildHistoryParams = buildHistoryParams;
22
22
  exports.buildUndoParams = buildUndoParams;
23
- const fs_1 = __importDefault(require("fs"));
23
+ const node_fs_1 = __importDefault(require("node:fs"));
24
24
  const output_1 = require("../output");
25
25
  const args_1 = require("../args");
26
26
  // ============================================================================
@@ -99,7 +99,7 @@ function buildInspectParams(options) {
99
99
  let signalConfig;
100
100
  if (options["signal-config"]) {
101
101
  try {
102
- const configRaw = fs_1.default.readFileSync(String(options["signal-config"]), "utf8");
102
+ const configRaw = node_fs_1.default.readFileSync(String(options["signal-config"]), "utf8");
103
103
  signalConfig = JSON.parse(configRaw);
104
104
  }
105
105
  catch {
@@ -164,6 +164,8 @@ function buildOpenParams(options) {
164
164
  windowGroupTitle: options["window-group"],
165
165
  windowTabId: options["window-tab"] ? Number(options["window-tab"]) : undefined,
166
166
  windowUrl: options["window-url"],
167
+ newGroup: options["new-group"] === true,
168
+ allowDuplicates: options["allow-duplicates"] === true,
167
169
  };
168
170
  }
169
171
  // ============================================================================
@@ -173,6 +175,7 @@ var params_groups_1 = require("./params-groups");
173
175
  Object.defineProperty(exports, "buildGroupUpdateParams", { enumerable: true, get: function () { return params_groups_1.buildGroupUpdateParams; } });
174
176
  Object.defineProperty(exports, "buildGroupAssignParams", { enumerable: true, get: function () { return params_groups_1.buildGroupAssignParams; } });
175
177
  Object.defineProperty(exports, "buildGroupUngroupParams", { enumerable: true, get: function () { return params_groups_1.buildGroupUngroupParams; } });
178
+ Object.defineProperty(exports, "buildGroupGatherParams", { enumerable: true, get: function () { return params_groups_1.buildGroupGatherParams; } });
176
179
  // ============================================================================
177
180
  // Move Command Parameters (re-exported from params-move.ts)
178
181
  // ============================================================================
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * Setup command handler: interactive browser profile configuration.
3
+ * Setup command handler: browser profile configuration.
4
4
  * Extracted from meta.ts for modularity.
5
5
  */
6
6
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -9,16 +9,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.resolveBrowser = resolveBrowser;
11
11
  exports.resolveExtensionId = resolveExtensionId;
12
- exports.promptExtensionId = promptExtensionId;
13
12
  exports.resolveNodePath = resolveNodePath;
14
13
  exports.resolveManifestDir = resolveManifestDir;
15
14
  exports.writeWrapper = writeWrapper;
16
15
  exports.runSetup = runSetup;
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");
16
+ const node_fs_1 = __importDefault(require("node:fs"));
17
+ const node_os_1 = __importDefault(require("node:os"));
18
+ const node_path_1 = __importDefault(require("node:path"));
22
19
  const constants_1 = require("../constants");
23
20
  const output_1 = require("../output");
24
21
  const profiles_1 = require("../../../shared/profiles");
@@ -49,76 +46,6 @@ function resolveExtensionId(options, required) {
49
46
  }
50
47
  return value;
51
48
  }
52
- async function promptExtensionId(browser) {
53
- const maxAttempts = 3;
54
- const extPage = browser === "chrome" ? "chrome://extensions" : "edge://extensions";
55
- const instructions = [
56
- "",
57
- "Next steps:",
58
- ` 1. Open ${extPage}`,
59
- " 2. Enable Developer mode",
60
- ' 3. Click "Load unpacked" and select the path above',
61
- " 4. Copy the extension ID shown on the extensions page",
62
- "",
63
- ].join("\n");
64
- process.stderr.write(instructions);
65
- // Collect lines from stdin and provide them on demand
66
- const lines = [];
67
- let closed = false;
68
- let waiting = null;
69
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stderr, terminal: false });
70
- rl.on("line", (line) => {
71
- if (waiting) {
72
- const cb = waiting;
73
- waiting = null;
74
- cb(line.trim());
75
- }
76
- else {
77
- lines.push(line.trim());
78
- }
79
- });
80
- rl.on("close", () => {
81
- closed = true;
82
- if (waiting) {
83
- const cb = waiting;
84
- waiting = null;
85
- cb(null);
86
- }
87
- });
88
- const nextLine = (prompt) => {
89
- process.stderr.write(prompt);
90
- if (lines.length > 0) {
91
- return Promise.resolve(lines.shift());
92
- }
93
- if (closed)
94
- return Promise.resolve(null);
95
- return new Promise((resolve) => { waiting = resolve; });
96
- };
97
- try {
98
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
99
- const raw = await nextLine("Paste the extension ID: ");
100
- if (raw === null) {
101
- (0, output_1.errorOut)("No input received (stdin closed).");
102
- }
103
- const value = raw.toLowerCase();
104
- if (constants_1.EXTENSION_ID_PATTERN.test(value)) {
105
- return value;
106
- }
107
- const remaining = maxAttempts - attempt;
108
- if (remaining > 0) {
109
- process.stderr.write(`Invalid extension ID (expected 32 lowercase a-p characters). ${remaining} attempt(s) remaining.\n`);
110
- }
111
- else {
112
- (0, output_1.errorOut)("Invalid extension ID after 3 attempts.");
113
- }
114
- }
115
- }
116
- finally {
117
- rl.close();
118
- }
119
- // unreachable due to errorOut, but satisfies TypeScript
120
- return "";
121
- }
122
49
  function resolveNodePath(options) {
123
50
  const raw = typeof options.node === "string"
124
51
  ? String(options.node)
@@ -127,38 +54,106 @@ function resolveNodePath(options) {
127
54
  if (!value) {
128
55
  (0, output_1.errorOut)("Node binary not found. Set --node or TABCTL_NODE.");
129
56
  }
130
- if (!path_1.default.isAbsolute(value)) {
57
+ if (!node_path_1.default.isAbsolute(value)) {
131
58
  (0, output_1.errorOut)(`Node path must be absolute: ${value}`);
132
59
  }
133
- try {
134
- fs_1.default.accessSync(value, fs_1.default.constants.X_OK);
60
+ if (process.platform !== "win32") {
61
+ try {
62
+ node_fs_1.default.accessSync(value, node_fs_1.default.constants.X_OK);
63
+ }
64
+ catch {
65
+ (0, output_1.errorOut)(`Node binary not executable: ${value}`);
66
+ }
135
67
  }
136
- catch {
137
- (0, output_1.errorOut)(`Node binary not executable: ${value}`);
68
+ else {
69
+ try {
70
+ node_fs_1.default.accessSync(value, node_fs_1.default.constants.R_OK);
71
+ }
72
+ catch {
73
+ (0, output_1.errorOut)(`Node binary not found: ${value}`);
74
+ }
138
75
  }
139
76
  return value;
140
77
  }
141
- function resolveHostPath() {
142
- const root = path_1.default.resolve(__dirname, "../../..");
143
- const hostPath = path_1.default.join(root, "host", "host.js");
144
- if (!fs_1.default.existsSync(hostPath)) {
145
- (0, output_1.errorOut)(`Host script not found at ${hostPath}. Run: npm run build`);
78
+ function resolveHostPath(dataDir) {
79
+ // Sync host bundle to stable path so wrapper survives npm upgrades
80
+ try {
81
+ const result = (0, extension_sync_1.syncHost)(dataDir);
82
+ return result.hostPath;
83
+ }
84
+ catch (err) {
85
+ const detail = err instanceof Error ? err.message : String(err);
86
+ (0, output_1.errorOut)(`Failed to resolve native host. Make sure the CLI is built (run: npm run build). Details: ${detail}`);
146
87
  }
147
- return hostPath;
148
88
  }
149
89
  function resolveManifestDir(browser) {
150
- const home = os_1.default.homedir();
90
+ const home = node_os_1.default.homedir();
151
91
  if (!home) {
152
92
  (0, output_1.errorOut)("Home directory not found.");
153
93
  }
94
+ if (process.platform === "win32") {
95
+ // Windows: registry-based is preferred, but file-based works with --user-data-dir.
96
+ // For system-wide, we point to the per-user NativeMessagingHosts under LOCALAPPDATA.
97
+ const base = process.env.LOCALAPPDATA || node_path_1.default.join(home, "AppData", "Local");
98
+ if (browser === "edge") {
99
+ return node_path_1.default.join(base, "Microsoft", "Edge", "User Data", "NativeMessagingHosts");
100
+ }
101
+ return node_path_1.default.join(base, "Google", "Chrome", "User Data", "NativeMessagingHosts");
102
+ }
103
+ if (process.platform === "linux") {
104
+ if (browser === "edge") {
105
+ return node_path_1.default.join(home, ".config", "microsoft-edge", "NativeMessagingHosts");
106
+ }
107
+ return node_path_1.default.join(home, ".config", "google-chrome", "NativeMessagingHosts");
108
+ }
109
+ // macOS
154
110
  if (browser === "edge") {
155
- return path_1.default.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
111
+ return node_path_1.default.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
156
112
  }
157
- return path_1.default.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
113
+ return node_path_1.default.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
158
114
  }
159
115
  function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
160
- fs_1.default.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
161
- const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.sh");
116
+ node_fs_1.default.mkdirSync(wrapperDir, { recursive: true });
117
+ if (process.platform !== "win32") {
118
+ try {
119
+ node_fs_1.default.chmodSync(wrapperDir, 0o700);
120
+ }
121
+ catch { /* ignore */ }
122
+ }
123
+ if (process.platform === "win32") {
124
+ // Prefer the Go launcher binary from the platform package.
125
+ // Falls back to a .cmd wrapper if unavailable (dev/testing only —
126
+ // .cmd wrappers don't work for Chrome native messaging).
127
+ let exeSrc;
128
+ try {
129
+ exeSrc = require.resolve("tabctl-win32-x64/tabctl-host.exe");
130
+ }
131
+ catch {
132
+ // Not installed
133
+ }
134
+ if (exeSrc) {
135
+ const exeDst = node_path_1.default.join(wrapperDir, "tabctl-host.exe");
136
+ node_fs_1.default.copyFileSync(exeSrc, exeDst);
137
+ const cfgLines = [nodePath, hostPath];
138
+ if (profileName) {
139
+ cfgLines.push(`TABCTL_PROFILE=${profileName}`);
140
+ }
141
+ cfgLines.push("");
142
+ node_fs_1.default.writeFileSync(node_path_1.default.join(wrapperDir, "host-launcher.cfg"), cfgLines.join("\r\n"), "utf8");
143
+ return exeDst;
144
+ }
145
+ // Fallback: .cmd wrapper (won't work with Chrome native messaging)
146
+ const wrapperPath = node_path_1.default.join(wrapperDir, "tabctl-host.cmd");
147
+ const lines = ["@echo off"];
148
+ if (profileName) {
149
+ lines.push(`set TABCTL_PROFILE=${profileName}`);
150
+ }
151
+ lines.push(`"${nodePath}" "${hostPath}" %*`);
152
+ lines.push("");
153
+ node_fs_1.default.writeFileSync(wrapperPath, lines.join("\r\n"), "utf8");
154
+ return wrapperPath;
155
+ }
156
+ const wrapperPath = node_path_1.default.join(wrapperDir, "tabctl-host.sh");
162
157
  const escapedNode = nodePath.replace(/"/g, "\\\"");
163
158
  const escapedHost = hostPath.replace(/"/g, "\\\"");
164
159
  const lines = [
@@ -171,22 +166,19 @@ function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
171
166
  lines.push(`exec \"${escapedNode}\" \"${escapedHost}\"`);
172
167
  lines.push("");
173
168
  const wrapper = lines.join("\n");
174
- fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
175
- fs_1.default.chmodSync(wrapperPath, 0o700);
169
+ node_fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
170
+ node_fs_1.default.chmodSync(wrapperPath, 0o700);
176
171
  return wrapperPath;
177
172
  }
178
- async function runSetup(options, prettyOutput) {
179
- if (process.platform !== "darwin") {
180
- (0, output_1.errorOut)("tabctl setup is only supported on macOS.");
181
- }
173
+ function runSetup(options, prettyOutput) {
182
174
  const browser = resolveBrowser(options.browser);
183
175
  if (!browser) {
184
176
  (0, output_1.errorOut)("Missing or invalid --browser (edge|chrome)");
185
177
  }
186
178
  const nodePath = resolveNodePath(options);
187
- const hostPath = resolveHostPath();
188
- // Sync extension to stable path (before extensionId so interactive mode can show it)
179
+ // Sync extension + host to stable paths (before extensionId so interactive mode can show it)
189
180
  const config = (0, constants_1.resolveConfig)();
181
+ const hostPath = resolveHostPath(config.baseDataDir);
190
182
  let extensionSync;
191
183
  try {
192
184
  extensionSync = (0, extension_sync_1.syncExtension)(config.baseDataDir);
@@ -198,29 +190,15 @@ async function runSetup(options, prettyOutput) {
198
190
  let extensionId = resolveExtensionId(options, false);
199
191
  if (!extensionId) {
200
192
  // Auto-derive from the installed extension path (Chromium uses SHA256 of the path)
201
- const installedDir = (0, extension_sync_1.resolveInstalledExtensionDir)(config.baseDataDir);
202
- if (fs_1.default.existsSync(path_1.default.join(installedDir, "manifest.json"))) {
193
+ // Prefer the just-synced path; fall back to resolving independently
194
+ const installedDir = extensionSync?.extensionDir ?? (0, extension_sync_1.resolveInstalledExtensionDir)(config.baseDataDir);
195
+ if (node_fs_1.default.existsSync(node_path_1.default.join(installedDir, "manifest.json"))) {
203
196
  extensionId = (0, extension_sync_1.deriveExtensionId)(installedDir);
204
197
  process.stderr.write(`Extension ID derived from: ${installedDir}\n`);
205
198
  }
206
199
  }
207
200
  if (!extensionId) {
208
- // Interactive mode: sync hadn't happened or path doesn't exist
209
- if (extensionSync?.extensionDir) {
210
- process.stderr.write(`\nExtension synced to: ${extensionSync.extensionDir}\n`);
211
- try {
212
- const pbcopy = (0, node_child_process_1.spawn)("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
213
- pbcopy.stdin.end(extensionSync.extensionDir);
214
- pbcopy.on("exit", (code) => {
215
- if (code === 0)
216
- process.stderr.write("(Path copied to clipboard)\n");
217
- });
218
- }
219
- catch {
220
- // clipboard copy is best-effort
221
- }
222
- }
223
- extensionId = await promptExtensionId(browser);
201
+ (0, output_1.errorOut)("Could not derive extension ID (extension not synced). Use --extension-id <id> or set TABCTL_EXTENSION_ID.");
224
202
  }
225
203
  // Profile name: --name flag or browser type
226
204
  const profileName = typeof options.name === "string" && options.name.trim()
@@ -233,20 +211,20 @@ async function runSetup(options, prettyOutput) {
233
211
  (0, output_1.errorOut)(err.message);
234
212
  }
235
213
  // Profile data dir (use baseDataDir to avoid nesting under another profile)
236
- const profileDataDir = path_1.default.join(config.baseDataDir, "profiles", profileName);
237
- fs_1.default.mkdirSync(profileDataDir, { recursive: true, mode: 0o700 });
214
+ const profileDataDir = node_path_1.default.join(config.baseDataDir, "profiles", profileName);
215
+ node_fs_1.default.mkdirSync(profileDataDir, { recursive: true });
238
216
  // Write profile-specific wrapper
239
217
  const wrapperPath = writeWrapper(nodePath, hostPath, profileName, profileDataDir);
240
218
  // Resolve manifest directory: custom user-data-dir or system-wide
241
219
  const rawUserDataDir = typeof options["user-data-dir"] === "string"
242
220
  ? options["user-data-dir"].trim()
243
221
  : "";
244
- const userDataDir = rawUserDataDir ? path_1.default.resolve(rawUserDataDir) : "";
222
+ const userDataDir = rawUserDataDir ? node_path_1.default.resolve(rawUserDataDir) : "";
245
223
  const manifestDir = userDataDir
246
- ? path_1.default.join(userDataDir, "NativeMessagingHosts")
224
+ ? node_path_1.default.join(userDataDir, "NativeMessagingHosts")
247
225
  : resolveManifestDir(browser);
248
- fs_1.default.mkdirSync(manifestDir, { recursive: true });
249
- const manifestPath = path_1.default.join(manifestDir, `${constants_1.HOST_NAME}.json`);
226
+ node_fs_1.default.mkdirSync(manifestDir, { recursive: true });
227
+ const manifestPath = node_path_1.default.join(manifestDir, `${constants_1.HOST_NAME}.json`);
250
228
  const manifest = {
251
229
  name: constants_1.HOST_NAME,
252
230
  description: constants_1.HOST_DESCRIPTION,
@@ -254,7 +232,7 @@ async function runSetup(options, prettyOutput) {
254
232
  type: "stdio",
255
233
  allowed_origins: [`chrome-extension://${extensionId}/`],
256
234
  };
257
- fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
235
+ node_fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
258
236
  // Register profile
259
237
  const profileEntry = {
260
238
  browser,
@@ -85,6 +85,8 @@ exports.COMMANDS = {
85
85
  { flag: "--window-group <name>", desc: "Find window containing group" },
86
86
  { flag: "--window-tab <id>", desc: "Find window containing tab" },
87
87
  { flag: "--window-url <substring>", desc: "Find window containing URL" },
88
+ { flag: "--new-group", desc: "Force new group even if one exists" },
89
+ { flag: "--allow-duplicates", desc: "Open duplicate URLs" },
88
90
  ],
89
91
  },
90
92
  "group-list": {
@@ -129,6 +131,13 @@ exports.COMMANDS = {
129
131
  { flag: "--expanded", desc: "Expand group after assign" },
130
132
  ],
131
133
  },
134
+ "group-gather": {
135
+ description: "Merge duplicate groups with the same name",
136
+ options: [
137
+ { flag: "--window <id|active|last-focused>", desc: "Target window" },
138
+ { flag: "--group <name>", desc: "Group name to gather (optional, gathers all if omitted)" },
139
+ ],
140
+ },
132
141
  "move-tab": {
133
142
  description: "Move a tab to a new position",
134
143
  options: [
@@ -167,7 +176,7 @@ exports.COMMANDS = {
167
176
  description: "Configure tabctl connection",
168
177
  options: [
169
178
  { flag: "--browser edge|chrome", desc: "Browser type" },
170
- { flag: "--extension-id <id>", desc: "Extension ID to connect to" },
179
+ { flag: "--extension-id <id>", desc: "Override auto-derived extension ID" },
171
180
  { flag: "--node <path>", desc: "Path to Node.js executable" },
172
181
  { flag: "--name <name>", desc: "Profile name (default: browser type)" },
173
182
  { flag: "--user-data-dir <path>", desc: "Chrome/Edge user data directory for custom profiles" },
@@ -253,4 +262,7 @@ exports.COMMANDS = {
253
262
  ping: {
254
263
  description: "Test connection to browser extension",
255
264
  },
265
+ reload: {
266
+ description: "Reload the browser extension (internal, used for upgrades)",
267
+ },
256
268
  };