tabctl 0.3.1 → 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
@@ -7,7 +7,7 @@ exports.createRequestId = createRequestId;
7
7
  exports.sendRequest = sendRequest;
8
8
  exports.fetchSnapshot = fetchSnapshot;
9
9
  exports.sendFireAndForget = sendFireAndForget;
10
- const net_1 = __importDefault(require("net"));
10
+ const node_net_1 = __importDefault(require("node:net"));
11
11
  const constants_1 = require("./constants");
12
12
  function createRequestId() {
13
13
  return `req-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
@@ -15,7 +15,7 @@ function createRequestId() {
15
15
  function sendRequest(payload, onProgress) {
16
16
  return new Promise((resolve, reject) => {
17
17
  const { socketPath } = (0, constants_1.resolveConfig)();
18
- const client = net_1.default.createConnection(socketPath);
18
+ const client = node_net_1.default.createConnection(socketPath);
19
19
  let buffer = "";
20
20
  client.on("connect", () => {
21
21
  client.write(`${JSON.stringify(payload)}\n`);
@@ -65,7 +65,7 @@ async function fetchSnapshot() {
65
65
  function sendFireAndForget(payload) {
66
66
  try {
67
67
  const { socketPath } = (0, constants_1.resolveConfig)();
68
- const client = net_1.default.createConnection(socketPath);
68
+ const client = node_net_1.default.createConnection(socketPath);
69
69
  client.on("connect", () => {
70
70
  client.write(`${JSON.stringify(payload)}\n`);
71
71
  // Unref after write so Node can exit without waiting for response
@@ -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,20 +31,20 @@ 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
50
  const result = (0, node_child_process_1.spawnSync)("npx", ["skills", ...args], { stdio: "pipe", shell: process.platform === "win32" });
@@ -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: {
@@ -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,12 +54,12 @@ 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
60
  if (process.platform !== "win32") {
134
61
  try {
135
- fs_1.default.accessSync(value, fs_1.default.constants.X_OK);
62
+ node_fs_1.default.accessSync(value, node_fs_1.default.constants.X_OK);
136
63
  }
137
64
  catch {
138
65
  (0, output_1.errorOut)(`Node binary not executable: ${value}`);
@@ -140,7 +67,7 @@ function resolveNodePath(options) {
140
67
  }
141
68
  else {
142
69
  try {
143
- fs_1.default.accessSync(value, fs_1.default.constants.R_OK);
70
+ node_fs_1.default.accessSync(value, node_fs_1.default.constants.R_OK);
144
71
  }
145
72
  catch {
146
73
  (0, output_1.errorOut)(`Node binary not found: ${value}`);
@@ -160,36 +87,36 @@ function resolveHostPath(dataDir) {
160
87
  }
161
88
  }
162
89
  function resolveManifestDir(browser) {
163
- const home = os_1.default.homedir();
90
+ const home = node_os_1.default.homedir();
164
91
  if (!home) {
165
92
  (0, output_1.errorOut)("Home directory not found.");
166
93
  }
167
94
  if (process.platform === "win32") {
168
95
  // Windows: registry-based is preferred, but file-based works with --user-data-dir.
169
96
  // For system-wide, we point to the per-user NativeMessagingHosts under LOCALAPPDATA.
170
- const base = process.env.LOCALAPPDATA || path_1.default.join(home, "AppData", "Local");
97
+ const base = process.env.LOCALAPPDATA || node_path_1.default.join(home, "AppData", "Local");
171
98
  if (browser === "edge") {
172
- return path_1.default.join(base, "Microsoft", "Edge", "User Data", "NativeMessagingHosts");
99
+ return node_path_1.default.join(base, "Microsoft", "Edge", "User Data", "NativeMessagingHosts");
173
100
  }
174
- return path_1.default.join(base, "Google", "Chrome", "User Data", "NativeMessagingHosts");
101
+ return node_path_1.default.join(base, "Google", "Chrome", "User Data", "NativeMessagingHosts");
175
102
  }
176
103
  if (process.platform === "linux") {
177
104
  if (browser === "edge") {
178
- return path_1.default.join(home, ".config", "microsoft-edge", "NativeMessagingHosts");
105
+ return node_path_1.default.join(home, ".config", "microsoft-edge", "NativeMessagingHosts");
179
106
  }
180
- return path_1.default.join(home, ".config", "google-chrome", "NativeMessagingHosts");
107
+ return node_path_1.default.join(home, ".config", "google-chrome", "NativeMessagingHosts");
181
108
  }
182
109
  // macOS
183
110
  if (browser === "edge") {
184
- 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");
185
112
  }
186
- 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");
187
114
  }
188
115
  function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
189
- fs_1.default.mkdirSync(wrapperDir, { recursive: true });
116
+ node_fs_1.default.mkdirSync(wrapperDir, { recursive: true });
190
117
  if (process.platform !== "win32") {
191
118
  try {
192
- fs_1.default.chmodSync(wrapperDir, 0o700);
119
+ node_fs_1.default.chmodSync(wrapperDir, 0o700);
193
120
  }
194
121
  catch { /* ignore */ }
195
122
  }
@@ -205,28 +132,28 @@ function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
205
132
  // Not installed
206
133
  }
207
134
  if (exeSrc) {
208
- const exeDst = path_1.default.join(wrapperDir, "tabctl-host.exe");
209
- fs_1.default.copyFileSync(exeSrc, exeDst);
135
+ const exeDst = node_path_1.default.join(wrapperDir, "tabctl-host.exe");
136
+ node_fs_1.default.copyFileSync(exeSrc, exeDst);
210
137
  const cfgLines = [nodePath, hostPath];
211
138
  if (profileName) {
212
139
  cfgLines.push(`TABCTL_PROFILE=${profileName}`);
213
140
  }
214
141
  cfgLines.push("");
215
- fs_1.default.writeFileSync(path_1.default.join(wrapperDir, "host-launcher.cfg"), cfgLines.join("\r\n"), "utf8");
142
+ node_fs_1.default.writeFileSync(node_path_1.default.join(wrapperDir, "host-launcher.cfg"), cfgLines.join("\r\n"), "utf8");
216
143
  return exeDst;
217
144
  }
218
145
  // Fallback: .cmd wrapper (won't work with Chrome native messaging)
219
- const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.cmd");
146
+ const wrapperPath = node_path_1.default.join(wrapperDir, "tabctl-host.cmd");
220
147
  const lines = ["@echo off"];
221
148
  if (profileName) {
222
149
  lines.push(`set TABCTL_PROFILE=${profileName}`);
223
150
  }
224
151
  lines.push(`"${nodePath}" "${hostPath}" %*`);
225
152
  lines.push("");
226
- fs_1.default.writeFileSync(wrapperPath, lines.join("\r\n"), "utf8");
153
+ node_fs_1.default.writeFileSync(wrapperPath, lines.join("\r\n"), "utf8");
227
154
  return wrapperPath;
228
155
  }
229
- const wrapperPath = path_1.default.join(wrapperDir, "tabctl-host.sh");
156
+ const wrapperPath = node_path_1.default.join(wrapperDir, "tabctl-host.sh");
230
157
  const escapedNode = nodePath.replace(/"/g, "\\\"");
231
158
  const escapedHost = hostPath.replace(/"/g, "\\\"");
232
159
  const lines = [
@@ -239,11 +166,11 @@ function writeWrapper(nodePath, hostPath, profileName, wrapperDir) {
239
166
  lines.push(`exec \"${escapedNode}\" \"${escapedHost}\"`);
240
167
  lines.push("");
241
168
  const wrapper = lines.join("\n");
242
- fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
243
- fs_1.default.chmodSync(wrapperPath, 0o700);
169
+ node_fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
170
+ node_fs_1.default.chmodSync(wrapperPath, 0o700);
244
171
  return wrapperPath;
245
172
  }
246
- async function runSetup(options, prettyOutput) {
173
+ function runSetup(options, prettyOutput) {
247
174
  const browser = resolveBrowser(options.browser);
248
175
  if (!browser) {
249
176
  (0, output_1.errorOut)("Missing or invalid --browser (edge|chrome)");
@@ -263,41 +190,15 @@ async function runSetup(options, prettyOutput) {
263
190
  let extensionId = resolveExtensionId(options, false);
264
191
  if (!extensionId) {
265
192
  // Auto-derive from the installed extension path (Chromium uses SHA256 of the path)
266
- const installedDir = (0, extension_sync_1.resolveInstalledExtensionDir)(config.baseDataDir);
267
- 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"))) {
268
196
  extensionId = (0, extension_sync_1.deriveExtensionId)(installedDir);
269
197
  process.stderr.write(`Extension ID derived from: ${installedDir}\n`);
270
198
  }
271
199
  }
272
200
  if (!extensionId) {
273
- // Interactive mode: sync hadn't happened or path doesn't exist
274
- if (extensionSync?.extensionDir) {
275
- process.stderr.write(`\nExtension synced to: ${extensionSync.extensionDir}\n`);
276
- try {
277
- const clipArgs = [];
278
- let clipCmd;
279
- if (process.platform === "darwin") {
280
- clipCmd = "pbcopy";
281
- }
282
- else if (process.platform === "win32") {
283
- clipCmd = "clip";
284
- }
285
- else {
286
- clipCmd = "xclip";
287
- clipArgs.push("-selection", "clipboard");
288
- }
289
- const clip = (0, node_child_process_1.spawn)(clipCmd, clipArgs, { stdio: ["pipe", "ignore", "ignore"] });
290
- clip.stdin.end(extensionSync.extensionDir);
291
- clip.on("exit", (code) => {
292
- if (code === 0)
293
- process.stderr.write("(Path copied to clipboard)\n");
294
- });
295
- }
296
- catch {
297
- // clipboard copy is best-effort
298
- }
299
- }
300
- 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.");
301
202
  }
302
203
  // Profile name: --name flag or browser type
303
204
  const profileName = typeof options.name === "string" && options.name.trim()
@@ -310,20 +211,20 @@ async function runSetup(options, prettyOutput) {
310
211
  (0, output_1.errorOut)(err.message);
311
212
  }
312
213
  // Profile data dir (use baseDataDir to avoid nesting under another profile)
313
- const profileDataDir = path_1.default.join(config.baseDataDir, "profiles", profileName);
314
- fs_1.default.mkdirSync(profileDataDir, { recursive: true });
214
+ const profileDataDir = node_path_1.default.join(config.baseDataDir, "profiles", profileName);
215
+ node_fs_1.default.mkdirSync(profileDataDir, { recursive: true });
315
216
  // Write profile-specific wrapper
316
217
  const wrapperPath = writeWrapper(nodePath, hostPath, profileName, profileDataDir);
317
218
  // Resolve manifest directory: custom user-data-dir or system-wide
318
219
  const rawUserDataDir = typeof options["user-data-dir"] === "string"
319
220
  ? options["user-data-dir"].trim()
320
221
  : "";
321
- const userDataDir = rawUserDataDir ? path_1.default.resolve(rawUserDataDir) : "";
222
+ const userDataDir = rawUserDataDir ? node_path_1.default.resolve(rawUserDataDir) : "";
322
223
  const manifestDir = userDataDir
323
- ? path_1.default.join(userDataDir, "NativeMessagingHosts")
224
+ ? node_path_1.default.join(userDataDir, "NativeMessagingHosts")
324
225
  : resolveManifestDir(browser);
325
- fs_1.default.mkdirSync(manifestDir, { recursive: true });
326
- 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`);
327
228
  const manifest = {
328
229
  name: constants_1.HOST_NAME,
329
230
  description: constants_1.HOST_DESCRIPTION,
@@ -331,7 +232,7 @@ async function runSetup(options, prettyOutput) {
331
232
  type: "stdio",
332
233
  allowed_origins: [`chrome-extension://${extensionId}/`],
333
234
  };
334
- fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
235
+ node_fs_1.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
335
236
  // Register profile
336
237
  const profileEntry = {
337
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" },
@@ -120,7 +120,7 @@ function applyPolicyFilter(command, params, snapshot, policyContext, policySumma
120
120
  protected: protectedTabs.map(mapProtectedTab),
121
121
  };
122
122
  }
123
- else if (command === "group-update" || command === "group-ungroup") {
123
+ else if (command === "group-update" || command === "group-ungroup" || command === "group-gather") {
124
124
  if (!eligibleIds.length || protectedTabs.length > 0) {
125
125
  earlyResponse = {
126
126
  ok: true,
@@ -10,7 +10,7 @@ exports.getDomain = getDomain;
10
10
  exports.evaluateTab = evaluateTab;
11
11
  exports.annotateEntry = annotateEntry;
12
12
  exports.summarizePolicy = summarizePolicy;
13
- const fs_1 = __importDefault(require("fs"));
13
+ const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const config_1 = require("../../shared/config");
15
15
  function defaultPolicyPath() {
16
16
  return (0, config_1.resolveConfig)().policyPath;
@@ -25,10 +25,10 @@ function defaultPolicyTemplate() {
25
25
  }
26
26
  function loadPolicy() {
27
27
  const resolvedPath = defaultPolicyPath();
28
- if (!fs_1.default.existsSync(resolvedPath)) {
28
+ if (!node_fs_1.default.existsSync(resolvedPath)) {
29
29
  return { policy: null, path: resolvedPath };
30
30
  }
31
- const raw = fs_1.default.readFileSync(resolvedPath, "utf8");
31
+ const raw = node_fs_1.default.readFileSync(resolvedPath, "utf8");
32
32
  const parsed = JSON.parse(raw);
33
33
  return { policy: parsed, path: resolvedPath };
34
34
  }
@@ -9,8 +9,8 @@ exports.buildDedupeOutput = buildDedupeOutput;
9
9
  exports.extractDedupePlan = extractDedupePlan;
10
10
  exports.formatReport = formatReport;
11
11
  exports.writeScreenshots = writeScreenshots;
12
- const fs_1 = __importDefault(require("fs"));
13
- const path_1 = __importDefault(require("path"));
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
14
  const report_1 = require("./report");
15
15
  const policy_1 = require("./policy");
16
16
  const output_1 = require("./output");
@@ -170,7 +170,7 @@ function formatReport(response, options, prettyOutput) {
170
170
  (0, output_1.errorOut)(`Unknown report format: ${format}`);
171
171
  }
172
172
  if (options.out) {
173
- fs_1.default.writeFileSync(String(options.out), content, "utf8");
173
+ node_fs_1.default.writeFileSync(String(options.out), content, "utf8");
174
174
  (0, output_1.printJson)({ ok: true, data: { writtenTo: options.out, format, count: entries.length, ...(page ? { page } : {}) } }, prettyOutput);
175
175
  return { printed: true };
176
176
  }
@@ -190,13 +190,13 @@ function writeScreenshots(response, options, prettyOutput) {
190
190
  const page = data && "page" in data ? data.page : undefined;
191
191
  const outDir = options.out
192
192
  ? String(options.out)
193
- : path_1.default.join(process.cwd(), ".tabctl", "screenshots", String(Date.now()));
194
- fs_1.default.mkdirSync(outDir, { recursive: true });
193
+ : node_path_1.default.join(process.cwd(), ".tabctl", "screenshots", String(Date.now()));
194
+ node_fs_1.default.mkdirSync(outDir, { recursive: true });
195
195
  let filesWritten = 0;
196
196
  const sanitized = entries.map((entry) => {
197
197
  const tabId = entry.tabId;
198
- const tabDir = path_1.default.join(outDir, String(tabId ?? "unknown"));
199
- fs_1.default.mkdirSync(tabDir, { recursive: true });
198
+ const tabDir = node_path_1.default.join(outDir, String(tabId ?? "unknown"));
199
+ node_fs_1.default.mkdirSync(tabDir, { recursive: true });
200
200
  const tiles = Array.isArray(entry.tiles) ? entry.tiles : [];
201
201
  const sanitizedTiles = tiles.map((tile) => {
202
202
  const rawUrl = tile.dataUrl;
@@ -215,9 +215,9 @@ function writeScreenshots(response, options, prettyOutput) {
215
215
  const total = Number.isFinite(tile.total) ? Number(tile.total) : null;
216
216
  const suffix = total && total > 1 ? `-of-${total}` : "";
217
217
  const filename = `screenshot-${index}${suffix}.${ext}`;
218
- const filePath = path_1.default.join(tabDir, filename);
218
+ const filePath = node_path_1.default.join(tabDir, filename);
219
219
  const buffer = Buffer.from(base64, "base64");
220
- fs_1.default.writeFileSync(filePath, buffer);
220
+ node_fs_1.default.writeFileSync(filePath, buffer);
221
221
  filesWritten += 1;
222
222
  return {
223
223
  ...rest,