tabctl 0.3.1 → 0.5.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
@@ -59,12 +59,14 @@ tabctl list # see your open tabs
59
59
 
60
60
  ## Commands
61
61
 
62
- <!-- test: "list sends list action", "analyze passes tab ids and github options", "inspect passes signal options", "close without confirm fails", "report format md returns markdown content", "undo sends undo action with txid" -->
62
+ <!-- test: "list sends list action", "analyze passes tab ids and progress option", "inspect passes signal options", "close without confirm fails", "report format md returns markdown content", "undo sends undo action with txid" -->
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
@@ -253,7 +255,7 @@ Notes:
253
255
  - `list` and `group-list` paginate by default (limit 100); use `--limit`, `--offset`, or `--no-page`.
254
256
  - Use `--group-id -1` or `--ungrouped` to target ungrouped tabs.
255
257
  - `--selector` implies `--signal selector`.
256
- - Unknown inspect signals are rejected (valid: `page-meta`, `github-state`, `selector`).
258
+ - Unknown inspect signals are rejected (valid: `page-meta`, `selector`).
257
259
  - Selector `attr` supports `href-url`/`src-url` to return absolute http(s) URLs.
258
260
  - `screenshot --out` writes per-tab folders into the target directory.
259
261
  - `tabctl undo` accepts a positional txid, `--txid`, or `--latest`.
@@ -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
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Doctor command handler: diagnose and repair profile health.
4
+ *
5
+ * Checks each profile's wrapper for valid Node/host paths, verifies
6
+ * extension sync status, and optionally auto-repairs broken wrappers.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.runDoctor = runDoctor;
13
+ const node_fs_1 = __importDefault(require("node:fs"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const config_1 = require("../../../shared/config");
16
+ const profiles_1 = require("../../../shared/profiles");
17
+ const wrapper_health_1 = require("../../../shared/wrapper-health");
18
+ const extension_sync_1 = require("../../../shared/extension-sync");
19
+ const setup_1 = require("./setup");
20
+ const output_1 = require("../output");
21
+ function checkProfile(name, entry, fix) {
22
+ const wrapperPath = (0, wrapper_health_1.resolveWrapperPath)(entry.dataDir);
23
+ const check = (0, wrapper_health_1.checkWrapper)(wrapperPath);
24
+ const issues = [...check.issues];
25
+ let fixed = false;
26
+ if (fix && !check.ok && check.info) {
27
+ const needsNodeFix = !node_fs_1.default.existsSync(check.info.nodePath);
28
+ const needsHostFix = !node_fs_1.default.existsSync(check.info.hostPath);
29
+ if (needsNodeFix || needsHostFix) {
30
+ const newNodePath = needsNodeFix ? process.execPath : check.info.nodePath;
31
+ let newHostPath = check.info.hostPath;
32
+ if (needsHostFix) {
33
+ // Use the stable bundled host path
34
+ try {
35
+ const config = (0, config_1.resolveConfig)();
36
+ newHostPath = (0, extension_sync_1.resolveInstalledHostPath)(config.baseDataDir);
37
+ if (!node_fs_1.default.existsSync(newHostPath)) {
38
+ issues.push(`Bundled host not found at ${newHostPath} — run: tabctl setup --browser ${entry.browser}`);
39
+ }
40
+ }
41
+ catch {
42
+ issues.push("Could not resolve bundled host path");
43
+ }
44
+ }
45
+ try {
46
+ (0, setup_1.writeWrapper)(newNodePath, newHostPath, check.info.profileName, node_path_1.default.dirname(wrapperPath));
47
+ fixed = true;
48
+ // Update issue messages to show they were fixed
49
+ const fixedIssues = [];
50
+ if (needsNodeFix) {
51
+ fixedIssues.push(`Fixed Node path: ${check.info.nodePath} → ${newNodePath}`);
52
+ }
53
+ if (needsHostFix) {
54
+ fixedIssues.push(`Fixed host path: ${check.info.hostPath} → ${newHostPath}`);
55
+ }
56
+ // Replace original issues with fixed messages
57
+ issues.length = 0;
58
+ issues.push(...fixedIssues);
59
+ }
60
+ catch (err) {
61
+ issues.push(`Failed to fix wrapper: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ ok: check.ok || fixed,
67
+ browser: entry.browser,
68
+ dataDir: entry.dataDir,
69
+ wrapperPath,
70
+ issues,
71
+ fixed,
72
+ };
73
+ }
74
+ function runDoctor(options, prettyOutput) {
75
+ const fix = options.fix === true;
76
+ const config = (0, config_1.resolveConfig)();
77
+ const registry = (0, profiles_1.loadProfiles)(config.configDir);
78
+ const profileNames = Object.keys(registry.profiles);
79
+ if (profileNames.length === 0) {
80
+ (0, output_1.errorOut)("No profiles configured. Run: tabctl setup --browser <edge|chrome>");
81
+ }
82
+ // Check each profile
83
+ const profiles = {};
84
+ for (const name of profileNames) {
85
+ profiles[name] = checkProfile(name, registry.profiles[name], fix);
86
+ }
87
+ // Check extension sync status
88
+ let extensionCheck;
89
+ try {
90
+ const sync = (0, extension_sync_1.checkExtensionSync)(config.baseDataDir);
91
+ extensionCheck = {
92
+ ok: !sync.needsSync,
93
+ synced: !sync.needsSync,
94
+ bundledVersion: sync.bundledVersion,
95
+ installedVersion: sync.installedVersion,
96
+ };
97
+ }
98
+ catch {
99
+ extensionCheck = {
100
+ ok: false,
101
+ synced: false,
102
+ bundledVersion: null,
103
+ installedVersion: null,
104
+ };
105
+ }
106
+ // Summary
107
+ const total = profileNames.length;
108
+ const healthy = Object.values(profiles).filter(p => p.ok).length;
109
+ const broken = total - healthy;
110
+ const fixed = Object.values(profiles).filter(p => p.fixed).length;
111
+ const allOk = broken === 0 && extensionCheck.ok;
112
+ (0, output_1.printJson)({
113
+ ok: allOk,
114
+ action: "doctor",
115
+ data: {
116
+ profiles,
117
+ extension: extensionCheck,
118
+ summary: { total, healthy, broken, fixed },
119
+ },
120
+ }, prettyOutput);
121
+ // Helpful stderr hints
122
+ if (!allOk && !fix) {
123
+ const brokenNames = Object.entries(profiles)
124
+ .filter(([, p]) => !p.ok)
125
+ .map(([n]) => n);
126
+ if (brokenNames.length > 0) {
127
+ process.stderr.write(`\nBroken profiles: ${brokenNames.join(", ")}\n`);
128
+ process.stderr.write("Run: tabctl doctor --fix\n\n");
129
+ }
130
+ }
131
+ if (fixed > 0) {
132
+ process.stderr.write(`\nFixed ${fixed} profile(s). Verify: tabctl ping\n\n`);
133
+ }
134
+ }
@@ -4,10 +4,13 @@
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.runDoctor = 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; } });
11
+ // Doctor command
12
+ var doctor_1 = require("./doctor");
13
+ Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return doctor_1.runDoctor; } });
11
14
  // Meta commands (version, ping, skill, policy, history, undo)
12
15
  var meta_1 = require("./meta");
13
16
  Object.defineProperty(exports, "runSkillInstall", { enumerable: true, get: function () { return meta_1.runSkillInstall; } });
@@ -36,6 +39,7 @@ Object.defineProperty(exports, "buildOpenParams", { enumerable: true, get: funct
36
39
  Object.defineProperty(exports, "buildGroupUpdateParams", { enumerable: true, get: function () { return params_1.buildGroupUpdateParams; } });
37
40
  Object.defineProperty(exports, "buildGroupUngroupParams", { enumerable: true, get: function () { return params_1.buildGroupUngroupParams; } });
38
41
  Object.defineProperty(exports, "buildGroupAssignParams", { enumerable: true, get: function () { return params_1.buildGroupAssignParams; } });
42
+ Object.defineProperty(exports, "buildGroupGatherParams", { enumerable: true, get: function () { return params_1.buildGroupGatherParams; } });
39
43
  Object.defineProperty(exports, "buildMoveTabParams", { enumerable: true, get: function () { return params_1.buildMoveTabParams; } });
40
44
  Object.defineProperty(exports, "buildMoveGroupParams", { enumerable: true, get: function () { return params_1.buildMoveGroupParams; } });
41
45
  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
  // ============================================================================
@@ -46,14 +46,11 @@ function buildAnalyzeParams(options) {
46
46
  const { tabIds, groupTitle, groupId, windowId, all } = buildScopeFromOptions(options);
47
47
  return {
48
48
  staleDays: options["stale-days"] ? Number(options["stale-days"]) : undefined,
49
- checkGitHub: Boolean(options.github),
50
49
  tabIds,
51
50
  groupTitle,
52
51
  groupId,
53
52
  windowId,
54
53
  all,
55
- githubConcurrency: options["github-concurrency"] ? Number(options["github-concurrency"]) : undefined,
56
- githubTimeoutMs: options["github-timeout-ms"] ? Number(options["github-timeout-ms"]) : undefined,
57
54
  progress: Boolean(options.progress),
58
55
  };
59
56
  }
@@ -99,7 +96,7 @@ function buildInspectParams(options) {
99
96
  let signalConfig;
100
97
  if (options["signal-config"]) {
101
98
  try {
102
- const configRaw = fs_1.default.readFileSync(String(options["signal-config"]), "utf8");
99
+ const configRaw = node_fs_1.default.readFileSync(String(options["signal-config"]), "utf8");
103
100
  signalConfig = JSON.parse(configRaw);
104
101
  }
105
102
  catch {
@@ -164,6 +161,8 @@ function buildOpenParams(options) {
164
161
  windowGroupTitle: options["window-group"],
165
162
  windowTabId: options["window-tab"] ? Number(options["window-tab"]) : undefined,
166
163
  windowUrl: options["window-url"],
164
+ newGroup: options["new-group"] === true,
165
+ allowDuplicates: options["allow-duplicates"] === true,
167
166
  };
168
167
  }
169
168
  // ============================================================================
@@ -173,6 +172,7 @@ var params_groups_1 = require("./params-groups");
173
172
  Object.defineProperty(exports, "buildGroupUpdateParams", { enumerable: true, get: function () { return params_groups_1.buildGroupUpdateParams; } });
174
173
  Object.defineProperty(exports, "buildGroupAssignParams", { enumerable: true, get: function () { return params_groups_1.buildGroupAssignParams; } });
175
174
  Object.defineProperty(exports, "buildGroupUngroupParams", { enumerable: true, get: function () { return params_groups_1.buildGroupUngroupParams; } });
175
+ Object.defineProperty(exports, "buildGroupGatherParams", { enumerable: true, get: function () { return params_groups_1.buildGroupGatherParams; } });
176
176
  // ============================================================================
177
177
  // Move Command Parameters (re-exported from params-move.ts)
178
178
  // ============================================================================