Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tab-groups-write.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cli/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function helpText(version: string, tools: Record<string, ToolCommand>): s
"Usage: reins <command> [flags]",
"",
"Tabs & pages:",
...["tabs", "groups", "open", "close", "focus", "nav"].map(tool),
...["tabs", "groups", "group", "ungroup", "open", "close", "focus", "nav"].map(tool),
"",
"Interaction:",
...[
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
97 changes: 89 additions & 8 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { groupsText, tabsText } from "./cli-commands.js";
/** One `reins <name>` 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, unknown>): string;
usage: string;
summary: string;
booleans?: string[];
Expand Down Expand Up @@ -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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown> {
const browser = flagStr(a, "browser");
return browser !== undefined ? { browserId: browser } : {};
}

/** Shared routing/targeting flags (--browser, --tab). */
function base(a: ParsedArgs): Record<string, unknown> {
const out: Record<string, unknown> = {};
Expand Down Expand Up @@ -86,25 +125,67 @@ export const TOOL_COMMANDS: Record<string, ToolCommand> = {
method: "list_tabs",
usage: "reins tabs [--browser <id>]",
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 <id>]",
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 <id> [--tab <id> …] [--group <gid>] [--title <t>] [--color <c>] [--collapse|--expand]\n reins group --group <gid> [--title <t>] [--color <c>] [--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 <id> [--tab <id> …] | --group <gid>",
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 <url> [--background]",
Expand Down
101 changes: 100 additions & 1 deletion packages/extension/src/lib/dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand Down Expand Up @@ -394,3 +394,102 @@ describe("dispatchWithMeta", () => {
expect(result).not.toHaveProperty("meta");
});
});

describe("tab group gate", () => {
const URLS: Record<number, string> = {
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();
});
});
Loading
Loading