agentcollar 0.1.0 → 0.2.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 CHANGED
@@ -5,7 +5,8 @@ agents and your accounts. Agents get a short-lived, task-scoped *mandate* that y
5
5
  Telegram — never your password or tokens.
6
6
 
7
7
  > **Hobby project. Use at your own risk. Do not connect accounts you can't afford to lose.**
8
- > Status: early. Gmail is still **fake** (an in-memory inbox); real Gmail is next.
8
+ > **Status: paused.** It works end to end (Claude Code → Telegram approval → real Gmail drafts), but
9
+ > development is paused and may never resume. The code stays open (MIT): fork it if you need it.
9
10
 
10
11
  ## Install
11
12
 
package/dist/approval.js CHANGED
@@ -24,7 +24,7 @@ export function denyStalePending(maxAgeMs, now = Date.now()) {
24
24
  return denied;
25
25
  }
26
26
  // Text written by the agent (its name, the task) is shown to the human, so a bad agent
27
- // could try to fake lines like "Действия: gmail.read" with line breaks, or hide text with
27
+ // could try to fake lines like "Actions: gmail.read" with line breaks, or hide text with
28
28
  // invisible / right-to-left characters. We squash it into one short, plain line.
29
29
  export function oneLine(text, max) {
30
30
  const plain = text
@@ -39,9 +39,9 @@ export function oneLine(text, max) {
39
39
  export function describe(mandate) {
40
40
  const lines = [];
41
41
  if (mandate.allowedActions.some((action) => action.endsWith(".send"))) {
42
- lines.push("⚠️ Агент просит право ОТПРАВЛЯТЬ от твоего имени");
42
+ lines.push("⚠️ The agent asks for the right to SEND in your name");
43
43
  }
44
- lines.push(`Действия: ${mandate.allowedActions.join(", ")}`, `Срок: ${mandate.expiresInSeconds} с после одобрения, лимит ${mandate.limit}`, `id: ${mandate.id}`, `Агент: ${oneLine(mandate.agent, 64)}`, `Задача (слова агента): «${oneLine(mandate.task, 200)}»`);
44
+ lines.push(`Actions: ${mandate.allowedActions.join(", ")}`, `Lifetime: ${mandate.expiresInSeconds} s after approval, limit ${mandate.limit} actions`, `id: ${mandate.id}`, `Agent: ${oneLine(mandate.agent, 64)}`, `Task (agent's own words): “${oneLine(mandate.task, 200)}”`);
45
45
  return lines.join("\n");
46
46
  }
47
47
  // Terminal channel. Questions are asked one at a time: if two agents ask at once,
@@ -50,7 +50,7 @@ let queue = Promise.resolve();
50
50
  export function askInTerminal(mandate) {
51
51
  queue = queue.then(async () => {
52
52
  const rl = createInterface({ input: process.stdin, output: process.stdout });
53
- const answer = await rl.question(`\nНовый запрос мандата:\n${describe(mandate)}\nОдобрить? [y/N] `);
53
+ const answer = await rl.question(`\nNew mandate request:\n${describe(mandate)}\nApprove? [y/N] `);
54
54
  rl.close();
55
55
  console.log(applyTerminalAnswer(mandate, answer));
56
56
  });
@@ -61,7 +61,7 @@ export function askInTerminal(mandate) {
61
61
  export function applyTerminalAnswer(mandate, answer) {
62
62
  const decision = answer.trim().toLowerCase() === "y" ? "approve" : "deny";
63
63
  if (decide(mandate.id, decision, "terminal") === undefined) {
64
- return " ⌛ уже решено (время вышло или ответили в Telegram), ответ не применён";
64
+ return " ⌛ already decided (timed out or answered in Telegram), your answer was not applied";
65
65
  }
66
- return decision === "approve" ? " ✅ одобрен" : " ❌ отклонён";
66
+ return decision === "approve" ? " ✅ approved" : " ❌ denied";
67
67
  }
@@ -22,18 +22,65 @@ export function localTime(iso, showDate) {
22
22
  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
23
23
  return showDate ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}` : time;
24
24
  }
25
+ // The terminal width right now (it changes when you resize the window). COLUMNS: for pipes and tests.
26
+ export function termWidth() {
27
+ return process.stdout.columns || Number(process.env.COLUMNS) || 100;
28
+ }
29
+ // Cuts text to `max` visible characters, ending with "…" when something was cut.
30
+ export function fit(text, max) {
31
+ const chars = [...text];
32
+ if (max <= 0)
33
+ return "";
34
+ return chars.length <= max ? text : chars.slice(0, max - 1).join("") + "…";
35
+ }
36
+ const cell = (text, width) => fit(text, width).padEnd(width);
37
+ // Breaks text into lines of at most `width` characters, at spaces.
38
+ export function wrap(text, width) {
39
+ const lines = [];
40
+ let line = "";
41
+ for (const word of text.split(" ")) {
42
+ if (line !== "" && [...line].length + 1 + [...word].length > width) {
43
+ lines.push(line);
44
+ line = "";
45
+ }
46
+ line = line === "" ? fit(word, width) : `${line} ${word}`;
47
+ }
48
+ if (line !== "")
49
+ lines.push(line);
50
+ return lines;
51
+ }
25
52
  // Agent names, actions and reasons come from agents. Printed raw, a text like "\u001b[2J" would
26
53
  // be a COMMAND to the terminal (clear screen, change title...). oneLine() strips control characters.
54
+ // Layout follows the window width: wide = one roomy line, medium = one tight line, narrow = two lines.
27
55
  export function formatEntry(entry, options) {
28
- const time = styleText("dim", localTime(entry.time, options.showDate));
29
- const agent = oneLine(entry.agent, 24).padEnd(16);
30
- const action = oneLine(entry.action, 32);
31
- const reason = styleText("dim", oneLine(entry.reason, 120));
32
- if (entry.action.startsWith("mandate.")) {
33
- // a human decision (approve / deny / revoke) or the relay fallback: one yellow line
34
- return `${time} ${styleText("yellow", `${agent} ${action.padEnd(18)} ${oneLine(entry.reason, 120)}`)}`;
35
- }
56
+ const width = options.width ?? termWidth();
57
+ const iso = localTime(entry.time, options.showDate);
58
+ const time = options.showDate && width < 100 ? iso.slice(5, 16) : iso; // "10-04 15:42" when tight
59
+ const agent = oneLine(entry.agent, 64);
60
+ const action = oneLine(entry.action, 64);
61
+ const reason = oneLine(entry.reason, 300);
62
+ const human = entry.action.startsWith("mandate."); // approve / deny / revoke by a person
36
63
  const verdict = entry.allowed ? styleText("green", "ALLOWED") : styleText("red", "DENIED ");
37
- const marks = checkMarks(entry).padEnd(11);
38
- return `${time} ${agent} ${action.padEnd(18)} ${verdict} ${marks} ${reason}`;
64
+ const dimTime = styleText("dim", time);
65
+ if (width >= 70) {
66
+ const wide = width >= 100;
67
+ const [agentW, actionW] = wide ? [16, 18] : [12, 14];
68
+ const marks = wide ? checkMarks(entry) : checkMarks(entry).replaceAll(" ", "");
69
+ const marksW = wide ? 11 : 6;
70
+ if (human) {
71
+ const humanActionW = Math.max(actionW, 16); // "mandate.approve" fits
72
+ const rest = width - time.length - agentW - humanActionW - 6;
73
+ return `${dimTime} ${styleText("yellow", `${cell(agent, agentW)} ${cell(action, humanActionW)} ${fit(reason, rest)}`)}`;
74
+ }
75
+ const rest = width - time.length - agentW - actionW - 7 - marksW - 10;
76
+ return `${dimTime} ${cell(agent, agentW)} ${cell(action, actionW)} ${verdict} ${marks.padEnd(marksW)} ${styleText("dim", fit(reason, rest))}`;
77
+ }
78
+ // narrow window: two short lines
79
+ if (human) {
80
+ return `${dimTime} ${styleText("yellow", fit(action, width - time.length - 2))}\n ${styleText("yellow", fit(`${agent} · ${reason}`, width - 2))}`;
81
+ }
82
+ const first = `${dimTime} ${verdict} ${fit(action, width - time.length - 11)}`;
83
+ const marks = checkMarks(entry).replaceAll(" ", "");
84
+ const second = ` ${marks} ${styleText("dim", fit(`${agent} · ${reason}`, width - marks.length - 4))}`;
85
+ return `${first}\n${second}`;
39
86
  }
@@ -0,0 +1,87 @@
1
+ // A guided walk through Google Cloud for `agcl gmail connect`, for people who have no OAuth client yet.
2
+ // Each step opens the right console page in the browser and says in one line what to click.
3
+ // (Google moves its menus around now and then, so every step also names the page.)
4
+ import { execFile } from "node:child_process";
5
+ import { existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { styleText } from "node:util";
9
+ import { SCOPES } from "../google/oauth.js";
10
+ export const GUIDE_STEPS = [
11
+ {
12
+ title: "Create a project",
13
+ url: "https://console.cloud.google.com/projectcreate",
14
+ todo: 'Project name: AgentCollar → Create. Then make sure "AgentCollar" is selected in the top bar.',
15
+ },
16
+ {
17
+ title: "Turn on the Gmail API",
18
+ url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com",
19
+ todo: "Click Enable.",
20
+ },
21
+ {
22
+ title: "Consent screen (Google Auth Platform → Overview)",
23
+ url: "https://console.cloud.google.com/auth/overview",
24
+ todo: "Get started → App name: AgentCollar (local), your email → Audience: External → contact email → Create.",
25
+ },
26
+ {
27
+ title: "Add yourself as a test user (Audience)",
28
+ url: "https://console.cloud.google.com/auth/audience",
29
+ todo: "Test users → Add users → your Gmail address → Save. The app stays in Testing: no Google review needed.",
30
+ },
31
+ {
32
+ title: "Permissions (Data Access)",
33
+ url: "https://console.cloud.google.com/auth/scopes",
34
+ todo: "Add or remove scopes → paste these two into “Manually add scopes” → Add to table → Update → Save:",
35
+ copy: SCOPES,
36
+ },
37
+ {
38
+ title: "Create the client (Clients)",
39
+ url: "https://console.cloud.google.com/auth/clients/create",
40
+ todo: "Application type: Desktop app → Name: agcl → Create → Download JSON.",
41
+ },
42
+ ];
43
+ // Waits for a client_secret*.json that appears in `folder` after `since` (an older one is ignored).
44
+ export async function waitForClientFile(folder, since, timeoutMs, pollMs = 1000) {
45
+ const deadline = Date.now() + timeoutMs;
46
+ while (Date.now() < deadline) {
47
+ if (existsSync(folder)) {
48
+ const fresh = readdirSync(folder)
49
+ .filter((name) => name.startsWith("client_secret") && name.endsWith(".json"))
50
+ .map((name) => join(folder, name))
51
+ .filter((file) => statSync(file).mtimeMs >= since - 1000);
52
+ if (fresh[0] !== undefined)
53
+ return fresh[0];
54
+ }
55
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
56
+ }
57
+ return null;
58
+ }
59
+ // Runs the guide in the terminal. Returns the downloaded client file, or null if the user stopped.
60
+ export async function runGuide(downloads) {
61
+ const started = Date.now();
62
+ console.log(styleText("bold", "\nGoogle Cloud setup — 6 steps, about 3 minutes."));
63
+ console.log("Each step opens a page in your browser. Do what it says, then press Enter here.\n");
64
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
65
+ try {
66
+ for (const [i, step] of GUIDE_STEPS.entries()) {
67
+ console.log(`${styleText("cyan", `[${i + 1}/${GUIDE_STEPS.length}]`)} ${styleText("bold", step.title)}`);
68
+ console.log(` ${step.todo}`);
69
+ for (const line of step.copy ?? [])
70
+ console.log(` ${styleText("yellow", line)}`);
71
+ console.log(styleText("dim", ` ${step.url}`));
72
+ execFile("open", [step.url], () => { });
73
+ const answer = await rl.question(styleText("dim", " Enter = done · q = stop "));
74
+ if (answer.trim().toLowerCase() === "q")
75
+ return null;
76
+ console.log("");
77
+ }
78
+ }
79
+ finally {
80
+ rl.close();
81
+ }
82
+ console.log("Waiting for client_secret_….json in your Downloads folder…");
83
+ const file = await waitForClientFile(downloads, started, 10 * 60 * 1000);
84
+ if (file === null)
85
+ console.log("No file in 10 minutes. Run agcl gmail connect again when you have it.");
86
+ return file;
87
+ }
@@ -0,0 +1,125 @@
1
+ // agcl gmail connect | status | disconnect — connect YOUR Gmail (read + drafts).
2
+ // The refresh token goes to the macOS Keychain; the access token only ever lives in memory.
3
+ import { execFile } from "node:child_process";
4
+ import { copyFileSync, chmodSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { styleText } from "node:util";
9
+ import { forgetConnection, readGmailInfo, REFRESH_TOKEN_ACCOUNT, saveConnection } from "../google/connection.js";
10
+ import { keychain } from "../google/keychain.js";
11
+ import { authorizeInBrowser, exchangeCode, parseClientJson, refreshAccessToken, revokeToken } from "../google/oauth.js";
12
+ import { ensureHome, googleClientFile } from "../paths.js";
13
+ async function ask(question) {
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ const answer = await rl.question(question);
16
+ rl.close();
17
+ return answer.trim().toLowerCase();
18
+ }
19
+ // The newest client_secret_*.json in ~/Downloads, where Google Cloud puts it.
20
+ function findDownloadedClient() {
21
+ const downloads = join(homedir(), "Downloads");
22
+ if (!existsSync(downloads))
23
+ return null;
24
+ const files = readdirSync(downloads)
25
+ .filter((name) => name.startsWith("client_secret") && name.endsWith(".json"))
26
+ .map((name) => join(downloads, name))
27
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
28
+ return files[0] ?? null;
29
+ }
30
+ function loadClient() {
31
+ if (!existsSync(googleClientFile))
32
+ throw new Error("No Google client yet. Run: agcl gmail connect");
33
+ return parseClientJson(readFileSync(googleClientFile, "utf8"));
34
+ }
35
+ async function connect(args) {
36
+ // 1. The Desktop-app client file from Google Cloud
37
+ let file = args[0] ?? null;
38
+ if (file === null) {
39
+ const found = findDownloadedClient();
40
+ if (found !== null && (await ask(`Use ${found}? [Y/n] `)) !== "n")
41
+ file = found;
42
+ }
43
+ if (file === null && existsSync(googleClientFile))
44
+ file = googleClientFile;
45
+ if (file === null) {
46
+ // No OAuth client yet: walk the person through Google Cloud, page by page.
47
+ if (!process.stdin.isTTY) {
48
+ console.error("No Google client yet. Run agcl gmail connect in a terminal for the guided setup,");
49
+ console.error("or pass the file: agcl gmail connect ~/Downloads/client_secret_….json");
50
+ return 1;
51
+ }
52
+ if ((await ask("No Google client found. Set one up now, step by step? [Y/n] ")) === "n")
53
+ return 1;
54
+ file = await (await import("./gmail-guide.js")).runGuide(join(homedir(), "Downloads"));
55
+ if (file === null)
56
+ return 1;
57
+ console.log(`${styleText("green", "✓")} Found ${file}\n`);
58
+ }
59
+ const client = parseClientJson(readFileSync(file, "utf8")); // fails early if it is the wrong file
60
+ ensureHome();
61
+ if (file !== googleClientFile) {
62
+ copyFileSync(file, googleClientFile);
63
+ chmodSync(googleClientFile, 0o600);
64
+ }
65
+ // 2. Sign in with Google in the browser
66
+ console.log("Opening Google in your browser. Allow: read your email + create drafts.");
67
+ const { code, verifier, redirectUri } = await authorizeInBrowser(client, (url) => {
68
+ console.log(styleText("dim", `If the browser did not open: ${url}`));
69
+ execFile("open", [url], () => { });
70
+ });
71
+ const tokens = await exchangeCode(client, code, verifier, redirectUri);
72
+ // 3. Which Gmail is this?
73
+ const profile = (await (await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { headers: { authorization: `Bearer ${tokens.accessToken}` } })).json());
74
+ const email = profile.emailAddress ?? "unknown";
75
+ saveConnection({ email, connectedAt: new Date().toISOString() }, tokens.refreshToken);
76
+ console.log(`\n${styleText("green", "✓")} Gmail connected: ${styleText("bold", email)}`);
77
+ console.log(" Read + create drafts. This connection cannot send at all: Google itself blocks it.");
78
+ console.log(" Refresh token: macOS Keychain. Access token: memory only.");
79
+ console.log(styleText("yellow", " Testing mode: Google ends this sign-in after 7 days. Then run agcl gmail connect again."));
80
+ if (file !== googleClientFile)
81
+ console.log(styleText("dim", ` You can delete ${file} now (a copy is in ${googleClientFile}).`));
82
+ console.log(" Restart the broker to use it: agcl server");
83
+ return 0;
84
+ }
85
+ async function status() {
86
+ const info = readGmailInfo();
87
+ const refreshToken = keychain.get(REFRESH_TOKEN_ACCOUNT);
88
+ if (info === null || refreshToken === null) {
89
+ console.log("Gmail: not connected (the broker uses a fake inbox). Connect: agcl gmail connect");
90
+ return 0;
91
+ }
92
+ try {
93
+ await refreshAccessToken(loadClient(), refreshToken);
94
+ console.log(`${styleText("green", "✓")} Gmail: ${info.email}, connected ${info.connectedAt.slice(0, 10)}, access works.`);
95
+ return 0;
96
+ }
97
+ catch (error) {
98
+ console.log(`${styleText("red", "✗")} Gmail: ${info.email}: ${error.message}`);
99
+ return 1;
100
+ }
101
+ }
102
+ async function disconnect() {
103
+ const token = forgetConnection();
104
+ if (token !== null)
105
+ await revokeToken(token); // also tell Google to cancel the access
106
+ console.log("Gmail disconnected: the Keychain entry is deleted and Google access is revoked.");
107
+ return 0;
108
+ }
109
+ export async function runGmail(args) {
110
+ const [sub, ...rest] = args;
111
+ try {
112
+ if (sub === "connect")
113
+ return await connect(rest);
114
+ if (sub === "status")
115
+ return await status();
116
+ if (sub === "disconnect")
117
+ return await disconnect();
118
+ }
119
+ catch (error) {
120
+ console.error(`${styleText("red", "✗")} ${error.message}`);
121
+ return 1;
122
+ }
123
+ console.error("Usage: agcl gmail connect [client_secret.json] | agcl gmail status | agcl gmail disconnect");
124
+ return 1;
125
+ }
@@ -15,6 +15,21 @@ export const SPIN = [
15
15
  ["", " ⢀⣠⣤⣴⣶⣖⣲⣦⣤⣀", " ⣠⣾⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣿⣦⣄", " ⢠⣾⣿⣿⣿⣿⣿⠃⢠⣤⣿⣿⣿⡿⢿⣿⣿⣆", " ⣾⣿⣿⣿⣿⣿⣿⣇ ⠙⢿⣿⣿⡆ ⣿⣿⣽⡄", " ⢸⣿⣿⣿⡿⠿⢿⣿⣿⣷⣄⣀⠈⠉ ⣠⣿⣿⣿⡇", " ⢸⣼⣿⣿⣇ ⢀⣀⡀⠈⠉⠙⠛⠻⣿⣿⣿⣿⣿⡇", " ⠈⣿⣿⣿⣿⣧⡀⠙⠃⢀⣶⣶⣶⣶⣿⣿⣿⣿⣿⠁", " ⠘⢿⣏⢿⣿⣿⣆ ⠘⢿⣿⣿⣿⣿⣿⢟⡿⠁", " ⠻⢷⣿⣿⣿⣷⣄⢀⣿⣿⣿⣿⣷⠟⠁", " ⠉⠛⠻⠿⠿⠿⠿⠿⠛⠉", ""],
16
16
  ["", " ⣀⣠⣴⣶⣶⣶⣶⣤⣄⡀", " ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢀⢾⣿⣿⣿⣿⣿⣿⣿⡟⠁⣀⢉⣿⣿⣿⣷⡄", " ⢀⣿⣾⣿⣿⠛⢿⣿⣿⣿⡆ ⣿⣿⣿⣿⣿⣿⣿⡄", " ⢸⣿⣿⣿⣧ ⣄⠈⠻⣿⣇ ⠹⣿⣿⡏⠉⣿⣿⣿", " ⢸⣿⢻⣿⣿ ⢸⠷ ⠙⢷⣀⠈⠉⢀⣼⣿⡟⡿", " ⣿⣼⣿⣿⡇ ⢠⣴⣷⣄ ⢻⣿⣿⣿⣿⣿⣿⠃", " ⠘⢿⣿⣿⣷ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃", " ⠈⠻⣿⣿⣤⣼⣿⣿⣿⣿⣿⢿⣿⡿⠛⠁", " ⠉⠛⠿⠿⠿⠿⠷⠞⠛⠁", ""],
17
17
  ];
18
+ // The same, 32×32 dots (16 columns × 8 rows), for narrow terminal windows.
19
+ export const SPIN_SMALL = [
20
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀", " ⣴⣿⣿⡟⠻⣿⣿⡿⠟⢿⣿⣆", " ⢠⣿⣿⡟⢠⡆⢹⡿ ⣶⣶⣿⣿⡄", " ⠘⣿⣿⠁⣠⣤ ⢧ ⠿⠟⣿⣿⡇", " ⠹⣧⣴⣿⣿⣧⣼⣷⣤⣶⣿⡟", " ⠙⠿⢿⣿⣿⣿⣿⣿⠟⠋", " ⠉⠉"],
21
+ [" ⣀⣀", " ⢀⡴⣾⣿⣷⣿⣿⣷⣦⡀", " ⣰⣿⣿⡿⠋⠉⣿⣿⣿⣿⣿⣆", " ⢰⣿⠟⠉⡀⠛ ⣿⠟⠛⠛⢿⣿⡀", " ⠸⣿⣶⣾⣿⠇⢸⠃⣰⣿⣷⣾⣿⠅", " ⠻⣿⣿⣿⣶⣿⡀⠙⢛⣿⣿⡏", " ⠉⠻⢿⣿⣿⣿⣿⣿⠿⠋", " ⠉⠉⠁"],
22
+ [" ⣀⣀", " ⣀⣴⣿⣿⣽⣿⣿⣷⣦⡀", " ⣴⡿⠟⠛⠛⣉⡉⢙⣿⣿⣿⣆", " ⢰⣿⣷⣶⣷⡄⠉⣰⣿⣿⣿⣿⣿⡀", " ⠸⣿⣿⣿⡋⣠⠞⢉⣀⣈⠙⣿⣿⠁", " ⠹⣿⣿⣿⣇⠐⠿⣿⣷⣾⣿⠏", " ⠈⠻⣿⣿⣷⣶⣿⣿⠟⠉", " ⠈⠉⠉⠉"],
23
+ [" ⣀⣀", " ⣠⣴⠾⣿⣿⣿⣿⣿⣦⡀", " ⣸⣿⣿⣦⣄⠈⣉⠛⠿⣿⣿⣆", " ⢠⣿⣿⣿⠿⠛ ⣉⣡⣴⣿⣿⣿⡀", " ⠘⣿⣿⣿⡶⠚⠉⠙⠛⢿⣿⣿⣿⠁", " ⠻⣿⣿⡄⠸⣿⣿⡇⣨⣿⣿⠏", " ⠙⠿⣿⣿⣿⣿⣿⡿⠟⠁", " ⠉⠉⠉"],
24
+ [" ⢀⣀⣀⡀", " ⢠⣴⣿⣿⡟⠻⣿⣷⣦⡀", " ⣰⣿⣿⣿⣿⣷⠄⠘⢿⣿⣽⣆", " ⢠⣿⣿⣿⣧⣈⣉ ⠛ ⢹⣿⡿⡄", " ⠸⣿⣿⠁⣠⣄⡉⠻⣿⣿⣿⣿⣿⠃", " ⢻⣿⣦⣿⣿⡿ ⣿⣿⣿⣿⠏", " ⠙⠿⢿⣿⣷⣾⣿⡿⠟⠁", " ⠈⠈⠁"],
25
+ [" ⢀⣀⣀⡀", " ⢀⣴⣾⣿⣿⣿⡿⢿⣦⡄", " ⣴⣿⣿⣿⠟⢿⣿⡧⠈⣿⣿⣦", " ⢰⣿⡿⠋⡉⠳⣄⠉⢠⡄⢻⣿⣻⡄", " ⢸⣿⣧⣼⣿⡆⠘⣷⣦⡁⣸⣿⣿⠃", " ⠻⣿⣿⣏⠃⣰⣿⣿⣿⣿⣿⠏", " ⠘⠻⢿⣿⣿⣿⣿⡿⠟⠁", " ⠈⠁"],
26
+ [" ⣀⣀", " ⣠⣴⣿⣿⣿⣿⣿⣷⣶⣄", " ⣼⣿⠿⠛⢿⡟⢻⣿⣿⠟⢻⣆", " ⢸⣿⣿⣴⣶ ⢳ ⠛⠋⢀⣿⣿⡄", " ⠘⣿⣿⠿⠿ ⣾⣇⠸⠃⣼⣿⣿⠃", " ⠹⣿⣷⣴⣾⣿⣿⣦⣼⣿⣿⠟", " ⠈⠻⢿⣿⣿⣿⣿⡿⠟⠁", " ⠈⠁"],
27
+ [" ⢀⣀⣀", " ⣠⣶⣿⣿⣿⣿⣿⣷⣦⣀", " ⣸⣿⣿⣥⣄⠈⣿⠿⣿⣿⣿⣦", " ⢐⣿⡿⢿⣿⠏⢠⡇⢰⣿⡿⠿⣿⡆", " ⠈⣿⣷⣤⣤⣴⣿ ⣤⠈⣀⣴⣿⠇", " ⠹⣿⣿⣿⣿⣿⣀⣠⣾⣿⣿⠏", " ⠈⠻⢿⣿⣿⢿⣿⡿⠞⠁", " ⠉⠉"],
28
+ [" ⣀⣀⣀⡀", " ⣀⣴⣿⣿⠿⢿⣿⣿⣦⡀", " ⣰⣿⡿⢿⣿⣶⠄⢹⣿⣿⣿⣆", " ⢀⣿⣿⣄⡉⠉⣁⡴⠋⣨⣿⣿⣿⡆", " ⠈⣿⣿⣿⣿⣿⠏⣀⠘⢿⠿⢿⣿⠇", " ⠹⣿⣿⣿⣅⣈⣉⣤⣤⣴⣾⠟", " ⠈⠻⢿⣿⣿⣟⣿⣿⠟⠉", " ⠉⠉"],
29
+ [" ⣀⣀⣀", " ⢀⣴⣾⣿⣿⣿⣿⣿⣶⣄", " ⣰⣿⣿⡋⢸⣿⣿⡆⠘⣿⣿⣦", " ⢀⣿⣿⣿⣷⣤⣄⣀⡤⠾⣿⣿⣿⡄", " ⠈⣿⣿⣿⠟⢋⣉ ⣤⣶⣿⣿⣿⠃", " ⠹⣿⣿⣶⣤⣉⡀⠙⠻⣿⣿⡏", " ⠈⠻⣿⣿⣿⣿⣿⡶⠟⠋", " ⠉⠉"],
30
+ [" ⢀⡀⡀", " ⢀⣴⣾⣿⡿⢿⣿⣷⣶⣄", " ⣰⣿⣿⣿⣿ ⣾⣿⣿⠻⣿⣧", " ⢠⣿⣿⣿⣿⣿⣦⣈⠙⠋⢀⣿⣿⡆", " ⠘⣾⣿⣇ ⣤ ⣉⡉⢻⣿⣿⣿⠃", " ⠹⣟⣿⣷⡄⠐⢿⣿⣿⣿⣿⠏", " ⠈⠻⢿⣿⣦⣼⣿⣿⠟⠃", " ⠈⠉⠉⠁"],
31
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡄", " ⣰⣿⣿⣿⣿⣿⠏⢠⣹⣿⣿⣦", " ⢠⣿⣿⡏⢈⠻⢿⡄⠸⣿⡟⢻⣿⡇", " ⠘⣯⣿⣧⠘⠃⣀⠙⢦⣈⣠⣾⣿⠇", " ⠻⣿⣿⡀⢺⣿⣷⣴⣿⣿⣿⠟", " ⠘⠻⣷⣾⣿⣿⣿⡿⠟⠁", " ⠈⠉⠉⠁"],
32
+ ];
18
33
  // Upright and smaller (32, 20, 12 dots): the medallion "flies" into the header.
19
34
  export const SHRINK = [
20
35
  [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀", " ⣴⣿⣿⡟⠻⣿⣿⡿⠟⢿⣿⣆", " ⢠⣿⣿⡟⢠⡆⢹⡿ ⣶⣶⣿⣿⡄", " ⠘⣿⣿⠁⣠⣤ ⢧ ⠿⠟⣿⣿⡇", " ⠹⣧⣴⣿⣿⣧⣼⣷⣤⣶⣿⡟", " ⠙⠿⢿⣿⣿⣿⣿⣿⠟⠋", " ⠉⠉"],
package/dist/cli/intro.js CHANGED
@@ -2,24 +2,34 @@
2
2
  // and un-types, then the medallion shrinks into the header. About 1.7 s; any key skips it.
3
3
  // The frames are plain text made once from the logo (see tools/gen-intro.ts).
4
4
  import { styleText } from "node:util";
5
- import { ICON, SHRINK, SPIN } from "./intro-frames.js";
5
+ import { ICON, SHRINK, SPIN, SPIN_SMALL } from "./intro-frames.js";
6
6
  const WORD = "AgentCollar";
7
7
  const WELCOME = "Welcome. Let agents work. Keep the keys.";
8
- const HEIGHT = SPIN[0].length; // every frame has this many lines, so each one overwrites the last
9
- const WORD_ROW = Math.floor(HEIGHT / 2) - 1;
10
- const WORD_COLUMN = 28;
11
- function frame(medallion, word = "", delayMs = 0) {
12
- const lines = Array.from({ length: HEIGHT }, (_, row) => {
13
- const left = medallion[row] ?? "";
14
- return row === WORD_ROW && word !== "" ? left.padEnd(WORD_COLUMN) + word : left;
15
- });
16
- return { lines, delayMs };
8
+ const WELCOME_SHORT = "Let agents work. Keep the keys.";
9
+ // The big medallion needs 60×16, the small one 44×12; a smaller window gets no intro at all.
10
+ export function introSize(columns, rows) {
11
+ if (columns >= 60 && rows >= SPIN[0].length + 4)
12
+ return "big";
13
+ if (columns >= 44 && rows >= SPIN_SMALL[0].length + 4)
14
+ return "small";
15
+ return "none";
17
16
  }
18
17
  // The whole animation as data: easy to test (duration, sizes) and to play.
19
- export function introTimeline() {
20
- const upright = SPIN[0];
18
+ export function introTimeline(size = "big", width = 100) {
19
+ const spin = size === "big" ? SPIN : SPIN_SMALL;
20
+ const height = spin[0].length; // every frame has this many lines, so each one overwrites the last
21
+ const wordRow = Math.floor(height / 2) - 1;
22
+ const wordColumn = Math.max(...spin[0].map((line) => [...line].length)) + 3;
23
+ const frame = (medallion, word = "", delayMs = 0) => ({
24
+ lines: Array.from({ length: height }, (_, row) => {
25
+ const left = medallion[row] ?? "";
26
+ return row === wordRow && word !== "" ? left.padEnd(wordColumn) + word : left;
27
+ }),
28
+ delayMs,
29
+ });
30
+ const upright = spin[0];
21
31
  const frames = [];
22
- for (const turned of SPIN)
32
+ for (const turned of spin)
23
33
  frames.push(frame(turned, "", 40)); // one full turn
24
34
  frames.push(frame(upright, "", 40)); // settles upright
25
35
  for (let i = 1; i <= WORD.length; i++)
@@ -27,9 +37,11 @@ export function introTimeline() {
27
37
  frames[frames.length - 1].delayMs = 250; // holds the full name
28
38
  for (let i = WORD.length - 1; i >= 0; i--)
29
39
  frames.push(frame(upright, WORD.slice(0, i), 22)); // un-types
30
- for (const smaller of SHRINK)
40
+ const shrink = size === "big" ? SHRINK : SHRINK.slice(1); // the small intro starts at 32 dots already
41
+ for (const smaller of shrink)
31
42
  frames.push(frame(smaller, "", 60)); // flies into the header
32
- frames.push(frame([`${ICON[0]} ${styleText("bold", WORD)}`, `${ICON[1]} ${WELCOME}`]));
43
+ const welcome = width >= WELCOME.length + 6 ? WELCOME : WELCOME_SHORT;
44
+ frames.push(frame([`${ICON[0]} ${styleText("bold", WORD)}`, `${ICON[1]} ${welcome}`]));
33
45
  return frames;
34
46
  }
35
47
  // Only for a person looking at a real terminal: never in pipes, logs, CI, or when asked not to.
@@ -40,7 +52,7 @@ export function shouldShowIntro(c) {
40
52
  return false;
41
53
  if (c.env.NO_COLOR !== undefined && c.env.NO_COLOR !== "")
42
54
  return false; // no-color.org rule
43
- return c.columns >= 72 && c.rows >= HEIGHT + 4;
55
+ return introSize(c.columns, c.rows) !== "none";
44
56
  }
45
57
  // ANSI escape codes used below:
46
58
  // ESC[?25l / ESC[?25h hide / show the cursor
@@ -50,7 +62,11 @@ export function shouldShowIntro(c) {
50
62
  // ESC[<n>A move the cursor n lines up
51
63
  const ESC = "\u001b";
52
64
  export async function playIntro(out = process.stdout, input = process.stdin) {
53
- const frames = introTimeline();
65
+ const size = introSize(out.columns ?? 100, out.rows ?? 40);
66
+ if (size === "none")
67
+ return;
68
+ const frames = introTimeline(size, out.columns ?? 100);
69
+ const HEIGHT = frames[0].lines.length;
54
70
  let skipped = false;
55
71
  let ctrlC = false;
56
72
  let wake = () => { };
package/dist/cli/logs.js CHANGED
@@ -13,11 +13,11 @@ export function parseLogsArgs(args) {
13
13
  else if (arg === "--agent") {
14
14
  const name = args[++i];
15
15
  if (name === undefined || name.startsWith("--"))
16
- throw new Error("После --agent нужно имя: --agent <имя>");
16
+ throw new Error("--agent needs a name: --agent <name>");
17
17
  filter.agent = name;
18
18
  }
19
19
  else
20
- throw new Error(`Неизвестный флаг: ${arg}. Есть: --agent <имя>, --denied, --today`);
20
+ throw new Error(`Unknown flag: ${arg}. Available: --agent <name>, --denied, --today`);
21
21
  }
22
22
  return filter;
23
23
  }
@@ -37,7 +37,7 @@ export async function runLogs(args) {
37
37
  }
38
38
  const entries = filterEntries(readAuditLog(auditLogFile), filter);
39
39
  if (entries.length === 0) {
40
- console.log("Записей нет.");
40
+ console.log("No entries.");
41
41
  return 0;
42
42
  }
43
43
  for (const entry of entries)
package/dist/cli/main.js CHANGED
@@ -2,17 +2,23 @@
2
2
  // both names point to one file, so `agcl watch` == `agentcollar watch`.
3
3
  // Each command lives in its own file and is loaded only when it is used.
4
4
  import { styleText } from "node:util";
5
+ import { fit, termWidth, wrap } from "./format.js";
5
6
  // Runs forever (until Ctrl+C): the server and the MCP server keep the process alive themselves.
6
7
  const forever = () => new Promise(() => { });
7
8
  const COMMANDS = {
8
9
  setup: {
9
10
  usage: "agcl setup",
10
- summary: "подключить своего Telegram-бота (мастер настройки)",
11
+ summary: "connect your own Telegram bot (setup wizard)",
11
12
  run: async () => (await import("./setup.js")).runSetup(),
12
13
  },
14
+ gmail: {
15
+ usage: "agcl gmail connect|status|disconnect",
16
+ summary: "connect your real Gmail (read + create drafts; cannot send)",
17
+ run: async (args) => (await import("./gmail.js")).runGmail(args),
18
+ },
13
19
  server: {
14
20
  usage: "agcl server",
15
- summary: "запустить брокер на 127.0.0.1",
21
+ summary: "start the broker on 127.0.0.1",
16
22
  run: async () => {
17
23
  await import("../server.js");
18
24
  return forever();
@@ -20,7 +26,7 @@ const COMMANDS = {
20
26
  },
21
27
  watch: {
22
28
  usage: "agcl watch",
23
- summary: "живой экран: запросы агентов в реальном времени и 6 проверок",
29
+ summary: "live screen: agent requests as they happen, with the 6 checks",
24
30
  run: async (args, flags) => {
25
31
  await (await import("./intro.js")).maybeIntro(flags.noIntro);
26
32
  return (await import("./watch.js")).runWatch(args);
@@ -28,22 +34,22 @@ const COMMANDS = {
28
34
  },
29
35
  mandates: {
30
36
  usage: "agcl mandates",
31
- summary: "мандаты запущенного брокера: статус, сколько осталось времени и действий",
37
+ summary: "mandates of the running broker: state, time and actions left",
32
38
  run: async (args) => (await import("./mandates.js")).runMandates(args),
33
39
  },
34
40
  logs: {
35
- usage: "agcl logs [--agent <имя>] [--denied] [--today]",
36
- summary: "аудит-лог: кто, что, когда, разрешено или нет",
41
+ usage: "agcl logs [--agent <name>] [--denied] [--today]",
42
+ summary: "the audit log: who, what, when, allowed or not",
37
43
  run: async (args) => (await import("./logs.js")).runLogs(args),
38
44
  },
39
45
  revoke: {
40
46
  usage: "agcl revoke <id>",
41
- summary: "мгновенно отозвать мандат (kill switch)",
47
+ summary: "revoke a mandate instantly (kill switch)",
42
48
  run: async (args) => (await import("./revoke.js")).runRevoke(args),
43
49
  },
44
50
  mcp: {
45
51
  usage: "agcl mcp",
46
- summary: "MCP-сервер для Claude Code и других агентов (stdio)",
52
+ summary: "MCP server for Claude Code and other agents (stdio)",
47
53
  run: async () => {
48
54
  await import("../mcp/server.js");
49
55
  return forever();
@@ -62,19 +68,23 @@ export function parseCommand(argv) {
62
68
  return { kind: "run", name, args, flags };
63
69
  }
64
70
  function helpText() {
71
+ const screen = termWidth();
65
72
  const width = Math.max(...Object.values(COMMANDS).map((c) => c.usage.length)) + 3;
66
- const row = (left, right) => ` ${styleText("cyan", left.padEnd(width))}${right}`;
73
+ // wide window: "usage summary" on one line; narrow: the summary goes under the usage
74
+ const row = (left, right) => screen >= width + 40
75
+ ? ` ${styleText("cyan", left.padEnd(width))}${fit(right, screen - width - 2)}`
76
+ : ` ${styleText("cyan", fit(left, screen - 2))}\n${wrap(right, screen - 6).map((line) => ` ${line}`).join("\n")}`;
67
77
  return [
68
- `${styleText("bold", "AgentCollar")} — let agents work, keep the keys.`,
78
+ fit(`${styleText("bold", "AgentCollar")} — let agents work, keep the keys.`, screen + 20),
69
79
  "",
70
- row("agcl", "заставка → мастер настройки (если ещё не настроен) → брокер"),
80
+ row("agcl", "intro → setup wizard (first time only) → broker"),
71
81
  ...Object.values(COMMANDS).map((c) => row(c.usage, c.summary)),
72
82
  "",
73
- row("--no-intro", "без заставки"),
74
- row("-h, --help", "эта справка"),
83
+ row("--no-intro", "skip the intro"),
84
+ row("-h, --help", "this help"),
75
85
  "",
76
- styleText("dim", "agcl — короткое имя agentcollar: agcl watch == agentcollar watch"),
77
- styleText("dim", "Данные: ~/.agentcollar/ (папка доступна только тебе)"),
86
+ styleText("dim", fit("agcl is the short name of agentcollar: agcl watch == agentcollar watch", screen)),
87
+ styleText("dim", fit("Your data: ~/.agentcollar/ (only you can open it)", screen)),
78
88
  ].join("\n");
79
89
  }
80
90
  export async function runCli(argv) {
@@ -86,7 +96,7 @@ export async function runCli(argv) {
86
96
  console.log(helpText());
87
97
  return 0;
88
98
  case "unknown":
89
- console.error(`${styleText("red", `Неизвестная команда: ${parsed.name}`)}\n\n${helpText()}`);
99
+ console.error(`${styleText("red", `Unknown command: ${parsed.name}`)}\n\n${helpText()}`);
90
100
  return 1;
91
101
  case "run":
92
102
  return COMMANDS[parsed.name].run(parsed.args, parsed.flags);