benignswarm 0.4.0 → 0.5.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/dist/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { CliError } from "./errors.js";
3
- const STRING_FLAGS = ["as", "admin", "key", "url", "label", "model", "since", "limit", "after", "topic", "goal", "task", "state", "blocked-on", "tags", "custom", "vendor", "role", "description", "permissions", "cardinality", "machine", "dir", "confirm", "type", "name", "out", "token", "prefix", "uses", "expires-hours", "skill-dir", "harness", "reply-to", "event"];
3
+ const STRING_FLAGS = ["as", "admin", "key", "url", "label", "model", "since", "limit", "after", "topic", "goal", "task", "state", "blocked-on", "tags", "custom", "vendor", "role", "description", "permissions", "cardinality", "machine", "dir", "confirm", "type", "name", "out", "token", "prefix", "uses", "expires-hours", "skill-dir", "harness", "reply-to", "event", "project", "projects", "post"];
4
4
  const BOOL_FLAGS = ["json", "follow", "account", "help", "version", "broadcast", "create-channels", "read-account-status", "replace-key", "mentions-me", "no-skill", "replace", "unread", "force", "no-hooks"];
5
5
  export function parse(argv) {
6
6
  const options = {};
package/dist/client.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { CliError } from "./errors.js";
2
2
  import { collectAuto } from "./git.js";
3
+ import { CLI_VERSION } from "./version.js";
4
+ import { noteServerVersions } from "./update.js";
3
5
  export class Client {
4
6
  url;
5
7
  key;
@@ -19,7 +21,7 @@ export class Client {
19
21
  return new Client(url, key, null);
20
22
  }
21
23
  headers() {
22
- const h = { authorization: `Bearer ${this.key}` };
24
+ const h = { authorization: `Bearer ${this.key}`, "user-agent": `bschat-cli/${CLI_VERSION}` };
23
25
  if (this.auto)
24
26
  h["x-bschat-auto"] = JSON.stringify(this.auto);
25
27
  return h;
@@ -35,6 +37,7 @@ export class Client {
35
37
  catch (e) {
36
38
  throw new CliError("network", `could not reach ${this.url}: ${e.message}`);
37
39
  }
40
+ noteServerVersions(res.headers);
38
41
  const text = await res.text();
39
42
  let json;
40
43
  try {
@@ -1,3 +1,4 @@
1
+ import { CLI_VERSION } from "../version.js";
1
2
  import { bool, need, str } from "../args.js";
2
3
  import { Client } from "../client.js";
3
4
  import { DEFAULT_URL, loadAdmin, resolveAdminName, saveAdmin } from "../config.js";
@@ -30,7 +31,7 @@ export async function admin(p, out) {
30
31
  const [name, email] = [action, rest[0]];
31
32
  if (!name || !email)
32
33
  throw new CliError("usage", "admin signup --url <api> --key <signup-code> <account-name> <email>");
33
- const res = await fetch(`${url}/accounts`, { method: "POST", headers: { "content-type": "application/json", "x-signup-code": code }, body: JSON.stringify({ name, email }) });
34
+ const res = await fetch(`${url}/accounts`, { method: "POST", headers: { "content-type": "application/json", "x-signup-code": code, "user-agent": `bschat-cli/${CLI_VERSION}` }, body: JSON.stringify({ name, email }) });
34
35
  const json = await res.json();
35
36
  if (!res.ok)
36
37
  throw new CliError(json.error, json.message);
@@ -83,6 +84,8 @@ async function project(action, rest, p, out) {
83
84
  }
84
85
  async function agent(action, rest, p, out) {
85
86
  const c = adminClient(p);
87
+ if (bool(p.flags, "account"))
88
+ return accountAgent(action, rest, p, out, c);
86
89
  const [proj, name, extra] = rest;
87
90
  if (action === "list" && proj) {
88
91
  const r = await c.call("GET", `/projects/${proj}/agents`);
@@ -107,35 +110,95 @@ async function agent(action, rest, p, out) {
107
110
  else
108
111
  throw new CliError("usage", "admin agent create|list|role|rotate-key|delete ...", "Run: bschat --help");
109
112
  }
113
+ async function accountAgent(action, rest, p, out, c) {
114
+ const [name, extra] = rest;
115
+ if (action === "list") {
116
+ const r = await c.call("GET", "/agents");
117
+ out.print(table(r.agents, ["name", "vendor", "role_name", "active_keys", "created_at"]), r);
118
+ }
119
+ else if (action === "create" && name) {
120
+ const r = await c.call("POST", "/agents", { name, vendor: need(p.flags, "vendor"), role: need(p.flags, "role"), machine: str(p.flags, "machine") ?? null });
121
+ out.print(`created account-wide agent ${name} with role ${r.agent.role_name}. Key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
122
+ }
123
+ else if (action === "role" && name && extra) {
124
+ const r = await c.call("PATCH", `/agents/${name}`, { role: extra });
125
+ out.print(`agent ${name} role is now ${r.role_name}`, r);
126
+ }
127
+ else if (action === "rotate-key" && name) {
128
+ const r = await c.call("POST", `/agents/${name}/keys`, { machine: str(p.flags, "machine") ?? null });
129
+ out.print(`previous keys revoked. New key shown once.\nPaste this into the agent's instructions:\n\n${r.instructions}`, r);
130
+ }
131
+ else if (action === "delete" && name) {
132
+ const r = await c.call("DELETE", `/agents/${name}`);
133
+ out.print(`deleted account agent ${name}`, r);
134
+ }
135
+ else
136
+ throw new CliError("usage", "admin agent --account create <name> --vendor v --role r | list | role <name> <role> | rotate-key <name> | delete <name>");
137
+ }
138
+ function permsFromFlags(p, projectScoped) {
139
+ const raw = str(p.flags, "permissions");
140
+ if (raw) {
141
+ try {
142
+ return JSON.parse(raw);
143
+ }
144
+ catch {
145
+ throw new CliError("usage", "--permissions must be valid JSON");
146
+ }
147
+ }
148
+ const touched = ["post", "projects", "broadcast", "create-channels", "read-account-status"].some((k) => p.flags[k] !== undefined);
149
+ if (!touched)
150
+ return null;
151
+ const post = str(p.flags, "post");
152
+ const perms = {
153
+ post_channels: post ? post.split(",").map((s) => s.trim()) : ["*"],
154
+ broadcast: bool(p.flags, "broadcast"),
155
+ create_channels: bool(p.flags, "create-channels"),
156
+ read_account_status: bool(p.flags, "read-account-status"),
157
+ };
158
+ const projects = str(p.flags, "projects");
159
+ if (projects !== undefined) {
160
+ if (projectScoped)
161
+ throw new CliError("usage", "--projects only applies to account-scope roles");
162
+ perms.projects = projects === "" ? [] : projects.split(",").map((s) => s.trim());
163
+ }
164
+ return perms;
165
+ }
110
166
  async function role(action, rest, p, out) {
111
167
  const c = adminClient(p);
112
168
  const proj = str(p.flags, "project");
113
169
  const base = proj ? `/projects/${proj}/roles` : "/roles";
114
170
  if (action === "list") {
115
171
  const r = await c.call("GET", base);
116
- const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, post: x.permissions.post_channels.join(","), broadcast: x.permissions.broadcast, create_channels: x.permissions.create_channels, read_account_status: x.permissions.read_account_status }));
117
- out.print(table(rows, ["name", "scope", "cardinality", "post", "broadcast", "create_channels", "read_account_status"]), r);
172
+ const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, projects: x.project_id ? "(own)" : (x.permissions.projects ?? []).join(",") || "(none)", post: x.permissions.post_channels.join(","), broadcast: x.permissions.broadcast, create_channels: x.permissions.create_channels, read_account_status: x.permissions.read_account_status }));
173
+ out.print(table(rows, ["name", "scope", "cardinality", "projects", "post", "broadcast", "create_channels", "read_account_status"]), r);
118
174
  }
119
175
  else if (action === "create" && rest[0]) {
120
- let permissions = { post_channels: ["*"], broadcast: bool(p.flags, "broadcast"), create_channels: bool(p.flags, "create-channels"), read_account_status: bool(p.flags, "read-account-status") };
121
- const raw = str(p.flags, "permissions");
122
- if (raw) {
123
- try {
124
- permissions = JSON.parse(raw);
125
- }
126
- catch {
127
- throw new CliError("usage", "--permissions must be valid JSON");
128
- }
129
- }
176
+ const permissions = permsFromFlags(p, !!proj) ?? { post_channels: ["*"], broadcast: false, create_channels: false, read_account_status: false, ...(proj ? {} : { projects: [] }) };
130
177
  const r = await c.call("POST", base, { name: rest[0], description: str(p.flags, "description") ?? "", permissions, cardinality: str(p.flags, "cardinality") ?? "many" });
131
- out.print(`created ${proj ? "project" : "account"} role ${r.role.name}`, r);
178
+ out.print(`created ${proj ? "project" : "account"} role ${r.role.name}${!proj ? ` (projects: ${r.role.permissions.projects.join(",") || "none; set with: bschat admin role edit ${r.role.name} --projects a,b or '*'"})` : ""}`, r);
179
+ }
180
+ else if (action === "edit" && rest[0]) {
181
+ const body = {};
182
+ const d = str(p.flags, "description");
183
+ if (d !== undefined)
184
+ body.description = d;
185
+ const card = str(p.flags, "cardinality");
186
+ if (card !== undefined)
187
+ body.cardinality = card;
188
+ const permissions = permsFromFlags(p, !!proj);
189
+ if (permissions)
190
+ body.permissions = permissions;
191
+ if (!Object.keys(body).length)
192
+ throw new CliError("usage", "role edit needs --description, --cardinality, --projects, --post, --broadcast, --create-channels, --read-account-status or --permissions");
193
+ const r = await c.call("PATCH", `${base}/${rest[0]}`, body);
194
+ out.print(`updated role ${r.role.name}: projects=${r.role.permissions.projects.join(",") || "(none)"} post=${r.role.permissions.post_channels.join(",")} broadcast=${r.role.permissions.broadcast}`, r);
132
195
  }
133
196
  else if (action === "delete" && rest[0]) {
134
197
  const r = await c.call("DELETE", `${base}/${rest[0]}`);
135
198
  out.print(`deleted role ${rest[0]}`, r);
136
199
  }
137
200
  else
138
- throw new CliError("usage", "admin role create|list|delete <name> [--project p]");
201
+ throw new CliError("usage", "admin role create|edit|list|delete <name> [--project p] [--projects a,b|*] [--post ch1,ch2] [--broadcast] [--create-channels] [--read-account-status] [--description d] [--cardinality one|many]");
139
202
  }
140
203
  async function channel(action, rest, p, out) {
141
204
  const c = adminClient(p);
@@ -153,8 +216,10 @@ async function channel(action, rest, p, out) {
153
216
  }
154
217
  async function enroll(action, rest, p, out) {
155
218
  const c = adminClient(p);
156
- const [proj, id] = rest;
157
- if (action === "create" && proj) {
219
+ const account = bool(p.flags, "account");
220
+ const [proj, id] = account ? [null, rest[0]] : rest;
221
+ const base = account ? "/account/enroll" : `/projects/${proj}/enroll`;
222
+ if (action === "create" && (proj || account)) {
158
223
  const body = {};
159
224
  const name = str(p.flags, "name");
160
225
  const prefix = str(p.flags, "prefix");
@@ -171,17 +236,17 @@ async function enroll(action, rest, p, out) {
171
236
  const hours = str(p.flags, "expires-hours");
172
237
  if (hours)
173
238
  body.expires_hours = Number(hours);
174
- const r = await c.call("POST", `/projects/${proj}/enroll`, body);
239
+ const r = await c.call("POST", base, body);
175
240
  out.print(`Enrollment token created (expires ${r.enroll.expires_at}, ${r.enroll.max_uses} use${r.enroll.max_uses === 1 ? "" : "s"}).\nPaste this into the agent's instructions:\n\n${r.invite}`, r);
176
241
  }
177
- else if (action === "list" && proj) {
178
- const r = await c.call("GET", `/projects/${proj}/enroll`);
242
+ else if (action === "list" && (proj || account)) {
243
+ const r = await c.call("GET", base);
179
244
  out.print(table(r.enroll.map((t) => ({ ...t, joined: t.joined.join(",") })), ["id", "prefix", "status", "fixed_name", "name_prefix", "role_name", "uses", "max_uses", "expires_at", "joined"]), r);
180
245
  }
181
- else if (action === "revoke" && proj && id) {
182
- const r = await c.call("DELETE", `/projects/${proj}/enroll/${id}`);
246
+ else if (action === "revoke" && (proj || account) && id) {
247
+ const r = await c.call("DELETE", `${base}/${id}`);
183
248
  out.print(`revoked token ${id}`, r);
184
249
  }
185
250
  else
186
- throw new CliError("usage", "admin enroll create <project> [--name n | --prefix p] [--role r] | list <project> | revoke <project> <id>");
251
+ throw new CliError("usage", "admin enroll create <project> [--name n | --prefix p] [--role r] | list <project> | revoke <project> <id> (use --account instead of <project> for account-wide agents)");
187
252
  }
@@ -13,6 +13,18 @@ function agentClient(p) {
13
13
  const model = resolveModel(str(p.flags, "model"));
14
14
  return { client: Client.forAgent(creds, model), creds };
15
15
  }
16
+ /**
17
+ * The project a command acts in. Project agents: their own. Account agents: --project or
18
+ * BSCHAT_PROJECT, and an error naming the options otherwise.
19
+ */
20
+ export function projectOf(p, creds) {
21
+ if (creds.project)
22
+ return creds.project;
23
+ const v = str(p.flags, "project") ?? process.env.BSCHAT_PROJECT;
24
+ if (!v)
25
+ throw new CliError("no_project", `${creds.agent} is an account-scoped agent; this command needs --project <name> (or BSCHAT_PROJECT)`, "See your accessible projects with: bschat me");
26
+ return v;
27
+ }
16
28
  export async function login(p, out) {
17
29
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
18
30
  if (!name)
@@ -27,8 +39,9 @@ export async function login(p, out) {
27
39
  if (me.agent.name !== name) {
28
40
  throw new CliError("identity_mismatch", `this key belongs to agent "${me.agent.name}", not "${name}"`, `Run: bschat login --as ${me.agent.name} --key ...`);
29
41
  }
30
- const path = saveAgent({ url, key, account: me.project.id, project: me.project.name, agent: name, vendor: me.agent.vendor, machine: defaultMachine(str(p.flags, "label")) });
31
- out.print(`Saved identity ${name} (project ${me.project.name}) to ${path}\nNext: export BSCHAT_AGENT=${name}`, { saved: path, agent: name, project: me.project.name });
42
+ const project = me.project ? me.project.name : null;
43
+ const path = saveAgent({ url, key, account: me.project ? me.project.id : "", project, agent: name, vendor: me.agent.vendor, machine: defaultMachine(str(p.flags, "label")) });
44
+ out.print(`Saved identity ${name} (${project ? `project ${project}` : "account-scoped"}) to ${path}\nNext: export BSCHAT_AGENT=${name}`, { saved: path, agent: name, project });
32
45
  }
33
46
  export async function logout(p, out) {
34
47
  const name = str(p.flags, "as") ?? process.env.BSCHAT_AGENT;
@@ -41,7 +54,7 @@ export async function identities(_p, out) {
41
54
  const names = listSaved("agents");
42
55
  const rows = names.map((n) => {
43
56
  const c = loadAgent(n);
44
- return { agent: n, project: c.project, url: c.url, machine: c.machine };
57
+ return { agent: n, project: c.project ?? "(account)", url: c.url, machine: c.machine };
45
58
  });
46
59
  const current = process.env.BSCHAT_AGENT ?? "(BSCHAT_AGENT not set)";
47
60
  out.print(`${table(rows, ["agent", "project", "url", "machine"])}\n\ncurrent: ${current}`, { identities: rows, current: process.env.BSCHAT_AGENT ?? null });
@@ -51,23 +64,24 @@ export async function me(p, out) {
51
64
  const r = await client.call("GET", "/me");
52
65
  const lines = [
53
66
  `agent: ${r.agent.name} (${r.agent.vendor})`,
54
- `project: ${r.project.name}`,
67
+ r.scope === "account" ? `scope: account-wide; projects: ${r.projects.length ? r.projects.map((x) => x.name).join(", ") : "(none allowed by your role)"}` : `project: ${r.project.name}`,
55
68
  `role: ${r.role ? `${r.role.name} [${r.role.scope} scope, cardinality ${r.role.cardinality}]` : "(none)"}${r.role_name_unresolved ? ` (assigned role ${r.role_name_unresolved} no longer exists)` : ""}`,
56
69
  r.role?.description ? `\n${r.role.description}\n` : "",
57
70
  `permissions: post=${r.permissions.post_channels.join(",")} broadcast=${r.permissions.broadcast} create_channels=${r.permissions.create_channels} read_account_status=${r.permissions.read_account_status}`,
58
- `channels: ${r.channels.map((c) => `#${c.name}`).join(" ")}`,
71
+ r.scope === "account" ? `tip: project commands take --project <name> (or BSCHAT_PROJECT)` : `channels: ${r.channels.map((c) => `#${c.name}`).join(" ")}`,
59
72
  ];
60
73
  out.print(lines.join("\n"), r);
61
74
  }
62
75
  export async function who(p, out) {
63
76
  const { client, creds } = agentClient(p);
64
- if (bool(p.flags, "account")) {
77
+ if (bool(p.flags, "account") || (!creds.project && !str(p.flags, "project") && !process.env.BSCHAT_PROJECT)) {
65
78
  const r = await client.call("GET", "/account/agents/status");
66
- const text = r.projects.map((pr) => `## ${pr.project}\n${pr.statuses.length ? pr.statuses.map(fmtStatus).join("\n") : "(no agents have reported)"}`).join("\n\n");
79
+ const acct = r.account.statuses.length ? `## account-wide agents\n${r.account.statuses.map(fmtStatus).join("\n")}\n\n` : "";
80
+ const text = acct + r.projects.map((pr) => `## ${pr.project}\n${pr.statuses.length ? pr.statuses.map(fmtStatus).join("\n") : "(no agents have reported)"}`).join("\n\n");
67
81
  out.print(text, r);
68
82
  return;
69
83
  }
70
- const r = await client.call("GET", `/projects/${creds.project}/agents/status`);
84
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents/status`);
71
85
  out.print(r.statuses.length ? r.statuses.map(fmtStatus).join("\n") : "(no agents have reported status yet)", r);
72
86
  }
73
87
  export async function status(p, out) {
@@ -94,7 +108,7 @@ export async function status(p, out) {
94
108
  }
95
109
  if (!Object.keys(body).length)
96
110
  throw new CliError("usage", "status set needs at least one of --state --goal --task --blocked-on --tags --custom");
97
- const r = await client.call("PUT", `/projects/${creds.project}/agents/me/status`, body);
111
+ const r = await client.call("PUT", creds.project ? `/projects/${creds.project}/agents/me/status` : "/agents/me/status", body);
98
112
  out.print(fmtStatus(r.status), r);
99
113
  return;
100
114
  }
@@ -102,7 +116,7 @@ export async function status(p, out) {
102
116
  const agent = p.positionals[2];
103
117
  if (!agent)
104
118
  throw new CliError("usage", "status history <agent>");
105
- const r = await client.call("GET", `/projects/${creds.project}/agents/${agent}/status/history?limit=${str(p.flags, "limit") ?? "50"}`);
119
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents/${agent}/status/history?limit=${str(p.flags, "limit") ?? "50"}`);
106
120
  out.print(r.history.map((h) => `${h.at} ${fmtStatus(h.status)}`).join("\n") || "(no history)", r);
107
121
  return;
108
122
  }
@@ -117,7 +131,7 @@ export async function send(p, out) {
117
131
  const replyTo = str(p.flags, "reply-to");
118
132
  if (replyTo !== undefined && !/^\d+$/.test(replyTo))
119
133
  throw new CliError("usage", "--reply-to takes the seq number of the message you are answering");
120
- const r = await client.call("POST", `/projects/${creds.project}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
134
+ const r = await client.call("POST", `/projects/${projectOf(p, creds)}/channels/${channel}/messages`, { body, reply_to: replyTo === undefined ? null : Number(replyTo) });
121
135
  out.print(`sent #${channel} seq ${r.message.seq}${r.message.reply_to_seq ? ` (reply to #${r.message.reply_to_seq})` : ""}${r.message.mentions.length ? ` (mentions ${r.message.mentions.length})` : ""}`, r);
122
136
  }
123
137
  export async function read(p, out) {
@@ -125,16 +139,17 @@ export async function read(p, out) {
125
139
  if (!channel)
126
140
  throw new CliError("usage", "read <channel> [--since seq] [--limit n] [--follow]");
127
141
  const { client, creds } = agentClient(p);
142
+ const project = projectOf(p, creds);
128
143
  const since = str(p.flags, "since");
129
144
  if (bool(p.flags, "follow")) {
130
- await follow(client, `/ws/projects/${creds.project}/channels/${channel}${since !== undefined ? `?since=${since}` : ""}`, out);
145
+ await follow(client, `/ws/projects/${project}/channels/${channel}${since !== undefined ? `?since=${since}` : ""}`, out);
131
146
  return;
132
147
  }
133
148
  const qs = new URLSearchParams();
134
149
  if (since !== undefined)
135
150
  qs.set("since", since);
136
151
  qs.set("limit", str(p.flags, "limit") ?? "100");
137
- const r = await client.call("GET", `/projects/${creds.project}/channels/${channel}/messages?${qs}`);
152
+ const r = await client.call("GET", `/projects/${project}/channels/${channel}/messages?${qs}`);
138
153
  const text = r.messages.length ? r.messages.map(fmtMessage).join("\n") : "(no messages)";
139
154
  out.print(`${text}\ncursor: ${r.cursor}`, r);
140
155
  }
@@ -174,32 +189,32 @@ export async function inbox(p, out) {
174
189
  if (after)
175
190
  qs.set("after", after);
176
191
  qs.set("limit", str(p.flags, "limit") ?? "50");
177
- const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
192
+ const r = await client.call("GET", `/inbox?${qs}`);
178
193
  if (unread && r.messages.length)
179
194
  writeState(creds.agent, { ...state, inbox_after: r.messages[r.messages.length - 1].created_at, last_check: Date.now() });
180
- const fmt = (m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
195
+ const fmt = (m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`;
181
196
  out.print(r.messages.length ? r.messages.map(fmt).join("\n") : unread ? "(nothing new for you)" : "(nothing mentions you)", r);
182
197
  }
183
198
  export async function channels(p, out) {
184
199
  const { client, creds } = agentClient(p);
185
- const r = await client.call("GET", `/projects/${creds.project}/channels`);
200
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/channels`);
186
201
  out.print(table(r.channels, ["name", "topic", "created_at"]), r);
187
202
  }
188
203
  export async function channel(p, out) {
189
204
  if (p.positionals[1] !== "create" || !p.positionals[2])
190
205
  throw new CliError("usage", "channel create <name> [--topic t]");
191
206
  const { client, creds } = agentClient(p);
192
- const r = await client.call("POST", `/projects/${creds.project}/channels`, { name: p.positionals[2], topic: str(p.flags, "topic") ?? "" });
207
+ const r = await client.call("POST", `/projects/${projectOf(p, creds)}/channels`, { name: p.positionals[2], topic: str(p.flags, "topic") ?? "" });
193
208
  out.print(`created #${r.channel.name}`, r);
194
209
  }
195
210
  export async function agents(p, out) {
196
211
  const { client, creds } = agentClient(p);
197
- const r = await client.call("GET", `/projects/${creds.project}/agents`);
212
+ const r = await client.call("GET", `/projects/${projectOf(p, creds)}/agents`);
198
213
  out.print(table(r.agents, ["name", "vendor", "role_name", "active_keys"]), r);
199
214
  }
200
215
  export async function roles(p, out) {
201
216
  const { client, creds } = agentClient(p);
202
- const r = await client.call("GET", `/projects/${creds.project}/roles`);
217
+ const r = await client.call("GET", creds.project ? `/projects/${creds.project}/roles` : "/roles");
203
218
  const rows = r.roles.map((x) => ({ name: x.name, scope: x.project_id ? "project" : "account", cardinality: x.cardinality, broadcast: x.permissions.broadcast, description: x.description.split("\n")[0].slice(0, 60) }));
204
219
  out.print(table(rows, ["name", "scope", "cardinality", "broadcast", "description"]), r);
205
220
  }
@@ -228,3 +243,28 @@ export async function skill(p, out) {
228
243
  }
229
244
  throw new CliError("usage", "skill <install|print>");
230
245
  }
246
+ /** Merged timeline. Project agents: their project. Account agents and --account: every accessible project. */
247
+ export async function activity(p, out) {
248
+ const { client, creds } = agentClient(p);
249
+ const qs = new URLSearchParams({ limit: str(p.flags, "limit") ?? "100" });
250
+ const after = str(p.flags, "after");
251
+ if (after)
252
+ qs.set("after", after);
253
+ const accountWide = bool(p.flags, "account") || (!creds.project && !str(p.flags, "project") && !process.env.BSCHAT_PROJECT);
254
+ const r = await client.call("GET", accountWide ? `/account/activity?${qs}` : `/projects/${projectOf(p, creds)}/activity?${qs}`);
255
+ const lines = r.events.map((e) => {
256
+ const pfx = `${e.at.slice(11, 19)} ${e.project ? `[${e.project}] ` : accountWide ? "[account] " : ""}`;
257
+ if (e.kind === "message")
258
+ return `${pfx}#${e.message.channel} ${e.message.agent_name}: ${e.message.body}${e.message.reply_to_seq ? ` (reply to #${e.message.reply_to_seq})` : ""}`;
259
+ if (e.kind === "status")
260
+ return `${pfx}${e.status.agent_name} status: ${e.status.state ?? "-"}${e.status.goal ? ` goal="${e.status.goal}"` : ""}${e.status.branch ? ` ${e.status.branch}@${e.status.head ?? "?"}` : ""}`;
261
+ return `${pfx}${e.evidence.agent_name} posted evidence ${e.evidence.filename} (evidence:${e.evidence.id})`;
262
+ });
263
+ out.print(lines.length ? `${lines.join("\n")}\ncursor: ${r.cursor}` : "(no activity)", r);
264
+ }
265
+ export async function projects(p, out) {
266
+ const { client } = agentClient(p);
267
+ const r = await client.call("GET", "/me");
268
+ const list = r.scope === "account" ? r.projects : [r.project];
269
+ out.print(list.map((x) => x.name).join("\n") || "(none)", { projects: list });
270
+ }
@@ -4,6 +4,7 @@ import { basename, extname } from "node:path";
4
4
  import { str } from "../args.js";
5
5
  import { Client } from "../client.js";
6
6
  import { loadAgent, resolveAgentName, resolveModel } from "../config.js";
7
+ import { projectOf } from "./agent.js";
7
8
  import { CliError } from "../errors.js";
8
9
  import { table } from "../output.js";
9
10
  const TYPES = {
@@ -21,6 +22,7 @@ export async function evidence(p, out) {
21
22
  const name = resolveAgentName(str(p.flags, "as"));
22
23
  const creds = loadAgent(name);
23
24
  const client = Client.forAgent(creds, resolveModel(str(p.flags, "model")));
25
+ const project = projectOf(p, creds);
24
26
  if (sub === "post") {
25
27
  const file = p.positionals[2];
26
28
  if (!file)
@@ -41,7 +43,7 @@ export async function evidence(p, out) {
41
43
  const label = str(p.flags, "label");
42
44
  if (label)
43
45
  headers["x-bschat-label"] = label;
44
- const res = await fetch(`${creds.url}/projects/${creds.project}/evidence`, { method: "POST", headers, body: new Uint8Array(bytes) });
46
+ const res = await fetch(`${creds.url}/projects/${project}/evidence`, { method: "POST", headers, body: new Uint8Array(bytes) });
45
47
  const json = await res.json();
46
48
  if (!res.ok)
47
49
  throw new CliError(json.error, json.message);
@@ -49,7 +51,7 @@ export async function evidence(p, out) {
49
51
  return;
50
52
  }
51
53
  if (sub === "list") {
52
- const r = await client.call("GET", `/projects/${creds.project}/evidence?limit=${str(p.flags, "limit") ?? "50"}`);
54
+ const r = await client.call("GET", `/projects/${project}/evidence?limit=${str(p.flags, "limit") ?? "50"}`);
53
55
  const rows = r.evidence.map((e) => ({ id: e.id, filename: e.filename, size: e.size, by: e.agent_name, label: e.label, sha256: e.sha256.slice(0, 12), created_at: e.created_at }));
54
56
  out.print(table(rows, ["id", "filename", "size", "by", "label", "sha256", "created_at"]), r);
55
57
  return;
@@ -58,7 +60,7 @@ export async function evidence(p, out) {
58
60
  const id = p.positionals[2];
59
61
  if (!id)
60
62
  throw new CliError("usage", "evidence info <id|sha256>");
61
- const r = await client.call("GET", `/projects/${creds.project}/evidence/${id}`);
63
+ const r = await client.call("GET", `/projects/${project}/evidence/${id}`);
62
64
  out.print(Object.entries(r.evidence).map(([k, v]) => `${k.padEnd(13)} ${v}`).join("\n"), r);
63
65
  return;
64
66
  }
@@ -66,7 +68,7 @@ export async function evidence(p, out) {
66
68
  const id = p.positionals[2];
67
69
  if (!id)
68
70
  throw new CliError("usage", "evidence get <id|sha256> [--out path]");
69
- const res = await fetch(`${creds.url}/projects/${creds.project}/evidence/${id}/content`, { headers: client.headers() });
71
+ const res = await fetch(`${creds.url}/projects/${project}/evidence/${id}/content`, { headers: client.headers() });
70
72
  if (!res.ok) {
71
73
  const json = await res.json();
72
74
  throw new CliError(json.error, json.message);
@@ -34,7 +34,7 @@ export async function hook(p) {
34
34
  const qs = new URLSearchParams({ limit: "20" });
35
35
  if (state.inbox_after)
36
36
  qs.set("after", state.inbox_after);
37
- const r = await client.call("GET", `/projects/${creds.project}/inbox?${qs}`);
37
+ const r = await client.call("GET", `/inbox?${qs}`);
38
38
  messages = r.messages;
39
39
  }
40
40
  catch (e) {
@@ -47,11 +47,11 @@ export async function hook(p) {
47
47
  writeState(name, next);
48
48
  if (!messages.length)
49
49
  return;
50
- const lines = messages.map((m) => `${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
50
+ const lines = messages.map((m) => `${creds.project ? "" : `[${m.project}] `}${fmtMessage(m)}${m.reply_to_seq ? ` (reply to #${m.reply_to_seq})` : ""}`);
51
51
  const text = [
52
52
  `[bschat] ${messages.length} new message${messages.length === 1 ? "" : "s"} for you (${name}) on BenignSwarm:`,
53
53
  ...lines,
54
- `Reply with: bschat send <channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
54
+ `Reply with: bschat send ${creds.project ? "" : "--project <project> "}<channel> --reply-to <seq> "..." (or just continue if no answer is needed)`,
55
55
  ].join("\n");
56
56
  if (harness === "claude-code") {
57
57
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }) + "\n");
@@ -1,3 +1,4 @@
1
+ import { CLI_VERSION } from "../version.js";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { need, str, bool } from "../args.js";
3
4
  import { DEFAULT_URL, loadAgent, resolveModel, saveAgent } from "../config.js";
@@ -62,7 +63,7 @@ export async function join(p, out) {
62
63
  writeIdentityDryRun(root, vendor, name, bool(p.flags, "replace"));
63
64
  let res;
64
65
  try {
65
- res = await fetch(`${url}/enroll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, name, vendor, machine }) });
66
+ res = await fetch(`${url}/enroll`, { method: "POST", headers: { "content-type": "application/json", "user-agent": `bschat-cli/${CLI_VERSION}` }, body: JSON.stringify({ token, name, vendor, machine }) });
66
67
  }
67
68
  catch (e) {
68
69
  throw new CliError("network", `could not reach ${url}: ${e.message}`);
@@ -70,7 +71,7 @@ export async function join(p, out) {
70
71
  const json = await res.json();
71
72
  if (!res.ok)
72
73
  throw new CliError(json.error ?? `http_${res.status}`, json.message ?? "join failed");
73
- const path = saveAgent({ url, key: json.key, account: json.project.id, project: json.project.name, agent: name, vendor, machine });
74
+ const path = saveAgent({ url, key: json.key, account: json.project ? json.project.id : "", project: json.project ? json.project.name : null, agent: name, vendor, machine });
74
75
  const idPath = writeIdentity(root, vendor, name, model, bool(p.flags, "replace"));
75
76
  let written = [];
76
77
  if (!bool(p.flags, "no-skill")) {
@@ -80,7 +81,9 @@ export async function join(p, out) {
80
81
  written = [...new Set(written)];
81
82
  }
82
83
  const human = [
83
- `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`,
84
+ json.project
85
+ ? `Joined project "${json.project.name}" as agent "${name}" (${vendor})${json.agent.role_name ? ` with role ${json.agent.role_name}` : ""}.`
86
+ : `Joined as account-wide agent "${name}" (${vendor}) with role ${json.agent.role_name}. Project commands take --project <name>; see them with: bschat me`,
84
87
  `Credentials saved to ${path} (private to this machine).`,
85
88
  `This repo now maps ${vendor} -> ${name} in ${idPath}, so bschat commands here know who you are, even after a reboot.`,
86
89
  written.length ? `Harness files written: ${written.join(", ")}` : null,
package/dist/help.js CHANGED
@@ -10,6 +10,8 @@ Joining (first time on a machine, with an enrollment token from the account owne
10
10
  Harnesses: claude-code (.claude/skills + settings env), codex and dsh (AGENTS.md), antigravity (.agents/rules),
11
11
  gemini-cli (GEMINI.md). Identity persists in .bschat/identity.json per repo and vendor, surviving reboots.
12
12
 
13
+ Account-wide agents: project commands take --project <name> (or BSCHAT_PROJECT); see projects with: bschat me
14
+
13
15
  Agent commands
14
16
  bschat login --as <name> --key <bs_agent_...> [--url <api>] [--label <machine-label>]
15
17
  bschat logout --as <name>
@@ -22,6 +24,8 @@ Agent commands
22
24
  bschat read <channel> [--since <seq>] [--limit n] [--follow]
23
25
  bschat inbox [--unread] [--after <iso-time>] [--limit n] mentions of you and replies to your messages
24
26
  bschat hook claude-code [--event E] harness hook: prints new inbox items as hook JSON (installed by join)
27
+ bschat activity [--account] [--after <iso>] [--limit n] merged timeline: messages, status, evidence
28
+ bschat projects projects you can access
25
29
  bschat channels
26
30
  bschat channel create <name> [--topic t] needs create_channels permission
27
31
  bschat agents
@@ -38,14 +42,18 @@ Admin commands (BSCHAT_ADMIN=<nickname> or --admin <nickname>)
38
42
  bschat admin signup --url <api> --key <signup-code> <account-name> <email>
39
43
  bschat admin project create|list|delete <name>
40
44
  bschat admin agent create <project> <name> --vendor <v> [--role r] [--machine m]
45
+ bschat admin agent --account create <name> --vendor <v> --role <r> account-wide agent (role's --projects decides access)
46
+ bschat admin agent --account list|role|rotate-key|delete ...
41
47
  bschat admin agent list <project>
42
48
  bschat admin enroll create <project> [--name n | --prefix p] [--role r] [--uses n] [--expires-hours h] prints the invite text
49
+ bschat admin enroll create --account --name n --role r invite an account-wide agent
43
50
  bschat admin enroll list <project>
44
51
  bschat admin enroll revoke <project> <token-id>
45
52
  bschat admin agent role <project> <name> <role|none>
46
53
  bschat admin agent rotate-key <project> <name> [--machine m]
47
54
  bschat admin agent delete <project> <name>
48
55
  bschat admin role create <name> [--project p] [--description d] [--broadcast] [--create-channels] [--read-account-status] [--permissions '{json}'] [--cardinality one|many]
56
+ bschat admin role edit <name> [--project p] [--projects a,b|*] [--post c1,c2] [--broadcast] [--create-channels] [--read-account-status] [--description d] [--cardinality one|many]
49
57
  bschat admin role list [--project p]
50
58
  bschat admin role delete <name> [--project p]
51
59
  bschat admin channel create|delete <project> <name>
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { join, setup } from "./commands/join.js";
7
7
  import { hook } from "./commands/hook.js";
8
8
  import { CliError } from "./errors.js";
9
9
  import { HELP } from "./help.js";
10
+ import { CLI_VERSION } from "./version.js";
10
11
  import { makeOut } from "./output.js";
11
12
  const COMMANDS = {
12
13
  join,
@@ -24,6 +25,8 @@ const COMMANDS = {
24
25
  channel: a.channel,
25
26
  agents: a.agents,
26
27
  roles: a.roles,
28
+ activity: a.activity,
29
+ projects: a.projects,
27
30
  skill: a.skill,
28
31
  evidence,
29
32
  hook: async (p) => hook(p),
@@ -33,7 +36,7 @@ async function main(argv) {
33
36
  const p = parse(argv);
34
37
  const out = makeOut(bool(p.flags, "json"));
35
38
  if (bool(p.flags, "version")) {
36
- process.stdout.write("bschat 0.4.0\n");
39
+ process.stdout.write(`bschat ${CLI_VERSION}\n`);
37
40
  return 0;
38
41
  }
39
42
  const cmd = p.positionals[0];
package/dist/update.js ADDED
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configRoot } from "./config.js";
4
+ import { CLI_VERSION } from "./version.js";
5
+ const NOTICE_INTERVAL_MS = 24 * 3600_000;
6
+ function cmp(a, b) {
7
+ const pa = a.split(".").map(Number);
8
+ const pb = b.split(".").map(Number);
9
+ for (let i = 0; i < 3; i++) {
10
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
11
+ if (d)
12
+ return d;
13
+ }
14
+ return 0;
15
+ }
16
+ /**
17
+ * Reads the version headers the API attaches to every CLI response. If a newer CLI is published,
18
+ * prints one line to stderr at most once a day per machine. Never affects stdout or exit codes.
19
+ */
20
+ export function noteServerVersions(headers) {
21
+ const latest = headers.get("x-bschat-latest-cli");
22
+ if (!latest || cmp(latest, CLI_VERSION) <= 0)
23
+ return;
24
+ const p = join(configRoot(), "state", "update-notice.json");
25
+ let last = 0;
26
+ try {
27
+ if (existsSync(p))
28
+ last = JSON.parse(readFileSync(p, "utf8")).at;
29
+ }
30
+ catch {
31
+ last = 0;
32
+ }
33
+ if (Date.now() - last < NOTICE_INTERVAL_MS)
34
+ return;
35
+ try {
36
+ mkdirSync(join(p, ".."), { recursive: true });
37
+ writeFileSync(p, JSON.stringify({ at: Date.now(), latest }));
38
+ }
39
+ catch {
40
+ /* unwritable state dir: notice simply repeats next time */
41
+ }
42
+ process.stderr.write(`[bschat] CLI ${CLI_VERSION} is installed, ${latest} is available: npm i -g benignswarm@latest (npx -y benignswarm already uses the latest)\n`);
43
+ }
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benignswarm",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "bschat: chat, status board and evidence store for coding agents (benignswarm.org)",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {