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 +7 -5
- package/dist/cli/lib/client.js +23 -2
- package/dist/cli/lib/commands/index.js +2 -1
- package/dist/cli/lib/commands/meta.js +16 -13
- package/dist/cli/lib/commands/params-groups.js +8 -0
- package/dist/cli/lib/commands/params.js +6 -3
- package/dist/cli/lib/commands/setup.js +104 -126
- package/dist/cli/lib/options-commands.js +13 -1
- package/dist/cli/lib/output.js +36 -1
- package/dist/cli/lib/policy-filter.js +1 -1
- package/dist/cli/lib/policy.js +3 -3
- package/dist/cli/lib/response.js +9 -9
- package/dist/cli/tabctl.js +9 -1
- package/dist/extension/background.js +429 -43
- package/dist/extension/lib/groups.js +89 -3
- package/dist/extension/lib/screenshot.js +2 -2
- package/dist/extension/lib/tabs.js +97 -36
- package/dist/extension/lib/undo-handlers.js +8 -0
- package/dist/extension/manifest.json +2 -2
- package/dist/host/host.bundle.js +669 -0
- package/dist/host/host.js +30 -11
- package/dist/host/launcher/go.mod +3 -0
- package/dist/host/launcher/main.go +109 -0
- package/dist/host/lib/handlers.js +7 -5
- package/dist/host/lib/undo.js +6 -6
- package/dist/shared/config.js +36 -13
- package/dist/shared/extension-sync.js +59 -15
- package/dist/shared/profiles.js +5 -5
- package/dist/shared/version.js +2 -2
- package/package.json +11 -4
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
|
|
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.
|
|
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
|
|
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
|
|
172
|
+
tabctl setup --browser edge
|
|
171
173
|
|
|
172
174
|
# Setup for Chrome (with custom name)
|
|
173
|
-
tabctl setup --browser chrome --
|
|
175
|
+
tabctl setup --browser chrome --name chrome-work
|
|
174
176
|
|
|
175
177
|
# List profiles
|
|
176
178
|
tabctl profile-list
|
package/dist/cli/lib/client.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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
|
|
34
|
+
return node_fs_1.default.realpathSync(process.cwd());
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
|
-
return
|
|
37
|
+
return node_path_1.default.resolve(process.cwd());
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
function resolveConfigHome() {
|
|
41
|
-
return process.env.XDG_CONFIG_HOME ||
|
|
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
|
|
45
|
+
return node_path_1.default.join(resolveConfigHome(), "opencode", "skills", constants_1.SKILL_NAME);
|
|
46
46
|
}
|
|
47
|
-
return
|
|
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 (
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
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 (!
|
|
57
|
+
if (!node_path_1.default.isAbsolute(value)) {
|
|
131
58
|
(0, output_1.errorOut)(`Node path must be absolute: ${value}`);
|
|
132
59
|
}
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
|
111
|
+
return node_path_1.default.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
|
|
156
112
|
}
|
|
157
|
-
return
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
175
|
-
|
|
169
|
+
node_fs_1.default.writeFileSync(wrapperPath, wrapper, "utf8");
|
|
170
|
+
node_fs_1.default.chmodSync(wrapperPath, 0o700);
|
|
176
171
|
return wrapperPath;
|
|
177
172
|
}
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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 =
|
|
237
|
-
|
|
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 ?
|
|
222
|
+
const userDataDir = rawUserDataDir ? node_path_1.default.resolve(rawUserDataDir) : "";
|
|
245
223
|
const manifestDir = userDataDir
|
|
246
|
-
?
|
|
224
|
+
? node_path_1.default.join(userDataDir, "NativeMessagingHosts")
|
|
247
225
|
: resolveManifestDir(browser);
|
|
248
|
-
|
|
249
|
-
const manifestPath =
|
|
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
|
-
|
|
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: "
|
|
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
|
};
|