@pitboss/cli 0.1.3 → 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
@@ -38,7 +38,7 @@ relai login --invite inv_<code> --api <https://your-relai-host>
38
38
  If you're standing one up yourself, see the [main repo](https://github.com/phillipsio/relai) for setup; then:
39
39
 
40
40
  ```bash
41
- relai init # guided setup — saves config to ~/.config/relai/config.json
41
+ relai init # guided setup — saves config to ~/.config/pitboss/config.json
42
42
  ```
43
43
 
44
44
  After either path, `relai init` / `relai login` prints a ready-to-paste `mcpServers` block — drop it into your project's `.mcp.json` (or `~/.claude.json`) so your AI agent (Claude Code, Cursor, Copilot, etc.) can use the relai tools directly.
@@ -117,16 +117,16 @@ relai token revoke <tokenId> Revoke a specific token
117
117
 
118
118
  ## Multi-identity
119
119
 
120
- Set `RELAI_CONFIG_DIR` to override the config location and run multiple agent identities side by side from one machine:
120
+ Set `PITBOSS_CONFIG_DIR` to override the config location and run multiple agent identities side by side from one machine:
121
121
 
122
122
  ```bash
123
- export RELAI_CONFIG_DIR=/tmp/relai-coworker
123
+ export PITBOSS_CONFIG_DIR=/tmp/relai-coworker
124
124
  relai login --invite inv_<...>
125
125
  ```
126
126
 
127
127
  ## Config
128
128
 
129
- `~/.config/relai/config.json` (or `$RELAI_CONFIG_DIR/config.json`):
129
+ `~/.config/pitboss/config.json` (or `$PITBOSS_CONFIG_DIR/config.json`):
130
130
 
131
131
  ```json
132
132
  {
package/dist/cli.js CHANGED
@@ -18010,14 +18010,46 @@ var dist_default7 = createPrompt((config, done) => {
18010
18010
  // src/config.ts
18011
18011
  import { homedir } from "node:os";
18012
18012
  import { join, dirname } from "node:path";
18013
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync, existsSync, renameSync } from "node:fs";
18013
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync, existsSync, renameSync, rmSync as rmSync2, lstatSync, statSync } from "node:fs";
18014
18014
  import { randomBytes } from "node:crypto";
18015
+ var configDirOverride = () => process.env.PITBOSS_CONFIG_DIR || process.env.RELAI_CONFIG_DIR || void 0;
18016
+ function configDir() {
18017
+ return configDirOverride() ?? join(homedir(), ".config", "pitboss");
18018
+ }
18015
18019
  function configPath() {
18016
- const dir = process.env.RELAI_CONFIG_DIR ?? join(homedir(), ".config", "relai");
18017
- return join(dir, "config.json");
18020
+ return join(configDir(), "config.json");
18021
+ }
18022
+ function legacyHomePath(name) {
18023
+ return join(homedir(), ".config", "relai", name);
18024
+ }
18025
+ var configOverridden = () => configDirOverride() !== void 0;
18026
+ var warnedFor = /* @__PURE__ */ new Set();
18027
+ function readableFrom(current, legacy, overridden) {
18028
+ if (overridden || existsSync(current) || !existsSync(legacy)) return current;
18029
+ if (!warnedFor.has(legacy)) {
18030
+ warnedFor.add(legacy);
18031
+ console.error(`pitboss: reading ${legacy}; the next write moves it to ${current}.`);
18032
+ }
18033
+ return legacy;
18034
+ }
18035
+ function retireLegacy(legacy, current, overridden) {
18036
+ if (overridden) return;
18037
+ try {
18038
+ const found = lstatSync(legacy, { throwIfNoEntry: false });
18039
+ if (!found) return;
18040
+ const [a, b] = [statSync(dirname(legacy)), statSync(dirname(current))];
18041
+ if (a.dev === b.dev && a.ino === b.ino) return;
18042
+ rmSync2(legacy);
18043
+ } catch (err) {
18044
+ if (err.code === "ENOENT") return;
18045
+ console.error(`pitboss: could not remove ${legacy} (${String(err)}); it may still hold a token, delete it by hand.`);
18046
+ }
18047
+ }
18048
+ function configFileInUse() {
18049
+ return readableFrom(configPath(), legacyHomePath("config.json"), configOverridden());
18018
18050
  }
18019
18051
  function readConfig() {
18020
- const file = configPath();
18052
+ const file = configFileInUse();
18021
18053
  if (!existsSync(file)) return null;
18022
18054
  try {
18023
18055
  const raw = JSON.parse(readFileSync2(file, "utf-8"));
@@ -18029,10 +18061,12 @@ function readConfig() {
18029
18061
  }
18030
18062
  function writeConfig(config) {
18031
18063
  const file = configPath();
18064
+ const migrating = !existsSync(file);
18032
18065
  mkdirSync(dirname(file), { recursive: true });
18033
18066
  const tmp = `${file}.relai-${randomBytes(8).toString("hex")}`;
18034
18067
  writeFileSync2(tmp, JSON.stringify(config, null, 2), { mode: 384, flag: "wx" });
18035
18068
  renameSync(tmp, file);
18069
+ if (migrating) retireLegacy(legacyHomePath("config.json"), file, configOverridden());
18036
18070
  return file;
18037
18071
  }
18038
18072
  function requireConfig() {
@@ -18896,7 +18930,7 @@ function runtimeTargets(workerType, { home, repo: repo2 }) {
18896
18930
  case "gpt":
18897
18931
  return [join2(home, ".codex", "mcp.json")];
18898
18932
  case "mcp":
18899
- return [join2(home, ".config", "relai", "config.json")];
18933
+ return [join2(home, ".config", "pitboss", "config.json")];
18900
18934
  // A person, not a runtime. Nothing to configure.
18901
18935
  case "human":
18902
18936
  return [];
@@ -19123,12 +19157,12 @@ import { homedir as homedir3 } from "node:os";
19123
19157
  import { join as join3, resolve, dirname as dirname2 } from "node:path";
19124
19158
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "node:fs";
19125
19159
  import { createHash } from "node:crypto";
19160
+ var stateOverridden = () => Boolean(process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE);
19126
19161
  function statePath() {
19127
- if (process.env.RELAI_AGENTS_STATE) return process.env.RELAI_AGENTS_STATE;
19128
- return join3(homedir3(), ".config", "relai", "agents.json");
19162
+ return process.env.PITBOSS_AGENTS_STATE || process.env.RELAI_AGENTS_STATE || join3(homedir3(), ".config", "pitboss", "agents.json");
19129
19163
  }
19130
19164
  function readAgentsState() {
19131
- const p = statePath();
19165
+ const p = readableFrom(statePath(), legacyHomePath("agents.json"), stateOverridden());
19132
19166
  if (!existsSync3(p)) return { agents: [] };
19133
19167
  try {
19134
19168
  const raw = JSON.parse(readFileSync4(p, "utf-8"));
@@ -19140,8 +19174,10 @@ function readAgentsState() {
19140
19174
  }
19141
19175
  function writeAgentsState(state) {
19142
19176
  const p = statePath();
19177
+ const migrating = !existsSync3(p);
19143
19178
  mkdirSync2(dirname2(p), { recursive: true });
19144
19179
  writeFileSync3(p, JSON.stringify(state, null, 2));
19180
+ if (migrating) retireLegacy(legacyHomePath("agents.json"), p, stateOverridden());
19145
19181
  }
19146
19182
  function hashToken(token2) {
19147
19183
  return createHash("sha256").update(token2).digest("hex").slice(0, 16);
@@ -19242,7 +19278,8 @@ async function loginCommand(opts) {
19242
19278
  if (existing && opts.invite) {
19243
19279
  console.log(source_default.yellow(`
19244
19280
  Already logged in as ${source_default.bold(existing.agentName)} (${existing.agentId})`));
19245
- console.log(source_default.dim("Delete ~/.config/relai/config.json to re-login.\n"));
19281
+ console.log(source_default.dim(`Delete ${configFileInUse()} to re-login.
19282
+ `));
19246
19283
  return;
19247
19284
  }
19248
19285
  const apiUrl = opts.api ?? await dist_default6({ message: "API URL", default: "http://localhost:3010" });
@@ -19581,7 +19618,8 @@ async function run(opts) {
19581
19618
  role: invite.role,
19582
19619
  specialization: invite.specialization ?? void 0,
19583
19620
  workerType: invite.workerType,
19584
- domains: invite.domains ?? []
19621
+ domains: invite.domains ?? [],
19622
+ .../^[^\p{Cc}\p{Cf}\u2028\u2029\u0085]{1,1024}$/u.test(root) ? { repoPath: root } : {}
19585
19623
  });
19586
19624
  if (accepted.status !== 201) {
19587
19625
  const said = accepted.payload?.error?.message;
package/dist/mcp.js CHANGED
@@ -9766,10 +9766,6 @@ var require_content_type = __commonJS({
9766
9766
  });
9767
9767
 
9768
9768
  // ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.6.0/node_modules/@modelcontextprotocol/sdk/dist/esm/server/sse.js
9769
- var sse_exports = {};
9770
- __export(sse_exports, {
9771
- SSEServerTransport: () => SSEServerTransport
9772
- });
9773
9769
  import { randomUUID } from "node:crypto";
9774
9770
  var import_raw_body, import_content_type, MAXIMUM_MESSAGE_SIZE, SSEServerTransport;
9775
9771
  var init_sse = __esm({
@@ -9888,6 +9884,106 @@ data: ${JSON.stringify(message)}
9888
9884
  }
9889
9885
  });
9890
9886
 
9887
+ // ../mcp-server/src/http-auth.ts
9888
+ var http_auth_exports = {};
9889
+ __export(http_auth_exports, {
9890
+ isAuthorizedBearer: () => isAuthorizedBearer,
9891
+ isBlankCredential: () => isBlankCredential,
9892
+ resolveHttpCredential: () => resolveHttpCredential
9893
+ });
9894
+ import { createHash, timingSafeEqual } from "node:crypto";
9895
+ function sha256(value) {
9896
+ return createHash("sha256").update(value, "utf8").digest();
9897
+ }
9898
+ function isBlankCredential(value) {
9899
+ return value.trim().length === 0;
9900
+ }
9901
+ function isAuthorizedBearer(authHeader, credential) {
9902
+ if (isBlankCredential(credential) || !authHeader) return false;
9903
+ const spaceIndex = authHeader.indexOf(" ");
9904
+ if (spaceIndex === -1) return false;
9905
+ if (authHeader.slice(0, spaceIndex).toLowerCase() !== "bearer") return false;
9906
+ const token = authHeader.slice(spaceIndex + 1);
9907
+ return timingSafeEqual(sha256(token), sha256(credential));
9908
+ }
9909
+ function resolveHttpCredential(mcpHttpToken, ownerMode, ownerToken, apiSecret) {
9910
+ if (mcpHttpToken) return mcpHttpToken;
9911
+ return (ownerMode ? ownerToken : apiSecret) ?? "";
9912
+ }
9913
+ var init_http_auth = __esm({
9914
+ "../mcp-server/src/http-auth.ts"() {
9915
+ "use strict";
9916
+ }
9917
+ });
9918
+
9919
+ // ../mcp-server/src/http-transport.ts
9920
+ var http_transport_exports = {};
9921
+ __export(http_transport_exports, {
9922
+ createHttpRequestListener: () => createHttpRequestListener
9923
+ });
9924
+ function createHttpRequestListener(server2, credential) {
9925
+ let current;
9926
+ let takeoverQueue = Promise.resolve();
9927
+ function detach(transport) {
9928
+ transport.onclose = void 0;
9929
+ transport.onerror = void 0;
9930
+ transport.onmessage = void 0;
9931
+ }
9932
+ async function takeOver(res) {
9933
+ const replaced = current;
9934
+ current = void 0;
9935
+ if (replaced) {
9936
+ detach(replaced);
9937
+ try {
9938
+ await replaced.close();
9939
+ } catch {
9940
+ }
9941
+ }
9942
+ const transport = new SSEServerTransport("/messages", res);
9943
+ try {
9944
+ await server2.connect(transport);
9945
+ current = transport;
9946
+ } catch (err) {
9947
+ detach(transport);
9948
+ console.error("[relai-mcp] failed to establish SSE connection:", err instanceof Error ? err.message : err);
9949
+ if (!res.headersSent) {
9950
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { code: "connect_failed", message: "Failed to establish SSE connection" } }));
9951
+ }
9952
+ }
9953
+ }
9954
+ return async (req, res) => {
9955
+ if (!isAuthorizedBearer(req.headers.authorization, credential)) {
9956
+ console.error(`[relai-mcp] rejected unauthenticated HTTP request: ${req.method} ${req.url}`);
9957
+ res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" }).end(UNAUTHORIZED_BODY);
9958
+ return;
9959
+ }
9960
+ const path = (req.url ?? "").split("?")[0];
9961
+ if (req.method === "GET" && path === "/sse") {
9962
+ const attempt = takeoverQueue.then(() => takeOver(res), () => takeOver(res));
9963
+ takeoverQueue = attempt;
9964
+ await attempt;
9965
+ } else if (req.method === "POST" && path === "/messages") {
9966
+ res.writeHead(501, { "content-type": "application/json" }).end(NOT_IMPLEMENTED_BODY);
9967
+ } else {
9968
+ res.writeHead(404).end();
9969
+ }
9970
+ };
9971
+ }
9972
+ var UNAUTHORIZED_BODY, NOT_IMPLEMENTED_BODY;
9973
+ var init_http_transport = __esm({
9974
+ "../mcp-server/src/http-transport.ts"() {
9975
+ "use strict";
9976
+ init_sse();
9977
+ init_http_auth();
9978
+ UNAUTHORIZED_BODY = JSON.stringify({
9979
+ error: { code: "unauthorized", message: "Missing or invalid bearer token" }
9980
+ });
9981
+ NOT_IMPLEMENTED_BODY = JSON.stringify({
9982
+ error: { code: "not_implemented", message: "This server does not yet process incoming tool-call messages" }
9983
+ });
9984
+ }
9985
+ });
9986
+
9891
9987
  // ../mcp-server/src/index.ts
9892
9988
  import { readFileSync } from "node:fs";
9893
9989
  import { join } from "node:path";
@@ -12177,6 +12273,12 @@ var ApiClient = class {
12177
12273
  `/agents/${agentId}/tokens`
12178
12274
  );
12179
12275
  }
12276
+ deleteAgent(agentId) {
12277
+ return this.request("DELETE", `/agents/${agentId}`);
12278
+ }
12279
+ revokeToken(tokenId) {
12280
+ return this.request("DELETE", `/tokens/${tokenId}`);
12281
+ }
12180
12282
  listInvites(repoId) {
12181
12283
  return this.request(
12182
12284
  "GET",
@@ -12824,7 +12926,39 @@ ${version.body}`
12824
12926
  // Additive, never a swap: an owner-scoped agent is still an agent and needs
12825
12927
  // everything above. buildOperatorTools assumes no agent identity, and its
12826
12928
  // create_task bypasses propose/commit.
12827
- ...opts.ownerScoped ? buildProvisioningTools(client) : []
12929
+ ...opts.ownerScoped ? [...buildProvisioningTools(client, { credentialMinting: true }), ...buildAgentManagementTools(client, agentId)] : []
12930
+ ];
12931
+ }
12932
+ function buildAgentManagementTools(client, selfId) {
12933
+ const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
12934
+ return [
12935
+ {
12936
+ name: "remove_agent",
12937
+ description: "Permanently delete one of the owner's agents, with its tokens. Cannot be undone: the agent has to be invited again to come back. Use list_agents to find the id. You cannot remove yourself, and a repo's only orchestrator cannot be removed.",
12938
+ inputSchema: external_exports.object({ agentId: external_exports.string().min(1).describe("The agent to delete.") }),
12939
+ handler: async (input) => {
12940
+ if (input.agentId === selfId) {
12941
+ return { content: [{ type: "text", text: "Refused: you cannot remove yourself." }], isError: true };
12942
+ }
12943
+ await client.deleteAgent(input.agentId);
12944
+ return text({ removed: input.agentId });
12945
+ }
12946
+ },
12947
+ {
12948
+ name: "list_tokens",
12949
+ description: "List an agent's tokens: id, createdAt, lastUsedAt, revokedAt, whether each is owner-scoped, and which one is current. Never the token itself. Use it before revoke_token.",
12950
+ inputSchema: external_exports.object({ agentId: external_exports.string().min(1).describe("Whose tokens to list.") }),
12951
+ handler: async (input) => text({ tokens: await client.listAgentTokens(input.agentId) })
12952
+ },
12953
+ {
12954
+ name: "revoke_token",
12955
+ description: "Revoke one token by id, so whatever holds it stops working at once. The agent itself stays; it needs a new token or invite to reconnect. Revoking your own current token ends this session.",
12956
+ inputSchema: external_exports.object({ tokenId: external_exports.string().min(1).describe("From list_tokens.") }),
12957
+ handler: async (input) => {
12958
+ await client.revokeToken(input.tokenId);
12959
+ return text({ revoked: input.tokenId });
12960
+ }
12961
+ }
12828
12962
  ];
12829
12963
  }
12830
12964
  function selectTools(client, config) {
@@ -12879,11 +13013,7 @@ function buildProvisioningTools(client, opts = {}) {
12879
13013
  return { content: [{ type: "text", text: JSON.stringify({ repo }, null, 2) }] };
12880
13014
  }
12881
13015
  },
12882
- // invite_agent mints a bearer credential, and agent mode reaches a headless
12883
- // `claude --print --dangerously-skip-permissions` loop where nothing
12884
- // confirms a call. Before this, no agent-mode tool took a repoId from the
12885
- // model at all, so the model's reach was one repo even when the credential
12886
- // reached every repo the owner owns.
13016
+ // Mints a bearer credential: owner mode and the god credential only.
12887
13017
  ...opts.credentialMinting ? [{
12888
13018
  name: "invite_agent",
12889
13019
  description: "Authorise a new agent to join one of your repos. This does NOT create the agent: it mints a single-use, expiring invite code and returns the exact command that redeems it. Two ways to finish. If you have a shell on the machine that agent will run on, create its working directory and run the command there. If you do not, give the user the command to paste. Either way the redeem is INTERACTIVE: `login` prompts for the agent name and specialization and does not yet read them from the invite, so expect to answer two questions and do not run it anywhere it cannot reach a terminal. The code is the only secret here and it is worthless once redeemed, which is why this is an invite rather than a token: a token would be long-lived and would sit in this conversation. Set workerType to the runtime that agent actually is, or it joins labelled 'human' and routing treats it wrongly.",
@@ -12907,8 +13037,8 @@ function buildProvisioningTools(client, opts = {}) {
12907
13037
  text: JSON.stringify({
12908
13038
  invite,
12909
13039
  code,
12910
- redeemCommand: `RELAI_CONFIG_DIR=~/.config/relai/${slot} pitboss login --api ${client.apiUrl} --invite ${code}${workerTypeFlag}`,
12911
- nextStep: "Run redeemCommand in the directory that agent will work from, or give it to the user to paste there. It will ask for an agent name and a specialization. The code is single-use and expires. One machine holds one identity per config dir, which is why the command sets RELAI_CONFIG_DIR \u2014 drop it and a machine that already has an agent will report success without redeeming anything."
13040
+ redeemCommand: `PITBOSS_CONFIG_DIR=~/.config/pitboss/${slot} RELAI_CONFIG_DIR=~/.config/pitboss/${slot} pitboss login --api ${client.apiUrl} --invite ${code}${workerTypeFlag}`,
13041
+ nextStep: "Run redeemCommand in the directory that agent will work from, or give it to the user to paste there. It will ask for an agent name and a specialization. The code is single-use and expires. One machine holds one identity per config dir, which is why the command sets the config dir \u2014 drop it and a machine that already has an agent will report success without redeeming anything."
12912
13042
  }, null, 2)
12913
13043
  }]
12914
13044
  };
@@ -13439,7 +13569,7 @@ var {
13439
13569
  OWNER_ID,
13440
13570
  TRANSPORT = "stdio"
13441
13571
  } = process.env;
13442
- var OWNER_MODE = Boolean(API_OWNER_TOKEN);
13572
+ var OWNER_MODE = Boolean(API_OWNER_TOKEN?.trim());
13443
13573
  if (OWNER_MODE) {
13444
13574
  if (!OWNER_ID || !OWNER_ID.startsWith("usr_")) {
13445
13575
  console.error("[relai-mcp] owner mode requires OWNER_ID (a 'usr_\u2026' id) alongside API_OWNER_TOKEN");
@@ -13449,7 +13579,7 @@ if (OWNER_MODE) {
13449
13579
  "[relai-mcp] owner mode: API_OWNER_TOKEN is a god-key credential \u2014 keep this server off the open internet (localhost bind + authenticating reverse proxy only)."
13450
13580
  );
13451
13581
  } else {
13452
- if (!API_SECRET) {
13582
+ if (!API_SECRET?.trim()) {
13453
13583
  console.error("[relai-mcp] API_SECRET is required");
13454
13584
  process.exit(1);
13455
13585
  }
@@ -13559,20 +13689,20 @@ async function main() {
13559
13689
  const transport = new StdioServerTransport();
13560
13690
  await server.connect(transport);
13561
13691
  } else if (TRANSPORT === "http") {
13562
- const { SSEServerTransport: SSEServerTransport2 } = await Promise.resolve().then(() => (init_sse(), sse_exports));
13563
13692
  const http = await import("node:http");
13693
+ const { createHttpRequestListener: createHttpRequestListener2 } = await Promise.resolve().then(() => (init_http_transport(), http_transport_exports));
13694
+ const { resolveHttpCredential: resolveHttpCredential2, isBlankCredential: isBlankCredential2 } = await Promise.resolve().then(() => (init_http_auth(), http_auth_exports));
13564
13695
  const port = Number(process.env.MCP_PORT ?? 3001);
13565
13696
  const host = process.env.MCP_HOST ?? "127.0.0.1";
13566
- const httpServer = http.createServer(async (req, res) => {
13567
- if (req.method === "GET" && req.url === "/sse") {
13568
- const transport = new SSEServerTransport2("/messages", res);
13569
- await server.connect(transport);
13570
- } else if (req.method === "POST" && req.url === "/messages") {
13571
- res.writeHead(200).end();
13572
- } else {
13573
- res.writeHead(404).end();
13574
- }
13575
- });
13697
+ const credential = resolveHttpCredential2(process.env.MCP_HTTP_TOKEN, OWNER_MODE, API_OWNER_TOKEN, API_SECRET);
13698
+ if (isBlankCredential2(credential)) {
13699
+ console.error(
13700
+ "[relai-mcp] the HTTP transport credential (MCP_HTTP_TOKEN, API_SECRET, or API_OWNER_TOKEN) is empty or whitespace \u2014 refusing to start with a credential that would lock out every client"
13701
+ );
13702
+ process.exit(1);
13703
+ }
13704
+ const listener = createHttpRequestListener2(server, credential);
13705
+ const httpServer = http.createServer(listener);
13576
13706
  httpServer.listen(port, host, () => {
13577
13707
  console.error(`[relai-mcp] HTTP/SSE transport listening on ${host}:${port}`);
13578
13708
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitboss/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Connect your coding agents to a pitboss instance",
6
6
  "keywords": [