tabctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # tabctl
2
+
3
+ `tabctl` is a command-line tool that gives you terminal control over your browser tabs. List, search, group, move, close, deduplicate, inspect, and report on tabs without leaving your terminal — across Chrome and Edge.
4
+
5
+ It works through a lightweight local stack: the CLI talks to a native messaging host, which proxies requests to a browser extension. A policy file can protect pinned tabs or specific groups from automated actions, and every mutation is undoable.
6
+
7
+ This repo contains:
8
+ - Chrome/Edge extension (tab/group inspection + actions)
9
+ - Native messaging host (Node)
10
+ - CLI (`tabctl`) for on-demand workflows
11
+
12
+ The host only runs while the browser is open and the extension is connected.
13
+
14
+ ## Quick Start
15
+
16
+ ### 1. Build and install
17
+
18
+ ```bash
19
+ npm install
20
+ npm run build
21
+ npm link # puts tabctl on your PATH
22
+ ```
23
+
24
+ If you haven't run `npm link`, you can always use `node ./cli/tabctl.js` instead of `tabctl`.
25
+
26
+ ### 2. Set up your browser
27
+
28
+ Run the interactive setup — it syncs the extension, tells you where to load it, and prompts for the extension ID:
29
+
30
+ <!-- test: "setup interactive mode reads extension-id from stdin" -->
31
+ ```bash
32
+ tabctl setup --browser chrome
33
+ ```
34
+
35
+ This will:
36
+ 1. Copy the extension to a stable location (`~/.local/state/tabctl/extension/`)
37
+ 2. Print the path (and copy it to your clipboard)
38
+ 3. Ask you to load it as an unpacked extension in `chrome://extensions`
39
+ 4. Prompt you to paste the extension ID
40
+
41
+ > **Edge?** Use `--browser edge` and load from `edge://extensions` instead.
42
+
43
+ If you already know your extension ID, skip the interactive flow:
44
+
45
+ <!-- test: "setup writes native host manifest for chrome" -->
46
+ ```bash
47
+ tabctl setup --browser chrome --extension-id <your-extension-id>
48
+ ```
49
+
50
+ ### 3. Verify and explore
51
+
52
+ <!-- test: "ping sends ping action", "list sends list action" -->
53
+ ```bash
54
+ tabctl ping # check the connection
55
+ tabctl list # see your open tabs
56
+ ```
57
+
58
+ > **Multiple browsers?** See [Multi-Browser Setup](#multi-browser-setup) for running tabctl with both Chrome and Edge.
59
+
60
+ ## Commands
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" -->
63
+ | Command | Description |
64
+ |---------|-------------|
65
+ | `tabctl list` | List open tabs and groups |
66
+ | `tabctl analyze` | Find stale or duplicate tabs |
67
+ | `tabctl inspect --tab <id>` | Extract page metadata or CSS selectors |
68
+ | `tabctl close --tab <id>` | Close tabs with full undo support |
69
+ | `tabctl report` | Generate reports in JSON, Markdown, or CSV |
70
+ | `tabctl undo` | Revert the last action |
71
+
72
+ See [CLI.md](CLI.md) for the full command reference, options, and examples.
73
+
74
+ ## Screenshot output
75
+ When `--out` is omitted, screenshots are written to `./.tabctl/screenshots/<timestamp>` and the JSON response includes `writtenTo`.
76
+
77
+ ## Agent workflow (context -> selector)
78
+ Use screenshots only when you need visual context, then extract selectors with `inspect`.
79
+
80
+ 1) Capture context (full page tiles):
81
+ <!-- test: "screenshot passes capture options" -->
82
+ ```bash
83
+ tabctl screenshot --tab <id> --mode full
84
+ ```
85
+
86
+ 2) Identify the element visually, then extract its selector:
87
+ <!-- test: "inspect passes signal options" -->
88
+ ```bash
89
+ tabctl inspect --tab <id> --signal selector --selector '{"name":"target","selector":".your-selector"}'
90
+ ```
91
+
92
+ 3) If you need an absolute URL, set `--selector-attr href-url` or set `attr` to `href-url`/`src-url`:
93
+ <!-- test: "inspect passes selector attr" -->
94
+ ```bash
95
+ tabctl inspect --tab <id> --signal selector --selector '{"name":"link","selector":"a[href]","attr":"href-url"}'
96
+ tabctl inspect --tab <id> --signal selector --selector "link=a[href]" --selector-attr href-url
97
+ ```
98
+
99
+ ## Agent skills
100
+
101
+ Install the tabctl skill for agents (OpenCode, Claude Code, Codex, etc.) via the bundled command (uses the Skills CLI under the hood):
102
+
103
+ <!-- test: "skill install creates project skill link" -->
104
+ ```bash
105
+ tabctl skill
106
+ ```
107
+
108
+ This writes a project-local skill to `.opencode/skills/tabctl/SKILL.md`. You can also install globally:
109
+
110
+ <!-- test: "skill install supports global scope" -->
111
+ ```bash
112
+ tabctl skill --global
113
+ ```
114
+
115
+ To install into a specific agent toolchain with `skills`:
116
+
117
+ ```bash
118
+ npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode
119
+ ```
120
+
121
+ ## Policy (protect tabs)
122
+ By default the CLI loads a policy file from:
123
+ `<configDir>/policy.json` (default: `~/.config/tabctl/policy.json`)
124
+
125
+ Set `TABCTL_CONFIG_DIR` to override the config directory.
126
+
127
+ This is a **protection-only** policy that marks tabs as ineligible for agent actions.
128
+ Example:
129
+
130
+ ```json
131
+ {
132
+ "protect": {
133
+ "pinned": true,
134
+ "groupTitles": ["🔒"]
135
+ }
136
+ }
137
+ ```
138
+
139
+ Create a default policy file:
140
+
141
+ <!-- test: "policy init creates default file" -->
142
+ ```bash
143
+ tabctl policy --init
144
+ ```
145
+
146
+ `tabctl setup` does not install a default policy.
147
+ See `config/policy.example.json` for a starter template.
148
+
149
+ ## Configuration
150
+ Config directory: `TABCTL_CONFIG_DIR` → `$XDG_CONFIG_HOME/tabctl` → `~/.config/tabctl`
151
+
152
+ An optional `config.json` in the config directory can set `dataDir` to override where state files (socket, undo log) are stored. When `TABCTL_CONFIG_DIR` is set but no `dataDir` is configured, data defaults to `<configDir>/data/`; otherwise it uses `$XDG_STATE_HOME/tabctl` (or `~/.local/state/tabctl`).
153
+
154
+ See [CLI.md](CLI.md#configuration) for full details.
155
+
156
+ ## Runtime state
157
+ - Socket: `<dataDir>/tabctl.sock` (default: `~/.local/state/tabctl/tabctl.sock`)
158
+ - Undo log: `<dataDir>/undo.jsonl` (default: `~/.local/state/tabctl/undo.jsonl`)
159
+ - Profile registry: `<configDir>/profiles.json`
160
+
161
+ ## Multi-Browser Setup
162
+
163
+ > **Advanced topic** — you only need this if you run tabctl with more than one browser (e.g. Edge *and* Chrome).
164
+
165
+ tabctl supports multiple browser profiles. Each profile connects to a different **browser** (Chrome, Edge).
166
+
167
+ <!-- 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
+ ```bash
169
+ # Setup for Edge
170
+ tabctl setup --browser edge --extension-id <edge-id>
171
+
172
+ # Setup for Chrome (with custom name)
173
+ tabctl setup --browser chrome --extension-id <chrome-id> --name chrome-work
174
+
175
+ # List profiles
176
+ tabctl profile-list
177
+
178
+ # Switch default
179
+ tabctl profile-switch edge
180
+
181
+ # One-off command with different profile
182
+ tabctl list --profile chrome-work
183
+ ```
184
+
185
+ ### Custom Chrome Profile Directories
186
+
187
+ If you launch Chrome with `--user-data-dir`, Chrome looks for native messaging manifests inside that directory. Use `--user-data-dir` in setup to write the manifest to the right place:
188
+
189
+ <!-- test: "setup --user-data-dir writes manifest to custom path" -->
190
+ ```bash
191
+ tabctl setup --browser chrome --user-data-dir /path/to/chrome-profile
192
+ ```
193
+
194
+ This writes the manifest to `<user-data-dir>/NativeMessagingHosts/` instead of the system-wide location.
195
+
196
+ ### How It Works
197
+
198
+ Each profile gets its own:
199
+ - Native host manifest and wrapper script
200
+ - Unix socket for CLI-host communication
201
+ - Undo history log
202
+ - Data directory
203
+
204
+ Policy is shared across all profiles.
205
+
206
+ ## Security
207
+ - The native host is locked to your extension ID.
208
+ - All data stays local; no external API keys are used.
209
+
210
+ ## Development
211
+
212
+ ### TypeScript workflow
213
+ Source lives in `src/` and compiles to `build/`, then syncs to the runtime locations:
214
+ - `src/extension/background.ts` -> `extension/background.js`
215
+ - `src/host/host.ts` -> `host/host.js`
216
+ - `src/cli/tabctl.ts` -> `cli/tabctl.js`
217
+ - `src/tests/unit/*.ts` -> `tests/unit/*.js`
218
+
219
+ Build and test:
220
+
221
+ ```bash
222
+ npm install
223
+ npm run build
224
+ npm test
225
+ ```
226
+
227
+ ### Versioning
228
+ The base version lives in `package.json` and is embedded into the CLI, host, and extension at build time.
229
+
230
+ Commands:
231
+ ```bash
232
+ npm run bump:patch
233
+ npm run bump:minor
234
+ npm run bump:major
235
+ ```
236
+
237
+ Local builds default to a dev version when a `.git` directory is present, appending the short SHA.
238
+ ```bash
239
+ npm run build
240
+ ```
241
+
242
+ This produces versions like `0.1.0-dev.abc12345` (and appends `.dirty` when the repo has uncommitted changes).
243
+
244
+ For release builds without SHA, set:
245
+ ```bash
246
+ TABCTL_VERSION_MODE=release npm run build
247
+ ```
248
+
249
+ Notes:
250
+ - `close --apply` uses the most recent analysis by `analysisId`.
251
+ - `close` without `--apply` requires `--confirm` to prevent accidental closure.
252
+ - Reports include short descriptions from page metadata and a fallback snippet.
253
+ - `list` and `group-list` paginate by default (limit 100); use `--limit`, `--offset`, or `--no-page`.
254
+ - Use `--group-id -1` or `--ungrouped` to target ungrouped tabs.
255
+ - `--selector` implies `--signal selector`.
256
+ - Unknown inspect signals are rejected (valid: `page-meta`, `github-state`, `selector`).
257
+ - Selector `attr` supports `href-url`/`src-url` to return absolute http(s) URLs.
258
+ - `screenshot --out` writes per-tab folders into the target directory.
259
+ - `tabctl undo` accepts a positional txid, `--txid`, or `--latest`.
260
+ - `tabctl history --json` returns a JSON array in `data`.
261
+ - `--format` is only supported by `report` (use `--json` elsewhere).
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeGroupColor = normalizeGroupColor;
4
+ exports.normalizeSignals = normalizeSignals;
5
+ exports.validateSignals = validateSignals;
6
+ exports.parseArgs = parseArgs;
7
+ const constants_1 = require("./constants");
8
+ const options_1 = require("./options");
9
+ const output_1 = require("./output");
10
+ function normalizeGroupColor(value) {
11
+ if (typeof value !== "string") {
12
+ return undefined;
13
+ }
14
+ const trimmed = value.trim().toLowerCase();
15
+ if (!trimmed) {
16
+ return undefined;
17
+ }
18
+ if (!constants_1.GROUP_COLORS.has(trimmed)) {
19
+ (0, output_1.errorOut)(`Invalid color: ${value}. Use one of: ${Array.from(constants_1.GROUP_COLORS).join(", ")}`);
20
+ }
21
+ return trimmed;
22
+ }
23
+ function normalizeSignals(value) {
24
+ if (!Array.isArray(value)) {
25
+ return [];
26
+ }
27
+ return value.map((signal) => String(signal).trim()).filter(Boolean);
28
+ }
29
+ function validateSignals(signals) {
30
+ for (const signal of signals) {
31
+ if (!constants_1.SUPPORTED_SIGNAL_SET.has(signal)) {
32
+ (0, output_1.errorOut)(`Unknown signal: ${signal}. Use one of: ${constants_1.SUPPORTED_SIGNALS.join(", ")}`);
33
+ }
34
+ }
35
+ }
36
+ function normalizeCommand(value) {
37
+ if (!value) {
38
+ return value;
39
+ }
40
+ if (value === "groups" || value === "group") {
41
+ return "group-list";
42
+ }
43
+ const meta = options_1.COMMANDS[value];
44
+ if (!meta?.aliases || meta.aliases.length === 0) {
45
+ return value;
46
+ }
47
+ return meta.aliases[0] ?? value;
48
+ }
49
+ function parseArgs(argv) {
50
+ const args = [...argv];
51
+ let command;
52
+ const options = { _: [] };
53
+ const warnings = [];
54
+ const pendingFlags = [];
55
+ const allowedFlags = (0, options_1.getAllowedFlags)();
56
+ const booleanFlags = (0, options_1.getBooleanFlags)();
57
+ while (args.length > 0) {
58
+ const arg = args.shift();
59
+ if (!arg.startsWith("--")) {
60
+ if (!command) {
61
+ command = normalizeCommand(arg);
62
+ if (command) {
63
+ const commandAllowedFlags = (0, options_1.getCommandAllowedFlags)(command);
64
+ for (const pending of pendingFlags) {
65
+ if (!commandAllowedFlags.has(pending)) {
66
+ warnings.push(`--${pending} is not supported by ${command}`);
67
+ }
68
+ }
69
+ }
70
+ continue;
71
+ }
72
+ options._.push(arg);
73
+ continue;
74
+ }
75
+ const key = arg.slice(2);
76
+ if (!allowedFlags.has(key)) {
77
+ if (key === "format") {
78
+ (0, output_1.errorOut)("Unknown option: --format");
79
+ }
80
+ (0, output_1.errorOut)(`Unknown option: --${key}`);
81
+ }
82
+ if (command) {
83
+ const commandAllowedFlags = (0, options_1.getCommandAllowedFlags)(command);
84
+ if (!commandAllowedFlags.has(key)) {
85
+ warnings.push(`--${key} is not supported by ${command}`);
86
+ }
87
+ }
88
+ else {
89
+ pendingFlags.push(key);
90
+ }
91
+ // Boolean flags (no value needed)
92
+ if (booleanFlags.has(key)) {
93
+ options[key] = true;
94
+ continue;
95
+ }
96
+ // Value required
97
+ const value = args.shift();
98
+ if (value == null) {
99
+ (0, output_1.errorOut)(`Missing value for --${key}`);
100
+ }
101
+ // Repeatable flags (accumulate into arrays)
102
+ if (key === "signal") {
103
+ if (!options.signal) {
104
+ options.signal = [];
105
+ }
106
+ options.signal.push(value);
107
+ continue;
108
+ }
109
+ if (key === "tab") {
110
+ if (!options.tab) {
111
+ options.tab = [];
112
+ }
113
+ options.tab.push(value);
114
+ continue;
115
+ }
116
+ if (key === "agent") {
117
+ if (!options.agent) {
118
+ options.agent = [];
119
+ }
120
+ options.agent.push(value);
121
+ continue;
122
+ }
123
+ if (key === "url") {
124
+ if (!options.url) {
125
+ options.url = [];
126
+ }
127
+ options.url.push(value);
128
+ continue;
129
+ }
130
+ if (key === "selector") {
131
+ if (!options.selector) {
132
+ options.selector = [];
133
+ }
134
+ options.selector.push(value);
135
+ continue;
136
+ }
137
+ // Single value flags
138
+ options[key] = value;
139
+ }
140
+ return { command, options, warnings };
141
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createRequestId = createRequestId;
7
+ exports.sendRequest = sendRequest;
8
+ exports.fetchSnapshot = fetchSnapshot;
9
+ const net_1 = __importDefault(require("net"));
10
+ const constants_1 = require("./constants");
11
+ function createRequestId() {
12
+ return `req-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
13
+ }
14
+ function sendRequest(payload, onProgress) {
15
+ return new Promise((resolve, reject) => {
16
+ const { socketPath } = (0, constants_1.resolveConfig)();
17
+ const client = net_1.default.createConnection(socketPath);
18
+ let buffer = "";
19
+ client.on("connect", () => {
20
+ client.write(`${JSON.stringify(payload)}\n`);
21
+ });
22
+ client.on("data", (data) => {
23
+ buffer += data;
24
+ let index;
25
+ while ((index = buffer.indexOf("\n")) >= 0) {
26
+ const line = buffer.slice(0, index).trim();
27
+ buffer = buffer.slice(index + 1);
28
+ if (!line) {
29
+ continue;
30
+ }
31
+ let response;
32
+ try {
33
+ response = JSON.parse(line);
34
+ }
35
+ catch (error) {
36
+ client.end();
37
+ client.destroy();
38
+ reject(error);
39
+ return;
40
+ }
41
+ if (response.progress && onProgress) {
42
+ onProgress(response);
43
+ continue;
44
+ }
45
+ client.end();
46
+ client.destroy();
47
+ resolve(response);
48
+ return;
49
+ }
50
+ });
51
+ client.on("error", (error) => {
52
+ reject(error);
53
+ });
54
+ });
55
+ }
56
+ async function fetchSnapshot() {
57
+ const response = await sendRequest({ id: createRequestId(), action: "list", params: {} });
58
+ if (!response.ok) {
59
+ return null;
60
+ }
61
+ return response.data;
62
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ /**
3
+ * Command module index.
4
+ * Re-exports all command handlers and parameter builders.
5
+ */
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;
8
+ // Meta commands (version, ping, setup, skill, policy, history, undo)
9
+ var meta_1 = require("./meta");
10
+ Object.defineProperty(exports, "runSetup", { enumerable: true, get: function () { return meta_1.runSetup; } });
11
+ Object.defineProperty(exports, "runSkillInstall", { enumerable: true, get: function () { return meta_1.runSkillInstall; } });
12
+ Object.defineProperty(exports, "runVersion", { enumerable: true, get: function () { return meta_1.runVersion; } });
13
+ Object.defineProperty(exports, "runPolicy", { enumerable: true, get: function () { return meta_1.runPolicy; } });
14
+ Object.defineProperty(exports, "runHistory", { enumerable: true, get: function () { return meta_1.runHistory; } });
15
+ Object.defineProperty(exports, "runUndo", { enumerable: true, get: function () { return meta_1.runUndo; } });
16
+ Object.defineProperty(exports, "runPing", { enumerable: true, get: function () { return meta_1.runPing; } });
17
+ // List commands (list, group-list)
18
+ var list_1 = require("./list");
19
+ Object.defineProperty(exports, "runList", { enumerable: true, get: function () { return list_1.runList; } });
20
+ Object.defineProperty(exports, "runGroupList", { enumerable: true, get: function () { return list_1.runGroupList; } });
21
+ // Profile commands (profile-list, profile-show, profile-switch, profile-remove)
22
+ var profile_1 = require("./profile");
23
+ Object.defineProperty(exports, "runProfileList", { enumerable: true, get: function () { return profile_1.runProfileList; } });
24
+ Object.defineProperty(exports, "runProfileShow", { enumerable: true, get: function () { return profile_1.runProfileShow; } });
25
+ Object.defineProperty(exports, "runProfileSwitch", { enumerable: true, get: function () { return profile_1.runProfileSwitch; } });
26
+ Object.defineProperty(exports, "runProfileRemove", { enumerable: true, get: function () { return profile_1.runProfileRemove; } });
27
+ // Parameter builders for all commands
28
+ var params_1 = require("./params");
29
+ Object.defineProperty(exports, "buildAnalyzeParams", { enumerable: true, get: function () { return params_1.buildAnalyzeParams; } });
30
+ Object.defineProperty(exports, "buildInspectParams", { enumerable: true, get: function () { return params_1.buildInspectParams; } });
31
+ Object.defineProperty(exports, "buildFocusParams", { enumerable: true, get: function () { return params_1.buildFocusParams; } });
32
+ Object.defineProperty(exports, "buildRefreshParams", { enumerable: true, get: function () { return params_1.buildRefreshParams; } });
33
+ Object.defineProperty(exports, "buildOpenParams", { enumerable: true, get: function () { return params_1.buildOpenParams; } });
34
+ Object.defineProperty(exports, "buildGroupUpdateParams", { enumerable: true, get: function () { return params_1.buildGroupUpdateParams; } });
35
+ Object.defineProperty(exports, "buildGroupUngroupParams", { enumerable: true, get: function () { return params_1.buildGroupUngroupParams; } });
36
+ Object.defineProperty(exports, "buildGroupAssignParams", { enumerable: true, get: function () { return params_1.buildGroupAssignParams; } });
37
+ Object.defineProperty(exports, "buildMoveTabParams", { enumerable: true, get: function () { return params_1.buildMoveTabParams; } });
38
+ Object.defineProperty(exports, "buildMoveGroupParams", { enumerable: true, get: function () { return params_1.buildMoveGroupParams; } });
39
+ Object.defineProperty(exports, "buildMergeWindowParams", { enumerable: true, get: function () { return params_1.buildMergeWindowParams; } });
40
+ Object.defineProperty(exports, "buildArchiveParams", { enumerable: true, get: function () { return params_1.buildArchiveParams; } });
41
+ Object.defineProperty(exports, "buildCloseParams", { enumerable: true, get: function () { return params_1.buildCloseParams; } });
42
+ Object.defineProperty(exports, "buildReportParams", { enumerable: true, get: function () { return params_1.buildReportParams; } });
43
+ Object.defineProperty(exports, "buildScreenshotParams", { enumerable: true, get: function () { return params_1.buildScreenshotParams; } });
44
+ Object.defineProperty(exports, "buildHistoryParams", { enumerable: true, get: function () { return params_1.buildHistoryParams; } });
45
+ Object.defineProperty(exports, "buildUndoParams", { enumerable: true, get: function () { return params_1.buildUndoParams; } });
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * List command handlers: list, group-list
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runList = runList;
7
+ exports.runGroupList = runGroupList;
8
+ const constants_1 = require("../constants");
9
+ const output_1 = require("../output");
10
+ const client_1 = require("../client");
11
+ const scope_1 = require("../scope");
12
+ const pagination_1 = require("../pagination");
13
+ const snapshot_1 = require("../snapshot");
14
+ // ============================================================================
15
+ // List Command
16
+ // ============================================================================
17
+ async function runList(options, policyContext, policySummary, prettyOutput) {
18
+ // Validate scope flags early (before connecting) so invalid values
19
+ // are rejected without needing a socket connection.
20
+ const scope = (0, scope_1.resolveScopeFlags)(options);
21
+ const response = await (0, client_1.sendRequest)({
22
+ id: (0, client_1.createRequestId)(),
23
+ action: "list",
24
+ params: {},
25
+ client: {
26
+ component: "cli",
27
+ version: constants_1.VERSION,
28
+ baseVersion: constants_1.BASE_VERSION,
29
+ gitSha: constants_1.GIT_SHA,
30
+ dirty: constants_1.DIRTY,
31
+ },
32
+ });
33
+ if (!response.ok) {
34
+ (0, output_1.printJson)(response, prettyOutput);
35
+ process.exit(1);
36
+ }
37
+ const data = response.data;
38
+ if (data && Array.isArray(data.windows)) {
39
+ const filtered = (0, snapshot_1.filterSnapshotByPolicy)(data, policyContext.policy);
40
+ if (typeof scope.windowId === "string") {
41
+ const resolvedWindowId = (0, scope_1.resolveWindowIdFromSnapshot)(filtered, scope.windowId);
42
+ scope.windowId = resolvedWindowId ?? null;
43
+ }
44
+ const allScope = options.all === true || !scope.hasScope;
45
+ const listParams = allScope
46
+ ? { all: true }
47
+ : {
48
+ tabIds: scope.tabIds.length ? scope.tabIds : undefined,
49
+ groupTitle: scope.groupTitle || undefined,
50
+ groupId: scope.groupId != null ? scope.groupId : undefined,
51
+ windowId: scope.windowId != null ? scope.windowId : undefined,
52
+ };
53
+ if (typeof listParams.windowId === "string") {
54
+ const resolvedWindowId = (0, scope_1.resolveWindowIdFromSnapshot)(filtered, listParams.windowId);
55
+ listParams.windowId = resolvedWindowId ?? undefined;
56
+ }
57
+ const selection = (0, scope_1.selectTabsFromSnapshot)(filtered, listParams);
58
+ if (selection.error) {
59
+ (0, output_1.printJson)({ ok: false, error: selection.error }, prettyOutput);
60
+ process.exit(1);
61
+ }
62
+ const selectedTabs = selection.tabs || [];
63
+ const tabIdSet = new Set(selectedTabs.map((tab) => tab.tabId).filter((id) => typeof id === "number"));
64
+ const ordered = listParams.all
65
+ ? (0, snapshot_1.orderTabs)(filtered, null)
66
+ : tabIdSet.size > 0
67
+ ? (0, snapshot_1.orderTabs)(filtered, tabIdSet)
68
+ : [];
69
+ const scopeArgs = (0, scope_1.buildScopeArgs)(options, allScope);
70
+ const pagination = (0, pagination_1.resolvePagination)(options, ordered.length, "list", scopeArgs);
71
+ const start = pagination.offset;
72
+ const end = pagination.offset + pagination.limit;
73
+ const pagedTabs = ordered.slice(start, end);
74
+ const pagedSnapshot = (0, snapshot_1.buildPagedSnapshot)(filtered, pagedTabs);
75
+ data.windows = pagedSnapshot.windows;
76
+ if (pagination.page) {
77
+ data.page = pagination.page;
78
+ }
79
+ data.policy = policySummary;
80
+ }
81
+ else if (response.ok) {
82
+ response.policy = policySummary;
83
+ }
84
+ (0, output_1.emitVersionWarnings)(response, "list");
85
+ (0, output_1.printJson)(response, prettyOutput);
86
+ }
87
+ // ============================================================================
88
+ // Group-List Command
89
+ // ============================================================================
90
+ async function runGroupList(options, policyContext, policySummary, prettyOutput) {
91
+ const params = {
92
+ windowId: options.all ? undefined : options.window,
93
+ };
94
+ const response = await (0, client_1.sendRequest)({
95
+ id: (0, client_1.createRequestId)(),
96
+ action: "group-list",
97
+ params,
98
+ client: {
99
+ component: "cli",
100
+ version: constants_1.VERSION,
101
+ baseVersion: constants_1.BASE_VERSION,
102
+ gitSha: constants_1.GIT_SHA,
103
+ dirty: constants_1.DIRTY,
104
+ },
105
+ });
106
+ if (!response.ok) {
107
+ (0, output_1.printJson)(response, prettyOutput);
108
+ process.exit(1);
109
+ }
110
+ const data = response.data;
111
+ // Fallback to snapshot if groups missing
112
+ if (data && (!Array.isArray(data.groups) || data.groups === null)) {
113
+ const scope = (0, scope_1.resolveScopeFlags)(options);
114
+ const snapshot = await (0, client_1.fetchSnapshot)();
115
+ if (snapshot) {
116
+ const filteredSnapshot = (0, snapshot_1.filterSnapshotByPolicy)(snapshot, policyContext.policy);
117
+ if (typeof scope.windowId === "string") {
118
+ const resolvedWindowId = (0, scope_1.resolveWindowIdFromSnapshot)(filteredSnapshot, scope.windowId);
119
+ scope.windowId = resolvedWindowId ?? null;
120
+ }
121
+ const scopeWindow = typeof scope.windowId === "number" && Number.isFinite(scope.windowId) ? scope.windowId : null;
122
+ const groups = (0, snapshot_1.buildGroupsFromSnapshot)(filteredSnapshot, scopeWindow);
123
+ data.groups = (0, scope_1.filterGroupsByScope)(groups, scope, filteredSnapshot, snapshot_1.buildTabIndex);
124
+ }
125
+ else {
126
+ data.groups = [];
127
+ }
128
+ }
129
+ // Apply scope filtering and pagination
130
+ if (data && Array.isArray(data.groups)) {
131
+ let groups = data.groups;
132
+ const scope = (0, scope_1.resolveScopeFlags)(options);
133
+ const allScope = options.all === true || !scope.hasScope;
134
+ let snapshot = null;
135
+ if (!allScope && scope.tabIds.length > 0) {
136
+ snapshot = await (0, client_1.fetchSnapshot)();
137
+ if (!snapshot) {
138
+ (0, output_1.errorOut)("Failed to load tabs for group-list filtering");
139
+ }
140
+ }
141
+ if (!allScope) {
142
+ groups = (0, scope_1.filterGroupsByScope)(groups, scope, snapshot, snapshot_1.buildTabIndex);
143
+ }
144
+ const scopeArgs = (0, scope_1.buildScopeArgs)(options, allScope);
145
+ const pagination = (0, pagination_1.resolvePagination)(options, groups.length, "group-list", scopeArgs);
146
+ const start = pagination.offset;
147
+ const end = pagination.offset + pagination.limit;
148
+ data.groups = groups.slice(start, end);
149
+ if (pagination.page) {
150
+ data.page = pagination.page;
151
+ }
152
+ data.policy = policySummary;
153
+ }
154
+ else if (response.ok) {
155
+ response.policy = policySummary;
156
+ }
157
+ (0, output_1.emitVersionWarnings)(response, "group-list");
158
+ (0, output_1.printJson)(response, prettyOutput);
159
+ }