tabctl 0.6.0-alpha.9 → 0.6.0-rc.2
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 +44 -62
- package/dist/extension/background.js +12 -41
- package/dist/extension/lib/screenshot.js +9 -1
- package/dist/extension/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# tabctl
|
|
2
2
|
|
|
3
|
-
Every open tab is a thread you forgot to pull. Tabctl
|
|
3
|
+
Every open tab is a thread you forgot to pull. Tabctl helps you query and change them safely.
|
|
4
4
|
|
|
5
|
-
A command-line instrument for browser tab orchestration
|
|
5
|
+
A command-line instrument for browser tab orchestration, now centered on a GraphQL API exposed through `tabctl query` and `tabctl schema`, plus `ping` and `history` convenience commands. Built for humans who hoard tabs and the AI agents who clean up after them.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -25,18 +25,17 @@ cargo install --path rust/crates/tabctl
|
|
|
25
25
|
|
|
26
26
|
## Agent Skill
|
|
27
27
|
|
|
28
|
-
Give your coding agent eyes into the browser.
|
|
28
|
+
Give your coding agent eyes into the browser. Install the tabctl skill via the Skills CLI:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
tabctl skill
|
|
32
|
-
# or: npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode -a github-copilot -a claude-code
|
|
31
|
+
npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode -a github-copilot -a claude-code
|
|
33
32
|
```
|
|
34
33
|
|
|
35
34
|
## Safety
|
|
36
35
|
|
|
37
36
|
Nothing leaves your machine. No cloud. No telemetry. Just a socket between your terminal and your browser, quiet as rain on neon.
|
|
38
37
|
|
|
39
|
-
Every mutation is undoable — `
|
|
38
|
+
Every mutation is undoable — `undoAction` rewinds closes, archives, and group changes like they never happened. A configurable policy layer shields pinned tabs and protected domains from accidental destruction. You pull the trigger; tabctl keeps the safety on until you mean it.
|
|
40
39
|
|
|
41
40
|
## What You Can Say
|
|
42
41
|
|
|
@@ -130,75 +129,64 @@ Optional setup release overrides:
|
|
|
130
129
|
|
|
131
130
|
### 3. Verify and explore
|
|
132
131
|
|
|
133
|
-
<!-- test: "ping sends ping action", "list sends list action" -->
|
|
134
132
|
```bash
|
|
135
|
-
tabctl ping
|
|
136
|
-
tabctl
|
|
133
|
+
tabctl ping
|
|
134
|
+
tabctl query '{ tabs { total items { tabId title url } } }'
|
|
135
|
+
tabctl schema
|
|
137
136
|
```
|
|
138
137
|
|
|
139
138
|
> **Multiple browsers?** See [Multi-Browser Setup](#multi-browser-setup) for running tabctl with both Chrome and Edge.
|
|
140
139
|
|
|
141
140
|
## Commands
|
|
142
141
|
|
|
143
|
-
<!-- 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" -->
|
|
144
142
|
| Command | Description |
|
|
145
143
|
|---------|-------------|
|
|
146
|
-
| `tabctl
|
|
147
|
-
| `tabctl
|
|
148
|
-
| `tabctl
|
|
149
|
-
| `tabctl
|
|
150
|
-
| `tabctl
|
|
151
|
-
| `tabctl close --tab <id>` | Close tabs with full undo support |
|
|
152
|
-
| `tabctl report` | Generate reports in JSON, Markdown, or CSV |
|
|
153
|
-
| `tabctl undo` | Revert the last action |
|
|
144
|
+
| `tabctl query '<GRAPHQL>'` | Query and mutate browser state through GraphQL |
|
|
145
|
+
| `tabctl schema` | Print the GraphQL schema |
|
|
146
|
+
| `tabctl ping` | Check host/browser connectivity and runtime version sync |
|
|
147
|
+
| `tabctl history` | Show recent undo history entries |
|
|
148
|
+
| `tabctl setup`, `doctor`, `policy`, `profile-*` | Local/admin profile management |
|
|
154
149
|
|
|
155
150
|
See [CLI.md](CLI.md) for the full command reference, options, and examples.
|
|
156
151
|
|
|
157
|
-
##
|
|
158
|
-
When `--out` is omitted, screenshots are written to `./.tabctl/screenshots/<timestamp>` and the JSON response includes `writtenTo`.
|
|
159
|
-
|
|
160
|
-
## Agent workflow (context -> selector)
|
|
161
|
-
Use screenshots only when you need visual context, then extract selectors with `inspect`.
|
|
152
|
+
## GraphQL examples
|
|
162
153
|
|
|
163
|
-
1) Capture context (full page tiles):
|
|
164
|
-
<!-- test: "screenshot passes capture options" -->
|
|
165
154
|
```bash
|
|
166
|
-
|
|
167
|
-
|
|
155
|
+
# Query tabs and groups
|
|
156
|
+
tabctl query '{ windows { windowId groups { groupId title } tabs { tabId title url groupTitle } } }'
|
|
168
157
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
tabctl inspect --tab <id> --signal selector --selector '{"name":"target","selector":".your-selector"}'
|
|
173
|
-
```
|
|
158
|
+
# Analyze stale and duplicate tabs
|
|
159
|
+
tabctl query '{ analyze(windowId: 123, staleDays: 30) { totalTabs duplicateTabs staleTabs } }'
|
|
174
160
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
```bash
|
|
178
|
-
tabctl inspect --tab <id> --signal selector --selector '{"name":"link","selector":"a[href]","attr":"href-url"}'
|
|
179
|
-
tabctl inspect --tab <id> --signal selector --selector "link=a[href]" --selector-attr href-url
|
|
180
|
-
```
|
|
161
|
+
# Inspect page metadata
|
|
162
|
+
tabctl query 'query { inspectTabs(tabIds: [456], signals: ["page-meta"]) { entries { tabId signals { name valueJson } } } }'
|
|
181
163
|
|
|
182
|
-
|
|
164
|
+
# Generate reports
|
|
165
|
+
tabctl query '{ reportTabs(windowId: 123) { entries { tabId title url description } } }'
|
|
183
166
|
|
|
184
|
-
|
|
167
|
+
# Capture screenshots
|
|
168
|
+
tabctl query 'query { captureScreenshots(tabIds: [456], mode: "viewport") { entries { tabId tiles { index width height } } } }'
|
|
185
169
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
170
|
+
# Open tabs in a new grouped window
|
|
171
|
+
tabctl query 'mutation { openTabs(urls: ["https://example.com"], group: "Research", newWindow: true) { windowId groupId tabs { tabId url } } }'
|
|
172
|
+
|
|
173
|
+
# Close tabs with undo support
|
|
174
|
+
tabctl query 'mutation { closeTabs(tabIds: [456], confirm: true) { txid closedTabs } }'
|
|
175
|
+
tabctl query 'mutation { undoAction(latest: true) { txid summary } }'
|
|
189
176
|
```
|
|
190
177
|
|
|
191
|
-
|
|
178
|
+
## Agent skills
|
|
179
|
+
|
|
180
|
+
Install the tabctl skill for agents (OpenCode, Claude Code, Codex, etc.) via the Skills CLI:
|
|
192
181
|
|
|
193
|
-
<!-- test: "skill install supports global scope" -->
|
|
194
182
|
```bash
|
|
195
|
-
tabctl skill
|
|
183
|
+
npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode
|
|
196
184
|
```
|
|
197
185
|
|
|
198
|
-
|
|
186
|
+
Install globally:
|
|
199
187
|
|
|
200
188
|
```bash
|
|
201
|
-
npx skills add https://github.com/ekroon/tabctl --skill tabctl -a opencode
|
|
189
|
+
npx skills add https://github.com/ekroon/tabctl --skill tabctl --global -a opencode
|
|
202
190
|
```
|
|
203
191
|
|
|
204
192
|
## Policy (protect tabs)
|
|
@@ -261,11 +249,11 @@ Relevant knobs: `TABCTL_SOCKET`, `TABCTL_TCP_PORT`, `TABCTL_PROFILE`, `TABCTL_DA
|
|
|
261
249
|
- `tabctl setup` fails with `Windows setup verification failed`: check `data.verification.reason` in JSON output (`ping-timeout`, `socket-not-found`, `socket-refused`, `ping-not-ok`, `extension-id-mismatch`), then follow printed manual steps.
|
|
262
250
|
- Runtime ID mismatch (`extension-id-mismatch`): compare expected vs runtime IDs from setup output, then rerun setup with the runtime ID shown by `edge://extensions` / `chrome://extensions`:
|
|
263
251
|
- `tabctl setup --browser <edge|chrome> --extension-id <runtime-id>`
|
|
264
|
-
- Runtime command runs can auto-sync extension files when host/extension versions drift; rerun `tabctl
|
|
252
|
+
- Runtime command runs can auto-sync extension files when host/extension versions drift; rerun `tabctl query 'mutation { reloadExtension { reloading } }'` if the browser does not pick up changes immediately.
|
|
265
253
|
- For local release-like testing while developing, force runtime sync behavior with `TABCTL_AUTO_SYNC_MODE=release-like`.
|
|
266
254
|
- Disable runtime sync entirely with `TABCTL_AUTO_SYNC_MODE=off`.
|
|
267
255
|
- `tabctl ping --json` is the canonical runtime version check (`versionsInSync`, `hostBaseVersion`, `baseVersion`).
|
|
268
|
-
- Version metadata is intentionally health-only: regular
|
|
256
|
+
- Version metadata is intentionally health-only: regular GraphQL payloads do not include version fields unless you explicitly query health surfaces.
|
|
269
257
|
- `tabctl ping` returns connect errors (`ENOENT`, `ECONNREFUSED`, timeout): ensure extension is loaded and active, rerun `tabctl setup`, and in WSL verify `TABCTL_TCP_PORT` or `<dataDir>/tcp-port` matches a listening localhost port.
|
|
270
258
|
- `tabctl doctor --fix --json` includes per-profile connectivity diagnostics in `data.profiles[].connectivity`; if ping remains unhealthy after local repairs, follow `manualSteps`.
|
|
271
259
|
|
|
@@ -275,7 +263,7 @@ Local release-like sync test recipe:
|
|
|
275
263
|
tabctl setup --browser edge --extension-id <extension-id> --release-tag v0.5.2
|
|
276
264
|
|
|
277
265
|
# 2) Run the current binary with forced release-like auto-sync
|
|
278
|
-
TABCTL_AUTO_SYNC_MODE=release-like cargo run --manifest-path rust/Cargo.toml -p tabctl --
|
|
266
|
+
TABCTL_AUTO_SYNC_MODE=release-like cargo run --manifest-path rust/Cargo.toml -p tabctl -- query '{ tabs { total } }'
|
|
279
267
|
|
|
280
268
|
# 3) Verify host/extension base versions are back in sync
|
|
281
269
|
tabctl ping --json
|
|
@@ -302,7 +290,7 @@ tabctl profile-list
|
|
|
302
290
|
tabctl profile-switch edge
|
|
303
291
|
|
|
304
292
|
# One-off command with different profile
|
|
305
|
-
tabctl
|
|
293
|
+
tabctl --profile chrome-work query '{ tabs { total } }'
|
|
306
294
|
```
|
|
307
295
|
|
|
308
296
|
### Custom Chrome Profile Directories
|
|
@@ -399,15 +387,9 @@ TABCTL_VERSION_MODE=release npm run build
|
|
|
399
387
|
```
|
|
400
388
|
|
|
401
389
|
Notes:
|
|
402
|
-
-
|
|
403
|
-
- `close` without `--apply` requires `--confirm` to prevent accidental closure.
|
|
390
|
+
- Browser reads and mutations now go through GraphQL via `tabctl query`.
|
|
404
391
|
- Reports include short descriptions from page metadata and a fallback snippet.
|
|
405
|
-
- `
|
|
406
|
-
-
|
|
407
|
-
-
|
|
408
|
-
- Unknown inspect signals are rejected (valid: `page-meta`, `selector`).
|
|
409
|
-
- Selector `attr` supports `href-url`/`src-url` to return absolute http(s) URLs.
|
|
410
|
-
- `screenshot --out` writes per-tab folders into the target directory.
|
|
411
|
-
- `tabctl undo` accepts a positional txid, `--txid`, or `--latest`.
|
|
392
|
+
- `inspectTabs` supports `page-meta` and `selector` signals.
|
|
393
|
+
- `captureScreenshots` returns tile metadata and image data from GraphQL.
|
|
394
|
+
- `undoAction` accepts either an explicit `txid` or `latest: true`.
|
|
412
395
|
- `tabctl history --json` returns a top-level JSON array.
|
|
413
|
-
- `--format` is only supported by `report` (use `--json` elsewhere).
|
|
@@ -163,7 +163,12 @@
|
|
|
163
163
|
if (format === "jpeg") {
|
|
164
164
|
options.quality = quality;
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
try {
|
|
167
|
+
return await chrome.tabs.captureVisibleTab(windowId, options);
|
|
168
|
+
} catch {
|
|
169
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
170
|
+
return chrome.tabs.captureVisibleTab(windowId, options);
|
|
171
|
+
}
|
|
167
172
|
}
|
|
168
173
|
async function getPageMetrics(tabId, timeoutMs, deps) {
|
|
169
174
|
const result = await deps.executeWithTimeout(tabId, timeoutMs, () => {
|
|
@@ -481,9 +486,7 @@
|
|
|
481
486
|
return n;
|
|
482
487
|
}
|
|
483
488
|
var state = {
|
|
484
|
-
port: null
|
|
485
|
-
lastFocused: {},
|
|
486
|
-
lastFocusedLoaded: false
|
|
489
|
+
port: null
|
|
487
490
|
};
|
|
488
491
|
function log(...args) {
|
|
489
492
|
console.log("[tabctl]", ...args);
|
|
@@ -535,41 +538,6 @@
|
|
|
535
538
|
connectNative();
|
|
536
539
|
}
|
|
537
540
|
});
|
|
538
|
-
async function ensureLastFocusedLoaded() {
|
|
539
|
-
if (state.lastFocusedLoaded) {
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const stored = await chrome.storage.local.get("lastFocused");
|
|
543
|
-
state.lastFocused = stored.lastFocused || {};
|
|
544
|
-
state.lastFocusedLoaded = true;
|
|
545
|
-
}
|
|
546
|
-
async function setLastFocused(tabId) {
|
|
547
|
-
await ensureLastFocusedLoaded();
|
|
548
|
-
state.lastFocused[String(tabId)] = Date.now();
|
|
549
|
-
await chrome.storage.local.set({ lastFocused: state.lastFocused });
|
|
550
|
-
}
|
|
551
|
-
chrome.tabs.onActivated.addListener((info) => {
|
|
552
|
-
setLastFocused(info.tabId).catch((error) => log("Failed to set last focused", error));
|
|
553
|
-
});
|
|
554
|
-
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
555
|
-
ensureLastFocusedLoaded().then(() => {
|
|
556
|
-
const key = String(tabId);
|
|
557
|
-
if (state.lastFocused[key]) {
|
|
558
|
-
delete state.lastFocused[key];
|
|
559
|
-
chrome.storage.local.set({ lastFocused: state.lastFocused });
|
|
560
|
-
}
|
|
561
|
-
}).catch((error) => log("Failed to prune last focused", error));
|
|
562
|
-
});
|
|
563
|
-
chrome.windows.onFocusChanged.addListener((windowId) => {
|
|
564
|
-
if (windowId === chrome.windows.WINDOW_ID_NONE) {
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
chrome.tabs.query({ windowId, active: true }).then((tabs) => {
|
|
568
|
-
if (tabs[0] && tabs[0].id != null) {
|
|
569
|
-
setLastFocused(tabs[0].id).catch((error) => log("Failed to set last focused", error));
|
|
570
|
-
}
|
|
571
|
-
}).catch((error) => log("Failed to query active tab", error));
|
|
572
|
-
});
|
|
573
541
|
async function handleNativeMessage(message) {
|
|
574
542
|
if (!message || typeof message !== "object") {
|
|
575
543
|
return;
|
|
@@ -678,7 +646,6 @@
|
|
|
678
646
|
}
|
|
679
647
|
}
|
|
680
648
|
async function getTabSnapshot() {
|
|
681
|
-
await ensureLastFocusedLoaded();
|
|
682
649
|
const windows = await chrome.windows.getAll({ populate: true, windowTypes: ["normal"] });
|
|
683
650
|
const groups = await chrome.tabGroups.query({});
|
|
684
651
|
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
@@ -697,7 +664,11 @@
|
|
|
697
664
|
groupTitle: group ? group.title : null,
|
|
698
665
|
groupColor: group ? group.color : null,
|
|
699
666
|
groupCollapsed: group ? group.collapsed : null,
|
|
700
|
-
|
|
667
|
+
lastAccessedAt: tab.lastAccessed || null,
|
|
668
|
+
favIconUrl: tab.favIconUrl || null,
|
|
669
|
+
status: tab.status || null,
|
|
670
|
+
discarded: tab.discarded || false,
|
|
671
|
+
audible: tab.audible || false
|
|
701
672
|
};
|
|
702
673
|
});
|
|
703
674
|
const windowGroups = groups.filter((group) => group.windowId === win.id).map((group) => ({
|
|
@@ -155,7 +155,15 @@ async function captureVisible(windowId, format, quality) {
|
|
|
155
155
|
if (format === "jpeg") {
|
|
156
156
|
options.quality = quality;
|
|
157
157
|
}
|
|
158
|
-
|
|
158
|
+
// Retry once after a short delay — headless Chrome on Windows can fail the
|
|
159
|
+
// first readback when the compositor hasn't fully initialised.
|
|
160
|
+
try {
|
|
161
|
+
return await chrome.tabs.captureVisibleTab(windowId, options);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
165
|
+
return chrome.tabs.captureVisibleTab(windowId, options);
|
|
166
|
+
}
|
|
159
167
|
}
|
|
160
168
|
async function getPageMetrics(tabId, timeoutMs, deps) {
|
|
161
169
|
const result = await deps.executeWithTimeout(tabId, timeoutMs, () => {
|