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 +2 -1
- package/dist/approval.js +6 -6
- package/dist/cli/format.js +57 -10
- package/dist/cli/gmail-guide.js +87 -0
- package/dist/cli/gmail.js +125 -0
- package/dist/cli/intro-frames.js +15 -0
- package/dist/cli/intro.js +33 -17
- package/dist/cli/logs.js +3 -3
- package/dist/cli/main.js +26 -16
- package/dist/cli/mandates.js +47 -22
- package/dist/cli/setup.js +17 -17
- package/dist/cli/start.js +1 -1
- package/dist/cli/watch.js +9 -6
- package/dist/env.js +1 -1
- package/dist/gmail-fake.js +24 -25
- package/dist/google/connection.js +36 -0
- package/dist/google/gmail.js +73 -0
- package/dist/google/keychain.js +40 -0
- package/dist/google/oauth.js +134 -0
- package/dist/mailbox.js +4 -0
- package/dist/mcp/protocol.js +2 -1
- package/dist/mcp/tools.js +38 -3
- package/dist/paths.js +2 -0
- package/dist/revoke.js +4 -4
- package/dist/server.js +35 -4
- package/dist/setup.js +3 -3
- package/dist/telegram.js +15 -15
- package/package.json +1 -1
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:
|
|
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 "
|
|
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(
|
|
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(`\
|
|
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 " ⌛
|
|
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
|
}
|
package/dist/cli/format.js
CHANGED
|
@@ -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
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
|
|
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
|
+
}
|
package/dist/cli/intro-frames.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
16
|
+
throw new Error("--agent needs a name: --agent <name>");
|
|
17
17
|
filter.agent = name;
|
|
18
18
|
}
|
|
19
19
|
else
|
|
20
|
-
throw new Error(
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
77
|
-
styleText("dim", "
|
|
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",
|
|
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);
|