diff --git a/.changeset/tab-groups-write.md b/.changeset/tab-groups-write.md new file mode 100644 index 0000000..a79b486 --- /dev/null +++ b/.changeset/tab-groups-write.md @@ -0,0 +1,6 @@ +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins group` puts tabs in a new or existing tab group and edits a group's title, color, and collapsed state. `reins ungroup` takes tabs out of their group or dissolves a whole group, and never closes tabs. reins groups nothing on its own; the agent decides. Group operations count as reading: read-only sites can be grouped, denied sites cannot. diff --git a/packages/cli/src/cli-commands.ts b/packages/cli/src/cli-commands.ts index cc5f49e..ada7bfe 100644 --- a/packages/cli/src/cli-commands.ts +++ b/packages/cli/src/cli-commands.ts @@ -25,7 +25,7 @@ export function helpText(version: string, tools: Record): s "Usage: reins [flags]", "", "Tabs & pages:", - ...["tabs", "groups", "open", "close", "focus", "nav"].map(tool), + ...["tabs", "groups", "group", "ungroup", "open", "close", "focus", "nav"].map(tool), "", "Interaction:", ...[ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2208b0f..60d71a4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -52,7 +52,7 @@ async function runTool(name: string, cmd: ToolCommand, argv: string[]): Promise< await waitForBrowsers(ensured.port); } - const result = await rpc(ensured.port, cmd.method, params); + const result = await rpc(ensured.port, cmd.methodFor?.(params) ?? cmd.method, params); if (name === "screenshot") { const shot = result as { data: string; mimeType: string }; diff --git a/packages/cli/src/commands.test.ts b/packages/cli/src/commands.test.ts index e544878..786b4ff 100644 --- a/packages/cli/src/commands.test.ts +++ b/packages/cli/src/commands.test.ts @@ -203,6 +203,46 @@ describe("TOOL_COMMANDS: params", () => { expect(() => build("cdp", ["Page.enable", "not json"])).toThrow("must be valid JSON"); expect(() => build("cdp", ["Page.enable", "[1,2]"])).toThrow("must be a JSON object"); }); + + it("group: repeatable --tab creates a group; props map through", () => { + const p = build("group", ["--tab", "12", "--tab", "13", "--title", "reins", "--color", "blue"]); + expect(p).toEqual({ tabIds: [12, 13], title: "reins", color: "blue" }); + expect(cmd("group").methodFor?.(p)).toBe("group_tabs"); + }); + + it("group: --tab with --group adds to that group", () => { + const p = build("group", ["--tab", "14", "--group", "7", "--browser", "b1"]); + expect(p).toEqual({ tabIds: [14], groupId: 7, browserId: "b1" }); + expect(cmd("group").methodFor?.(p)).toBe("group_tabs"); + }); + + it("group: --group without --tab edits the group", () => { + const p = build("group", ["--group", "7", "--collapse"]); + expect(p).toEqual({ groupId: 7, collapsed: true }); + expect(cmd("group").methodFor?.(p)).toBe("update_group"); + expect(build("group", ["--group", "7", "--expand"])).toEqual({ groupId: 7, collapsed: false }); + }); + + it("group: usage errors", () => { + expect(() => build("group", [])).toThrow(UsageError); + expect(() => build("group", ["--group", "7"])).toThrow(/--title, --color, --collapse/); + expect(() => build("group", ["--tab", "1", "--collapse", "--expand"])).toThrow(UsageError); + expect(() => build("group", ["--tab", "1", "--color", "magenta"])).toThrow(UsageError); + expect(() => build("group", ["--tab", "x"])).toThrow(UsageError); + }); + + it("group: formats the group id or ok", () => { + expect(format("group", { groupId: 7 })).toBe("group 7"); + expect(format("group", { ok: true })).toBe("ok"); + }); + + it("ungroup: exactly one of --tab or --group", () => { + expect(build("ungroup", ["--tab", "1", "--tab", "2"])).toEqual({ tabIds: [1, 2] }); + expect(build("ungroup", ["--group", "7"])).toEqual({ groupId: 7 }); + expect(() => build("ungroup", [])).toThrow(UsageError); + expect(() => build("ungroup", ["--tab", "1", "--group", "7"])).toThrow(UsageError); + expect(cmd("ungroup").method).toBe("ungroup_tabs"); + }); }); describe("TOOL_COMMANDS: formatting", () => { diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 9bf0ea7..6b0a5ce 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -13,6 +13,9 @@ import { groupsText, tabsText } from "./cli-commands.js"; /** One `reins ` tool subcommand: flags → /rpc params → printed text. */ export interface ToolCommand { method: string; + /** Pick the bridge method from the built params, when one command covers + * several (default: `method`). */ + methodFor?(params: Record): string; usage: string; summary: string; booleans?: string[]; @@ -57,6 +60,42 @@ function oneOf(a: ParsedArgs, name: string, allowed: string[]): string | undefin return v; } +const GROUP_COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]; + +/** Repeatable --tab → tab ids (undefined when absent). */ +function tabList(a: ParsedArgs): number[] | undefined { + const v = a.flags.tab; + if (v === undefined) return undefined; + const list = Array.isArray(v) ? v : [v]; + return list.map((s) => { + const n = Number(s); + if (typeof s !== "string" || !Number.isInteger(n)) { + throw new UsageError(`--tab must be an integer, got "${String(s)}"`); + } + return n; + }); +} + +/** --title / --color / --collapse|--expand → group props. */ +function groupProps(a: ParsedArgs): Record { + const out: Record = {}; + const title = flagStr(a, "title"); + if (title !== undefined) out.title = title; + const color = oneOf(a, "color", GROUP_COLORS); + if (color !== undefined) out.color = color; + if (a.flags.collapse === true && a.flags.expand === true) { + throw new UsageError("--collapse and --expand are mutually exclusive"); + } + if (a.flags.collapse === true) out.collapsed = true; + if (a.flags.expand === true) out.collapsed = false; + return out; +} + +function browserOnly(a: ParsedArgs): Record { + const browser = flagStr(a, "browser"); + return browser !== undefined ? { browserId: browser } : {}; +} + /** Shared routing/targeting flags (--browser, --tab). */ function base(a: ParsedArgs): Record { const out: Record = {}; @@ -86,25 +125,67 @@ export const TOOL_COMMANDS: Record = { method: "list_tabs", usage: "reins tabs [--browser ]", summary: "list tabs across all connected browsers", - build: (a) => { - const browser = flagStr(a, "browser"); - return browser !== undefined ? { browserId: browser } : {}; - }, + build: browserOnly, format: (r) => tabsText((r as { tabs: Tab[] }).tabs), }, groups: { method: "list_groups", usage: "reins groups [--browser ]", summary: "list tab groups across all connected browsers", - build: (a) => { - const browser = flagStr(a, "browser"); - return browser !== undefined ? { browserId: browser } : {}; - }, + build: browserOnly, format: (r) => { const { groups, skipped } = r as { groups: TabGroup[]; skipped?: SkippedBrowser[] }; return groupsText(groups, skipped); }, }, + group: { + method: "group_tabs", + methodFor: (p) => (p.tabIds === undefined ? "update_group" : "group_tabs"), + usage: + "reins group --tab [--tab …] [--group ] [--title ] [--color ] [--collapse|--expand]\n reins group --group [--title ] [--color ] [--collapse|--expand]", + summary: "group tabs (new group, or --group to add/edit one)", + booleans: ["collapse", "expand"], + multi: ["tab"], + build: (a) => { + const tabIds = tabList(a); + const groupId = flagInt(a, "group"); + const props = groupProps(a); + if (tabIds === undefined && groupId === undefined) { + throw new UsageError("--tab (to group tabs) or --group (to edit a group) is required"); + } + if (tabIds === undefined && Object.keys(props).length === 0) { + throw new UsageError("editing a group needs --title, --color, --collapse or --expand"); + } + return { + ...browserOnly(a), + ...(tabIds !== undefined ? { tabIds } : {}), + ...(groupId !== undefined ? { groupId } : {}), + ...props, + }; + }, + format: (r) => { + const g = r as { groupId?: number }; + return g.groupId !== undefined ? `group ${g.groupId}` : "ok"; + }, + }, + ungroup: { + method: "ungroup_tabs", + usage: "reins ungroup --tab [--tab …] | --group ", + summary: "take tabs out of their group, or dissolve a group (tabs stay open)", + multi: ["tab"], + build: (a) => { + const tabIds = tabList(a); + const groupId = flagInt(a, "group"); + if ((tabIds === undefined) === (groupId === undefined)) { + throw new UsageError("exactly one of --tab or --group is required"); + } + return { + ...browserOnly(a), + ...(tabIds !== undefined ? { tabIds } : { groupId }), + }; + }, + format: ok, + }, open: { method: "open_tab", usage: "reins open [--background]", diff --git a/packages/extension/src/lib/dispatch.test.ts b/packages/extension/src/lib/dispatch.test.ts index f3a530d..01ba86a 100644 --- a/packages/extension/src/lib/dispatch.test.ts +++ b/packages/extension/src/lib/dispatch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // vi.mock is hoisted by vitest so cdp.js is stubbed before any imports run. vi.mock("./monitor.js", () => ({ @@ -394,3 +394,102 @@ describe("dispatchWithMeta", () => { expect(result).not.toHaveProperty("meta"); }); }); + +describe("tab group gate", () => { + const URLS: Record = { + 1: "https://x.com/", + 2: "https://bank.com/", + 3: "https://docs.com/", + }; + function stubGroupChrome() { + vi.stubGlobal("chrome", { + tabs: { + get: async (id: number) => ({ id, url: URLS[id], windowId: 1 }), + query: vi.fn(async () => [{ id: 1 }, { id: 2 }]), + group: vi.fn(async () => 7), + ungroup: vi.fn(async () => undefined), + }, + tabGroups: { query: async () => [], update: vi.fn(async () => ({})) }, + }); + } + function denyBank() { + vi.mocked(ensureAllowed).mockImplementation(async (_m, host) => { + if (host === "bank.com") { + const e = new PolicyDenied("blocked by policy: bank.com is denied"); + e.meta = { host: "bank.com", tier: "deny" }; + throw e; + } + return "read"; + }); + } + + // mockImplementation (denyBank) persists across tests; clearAllMocks only + // wipes call history. Reset to the allow-all default before each test. + beforeEach(() => { + vi.mocked(ensureAllowed).mockReset(); + vi.mocked(ensureAllowed).mockResolvedValue(undefined as never); + }); + + it("group_tabs checks every tab's host at the group_tabs tier", async () => { + stubGroupChrome(); + const out = await dispatchWithMeta("group_tabs", { tabIds: [1, 3], title: "t" }); + expect(out.result).toEqual({ groupId: 7 }); + expect(vi.mocked(ensureAllowed).mock.calls).toEqual([ + ["group_tabs", "x.com"], + ["group_tabs", "docs.com"], + ]); + expect(out.meta).toEqual({}); + }); + + it("one denied tab refuses the whole group_tabs call, tagged with that tab", async () => { + stubGroupChrome(); + denyBank(); + const err = (await dispatchWithMeta("group_tabs", { tabIds: [1, 2] }).catch( + (e: unknown) => e, + )) as PolicyDenied; + expect(err).toBeInstanceOf(PolicyDenied); + expect(err.meta).toEqual({ host: "bank.com", tier: "deny", tabId: 2 }); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + }); + + it("ungroup --group checks the group's current tabs and ungroups exactly those", async () => { + stubGroupChrome(); + denyBank(); + await expect(dispatchWithMeta("ungroup_tabs", { groupId: 7 })).rejects.toBeInstanceOf( + PolicyDenied, + ); + expect(chrome.tabs.query).toHaveBeenCalledWith({ groupId: 7 }); + + vi.mocked(ensureAllowed).mockReset(); + vi.mocked(ensureAllowed).mockResolvedValue("read"); + vi.mocked(chrome.tabs.query).mockClear(); + await dispatchWithMeta("ungroup_tabs", { groupId: 7 }); + expect(chrome.tabs.ungroup).toHaveBeenCalledWith([1, 2]); + // The gate resolved the group once; the handler acted on that pinned set + // rather than querying again. + expect(chrome.tabs.query).toHaveBeenCalledTimes(1); + }); + + it("update_group has no host gate", async () => { + stubGroupChrome(); + await dispatchWithMeta("update_group", { groupId: 7, title: "done" }); + expect(ensureAllowed).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { title: "done" }); + }); + + it("validates params (exactly-one rule) before touching tabs", async () => { + stubGroupChrome(); + await expect(dispatchWithMeta("ungroup_tabs", {})).rejects.toThrow(/exactly one/); + await expect(dispatchWithMeta("update_group", { groupId: 7 })).rejects.toThrow( + /title, color, or collapsed/, + ); + }); + + it("unsupported browser fails before any policy check", async () => { + vi.stubGlobal("chrome", { tabs: { get: async () => ({}), query: async () => [] } }); + await expect(dispatchWithMeta("ungroup_tabs", { groupId: 7 })).rejects.toThrow( + "doesn't support tab groups", + ); + expect(ensureAllowed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/src/lib/dispatch.ts b/packages/extension/src/lib/dispatch.ts index 8f6e9f8..c61b24e 100644 --- a/packages/extension/src/lib/dispatch.ts +++ b/packages/extension/src/lib/dispatch.ts @@ -1,10 +1,13 @@ import { effectiveTier, type GatedMethod, + GroupTabsParams, hostOf, METHOD_TIERS, PolicyTightenParams, type ResponseMeta, + UngroupTabsParams, + UpdateGroupParams, } from "@reins/protocol"; import { cdpClick, @@ -31,7 +34,14 @@ import { } from "./page-actions.js"; import { ensureAllowed, PolicyDenied, policy, tightenPolicy } from "./policy.js"; import { reloadExtension } from "./reload.js"; -import { listGroups } from "./tab-groups.js"; +import { + groupTabIds, + groupTabs, + listGroups, + requireGroups, + ungroupTabs, + updateGroup, +} from "./tab-groups.js"; import { closeTab, listTabs, resizeWindow, selectTab } from "./tab-handler.js"; const NAV_HISTORY = new Set(["back", "forward", "reload"]); @@ -41,17 +51,46 @@ interface Gated { meta: ResponseMeta; } +/** Check every target tab's host; the first denial refuses the whole call, + * tagged with that tab for the audit trail. */ +async function ensureTabsAllowed(method: GatedMethod, tabIds: number[]): Promise { + for (const tabId of tabIds) { + const tab = await chrome.tabs.get(tabId); + try { + await ensureAllowed(method, hostOf(tab.url ?? "")); + } catch (err) { + if (err instanceof PolicyDenied && err.meta) err.meta = { ...err.meta, tabId }; + throw err; + } + } +} + /** * Policy gate. Resolves the target tab once (so gate and handler agree), * checks the host's tier against the method's required tier, and returns * params with tabId pinned plus the resolved host/tier/tabId for the audit * trail. list_tabs is gated per-tab (redaction) in runHandler; open_tab has * no current tab and checks its destination. list_groups has no host (group - * titles are the user's own labels). + * titles are the user's own labels). Group ops check every target tab (for + * a whole group, its current tabs) and pin that set in params; update_group + * only edits the group's label. */ async function gate(method: GatedMethod, params: unknown): Promise { const p = { ...((params ?? {}) as Record) }; if (method === "list_tabs" || method === "list_groups") return { params: p, meta: {} }; + if (method === "update_group") { + requireGroups(); + return { params: UpdateGroupParams.parse(p), meta: {} }; + } + if (method === "group_tabs" || method === "ungroup_tabs") { + // Before any lookup: on a browser without groups, tabs.query({groupId}) + // may ignore the filter and return every tab. + requireGroups(); + const parsed = (method === "group_tabs" ? GroupTabsParams : UngroupTabsParams).parse(p); + const tabIds = parsed.tabIds ?? (await groupTabIds(parsed.groupId as number)); + await ensureTabsAllowed(method, tabIds); + return { params: { ...parsed, tabIds }, meta: {} }; + } if (method === "open_tab") { const host = hostOf(String(p.url ?? "")); const tier = await ensureAllowed("open_tab", host); @@ -107,6 +146,12 @@ async function runHandler(method: GatedMethod, gated: Record): } case "list_groups": return listGroups(); + case "group_tabs": + return groupTabs(gated as Parameters[0]); + case "update_group": + return updateGroup(gated as Parameters[0]); + case "ungroup_tabs": + return ungroupTabs(gated as Parameters[0]); case "open_tab": return cdpOpenTab(gated as Parameters[0]); case "close_tab": diff --git a/packages/extension/src/lib/tab-groups.test.ts b/packages/extension/src/lib/tab-groups.test.ts index 8f95243..edb8969 100644 --- a/packages/extension/src/lib/tab-groups.test.ts +++ b/packages/extension/src/lib/tab-groups.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { GroupsUnsupported, groupsSupported, listGroups } from "./tab-groups.js"; +import { + GroupsUnsupported, + groupsSupported, + groupTabIds, + groupTabs, + listGroups, + ungroupTabs, + updateGroup, +} from "./tab-groups.js"; afterEach(() => vi.unstubAllGlobals()); @@ -72,3 +80,59 @@ describe("listGroups", () => { ); }); }); + +describe("groupTabs", () => { + it("creates a new group in the first tab's window, then applies props", async () => { + stubGroups(); + const out = await groupTabs({ tabIds: [1, 2], title: "reins", color: "blue" }); + expect(out).toEqual({ groupId: 7 }); + expect(chrome.tabs.group).toHaveBeenCalledWith({ + tabIds: [1, 2], + createProperties: { windowId: 1 }, + }); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { title: "reins", color: "blue" }); + }); + + it("adds to an existing group without createProperties, and skips update when no props", async () => { + stubGroups(); + await groupTabs({ tabIds: [3], groupId: 7 }); + expect(chrome.tabs.group).toHaveBeenCalledWith({ tabIds: [3], groupId: 7 }); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it("refuses with code=unsupported on browsers without groups", async () => { + stubGroups({ tabGroups: undefined }); + await expect(groupTabs({ tabIds: [1] })).rejects.toBeInstanceOf(GroupsUnsupported); + }); +}); + +describe("updateGroup", () => { + it("passes only the given props", async () => { + stubGroups(); + expect(await updateGroup({ groupId: 7, collapsed: true })).toEqual({ ok: true }); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { collapsed: true }); + }); +}); + +describe("ungroupTabs / groupTabIds", () => { + it("ungroups exactly the gate-resolved ids, never re-resolving (tabs stay open)", async () => { + stubGroups(); + const query = vi.fn(async () => [{ id: 1 }, { id: 2 }, { id: 3 }]); + (chrome.tabs as { query: unknown }).query = query; + expect(await ungroupTabs({ tabIds: [1, 2] })).toEqual({ ok: true }); + expect(chrome.tabs.ungroup).toHaveBeenCalledWith([1, 2]); + expect(query).not.toHaveBeenCalled(); + }); + + it("an empty set is a no-op", async () => { + stubGroups(); + await ungroupTabs({ tabIds: [] }); + expect(chrome.tabs.ungroup).not.toHaveBeenCalled(); + }); + + it("groupTabIds drops tabs without an id", async () => { + stubGroups(); + (chrome.tabs as { query: unknown }).query = async () => [{ id: 4 }, {}]; + expect(await groupTabIds(7)).toEqual([4]); + }); +}); diff --git a/packages/extension/src/lib/tab-groups.ts b/packages/extension/src/lib/tab-groups.ts index 8c5cc00..030d76e 100644 --- a/packages/extension/src/lib/tab-groups.ts +++ b/packages/extension/src/lib/tab-groups.ts @@ -1,4 +1,11 @@ -import type { ListGroupsResult } from "@reins/protocol"; +import type { + GroupTabsParams, + GroupTabsResult, + ListGroupsResult, + OkResult, + TabGroupColor, + UpdateGroupParams, +} from "@reins/protocol"; /** The browser has no tab-group API (some Chromium forks). `code` survives to the * ResponseFrame, so the agent can tell the user instead of guessing. */ @@ -45,3 +52,55 @@ export async function listGroups(): Promise { })), }; } + +type GroupProps = { title?: string; color?: TabGroupColor; collapsed?: boolean }; + +/** Only the props the agent actually set — undefined would reset them. */ +function groupProps(p: GroupProps): chrome.tabGroups.UpdateProperties | undefined { + const out: chrome.tabGroups.UpdateProperties = {}; + if (p.title !== undefined) out.title = p.title; + if (p.color !== undefined) out.color = p.color as chrome.tabGroups.UpdateProperties["color"]; + if (p.collapsed !== undefined) out.collapsed = p.collapsed; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** The ids of every tab currently in `groupId`. */ +export async function groupTabIds(groupId: number): Promise { + const tabs = await chrome.tabs.query({ groupId }); + return tabs.map((t) => t.id).filter((id): id is number => id !== undefined); +} + +/** Handle `group_tabs`: a new group, or add to `groupId`; then apply props. */ +export async function groupTabs(p: GroupTabsParams): Promise { + requireGroups(); + const tabIds = p.tabIds as [number, ...number[]]; + let groupId: number; + if (p.groupId !== undefined) { + groupId = await chrome.tabs.group({ tabIds, groupId: p.groupId }); + } else { + // A new group defaults to the *focused* window, which would drag the + // tabs across windows — keep it where the first tab already lives. + const first = await chrome.tabs.get(tabIds[0]); + groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: first.windowId } }); + } + const props = groupProps(p); + if (props) await chrome.tabGroups.update(groupId, props); + return { groupId }; +} + +/** Handle `update_group`. */ +export async function updateGroup(p: UpdateGroupParams): Promise { + requireGroups(); + await chrome.tabGroups.update(p.groupId, groupProps(p) ?? {}); + return { ok: true }; +} + +/** Handle `ungroup_tabs`. The dispatch gate resolves `--group` to tab ids + * (via `groupTabIds`) and policy-checks each one; this handler acts only on + * that gate-resolved set and never re-resolves, so an unchecked tab can't + * slip in. Tabs stay open. */ +export async function ungroupTabs(p: { tabIds: number[] }): Promise { + requireGroups(); + if (p.tabIds.length > 0) await chrome.tabs.ungroup(p.tabIds as [number, ...number[]]); + return { ok: true }; +} diff --git a/packages/protocol/src/cdp.test.ts b/packages/protocol/src/cdp.test.ts index de9b8da..0c66dd2 100644 --- a/packages/protocol/src/cdp.test.ts +++ b/packages/protocol/src/cdp.test.ts @@ -10,6 +10,7 @@ import { EvalParams, EvalResult, FillParams, + GroupTabsParams, HoverParams, NavigateParams, NetworkEntry, @@ -29,6 +30,8 @@ import { SnapshotParams, SnapshotResult, TypeParams, + UngroupTabsParams, + UpdateGroupParams, UploadParams, WaitForParams, } from "./cdp.js"; @@ -322,3 +325,25 @@ describe("NetworkResult schema", () => { expect(() => NetworkResult.parse({})).toThrow(); }); }); + +describe("tab group params", () => { + it("GroupTabsParams needs at least one tab and a known color", () => { + expect(GroupTabsParams.parse({ tabIds: [1, 2], title: "reins", color: "blue" })).toMatchObject({ + tabIds: [1, 2], + }); + expect(() => GroupTabsParams.parse({ tabIds: [] })).toThrow(); + expect(() => GroupTabsParams.parse({ tabIds: [1], color: "magenta" })).toThrow(); + }); + + it("UpdateGroupParams needs something to change", () => { + expect(UpdateGroupParams.parse({ groupId: 7, collapsed: true }).collapsed).toBe(true); + expect(() => UpdateGroupParams.parse({ groupId: 7 })).toThrow(/title, color, or collapsed/); + }); + + it("UngroupTabsParams needs exactly one of tabIds or groupId", () => { + expect(UngroupTabsParams.parse({ tabIds: [1] }).tabIds).toEqual([1]); + expect(UngroupTabsParams.parse({ groupId: 7 }).groupId).toBe(7); + expect(() => UngroupTabsParams.parse({})).toThrow(/exactly one/); + expect(() => UngroupTabsParams.parse({ tabIds: [1], groupId: 7 })).toThrow(/exactly one/); + }); +}); diff --git a/packages/protocol/src/cdp.ts b/packages/protocol/src/cdp.ts index b8af2d1..30a91db 100644 --- a/packages/protocol/src/cdp.ts +++ b/packages/protocol/src/cdp.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { TabGroupColor } from "./bridge.js"; /** Optional target tab; defaults (server/extension side) to the active tab. */ const tabId = z.number().optional(); @@ -91,6 +92,46 @@ export type CloseTabParams = z.infer; export const SelectTabParams = z.object({ browserId, tabId: z.number() }); export type SelectTabParams = z.infer; +/** Group properties the agent may set (all optional). */ +const groupProps = { + title: z.string().optional(), + color: TabGroupColor.optional(), + collapsed: z.boolean().optional(), +}; + +/** `group_tabs`: put tabs in a new group (no groupId) or an existing one. */ +export const GroupTabsParams = z.object({ + browserId, + tabIds: z.array(z.number()).min(1), + groupId: z.number().optional(), + ...groupProps, +}); +export type GroupTabsParams = z.infer; + +export const GroupTabsResult = z.object({ groupId: z.number() }); +export type GroupTabsResult = z.infer; + +/** `update_group`: retitle / recolor / collapse a group. */ +export const UpdateGroupParams = z + .object({ browserId, groupId: z.number(), ...groupProps }) + .refine((v) => v.title !== undefined || v.color !== undefined || v.collapsed !== undefined, { + message: "update_group needs a title, color, or collapsed", + }); +export type UpdateGroupParams = z.infer; + +/** `ungroup_tabs`: pull tabs out of their group, or dissolve a whole group. + * Never closes tabs. */ +export const UngroupTabsParams = z + .object({ + browserId, + tabIds: z.array(z.number()).min(1).optional(), + groupId: z.number().optional(), + }) + .refine((v) => (v.tabIds === undefined) !== (v.groupId === undefined), { + message: "ungroup_tabs needs exactly one of tabIds or groupId", + }); +export type UngroupTabsParams = z.infer; + export const ScreenshotParams = z.object({ browserId, tabId, diff --git a/packages/protocol/src/policy.test.ts b/packages/protocol/src/policy.test.ts index 19ae040..b690d60 100644 --- a/packages/protocol/src/policy.test.ts +++ b/packages/protocol/src/policy.test.ts @@ -84,10 +84,13 @@ describe("effectiveTier", () => { }); describe("METHOD_TIERS", () => { - it("classifies exactly the 24 bridge methods", () => { + it("classifies exactly the 27 bridge methods", () => { const read = [ "list_tabs", "list_groups", + "group_tabs", + "update_group", + "ungroup_tabs", "read_snapshot", "read_text", "screenshot", diff --git a/packages/protocol/src/policy.ts b/packages/protocol/src/policy.ts index 8a5e310..ff455d3 100644 --- a/packages/protocol/src/policy.ts +++ b/packages/protocol/src/policy.ts @@ -78,6 +78,10 @@ export function effectiveTier(policy: Policy, host: string | undefined): Tier { export const METHOD_TIERS = { list_tabs: "read", list_groups: "read", + // Tab-group ops touch the tab strip, never page content: readonly sites may be grouped, denied ones may not. + group_tabs: "read", + update_group: "read", + ungroup_tabs: "read", read_snapshot: "read", read_text: "read", screenshot: "read", diff --git a/packages/web/src/routes/docs/commands.tsx b/packages/web/src/routes/docs/commands.tsx index 097a513..24559f2 100644 --- a/packages/web/src/routes/docs/commands.tsx +++ b/packages/web/src/routes/docs/commands.tsx @@ -27,6 +27,14 @@ const GROUPS: Array<{ id: string; title: string; intro?: string; rows: [string, "reins groups [--browser ]", "List tab groups. reins tabs marks each grouped tab with g.", ], + [ + "reins group --tab [--tab …] [--group ] [--title ] [--color ] [--collapse|--expand]", + "Put tabs in a new group, or in an existing one with --group. With --group and no --tab, edit the group.", + ], + [ + "reins ungroup --tab [--tab …] | --group ", + "Take tabs out of their group, or dissolve a whole group. Tabs stay open.", + ], ["reins open [--background]", "Open a new tab."], ["reins close --tab ", "Close a tab."], ["reins focus --tab ", "Focus (activate) a tab."], diff --git a/skills/reins/SKILL.md b/skills/reins/SKILL.md index a6dae2f..4551889 100644 --- a/skills/reins/SKILL.md +++ b/skills/reins/SKILL.md @@ -89,6 +89,9 @@ reins status ``` tabs / open / close / focus / nav groups tab groups (id, title, color); `tabs` shows g per grouped tab +group --tab [--tab …] [--group ] [--title T] [--color blue] [--collapse|--expand] + no --tab + --group : edit that group +ungroup --tab [--tab …] | --group (tabs stay open) snapshot interactive elements + refs click --ref|--selector [--button right|middle] [--count 2] type --text "…" [--enter] keystrokes into an element @@ -110,9 +113,17 @@ cdp ['{json}'] raw Chrome DevTools Protocol ``` Page commands take `--tab ` (default: the active tab); `tabs` and -`groups` take no tab. Every command takes `--json` (raw result). +`groups` take no tab; `group` and `ungroup` take a repeatable `--tab ` +list (no default). Every command takes `--json` (raw result). `reins help ` shows exact usage. +**Tab groups.** You can put the tabs you open for a task into a group +(`reins group --tab 12 --tab 13 --title reins --color blue`) so the user sees +which tabs are yours. Grouping moves tabs into the group's window (a new group +stays in the first tab's window). Don't regroup the user's own tabs unless they +ask. A browser without the tab-group API answers with an error naming it, and +`reins groups` lists it as skipped. Dia supports groups (cyan shows as blue). + ## Recipes for the powerful stuff `eval` executes in the page's **main world / real origin**, so it sees the same