agentradar-cli 0.1.8 → 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/dist/setup.js CHANGED
@@ -1,8 +1,25 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { chmod, lstat, mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
2
- import { basename, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { homedir } from "node:os";
4
6
  import * as tls from "node:tls";
5
7
  const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
8
+ /** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
9
+ const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
10
+ /**
11
+ * R004 — the server's harness id, in the words `supervise-agent.sh` accepts.
12
+ *
13
+ * Two vocabularies, deliberately: the server stores `claude_code` (its internal id, the key of
14
+ * `HARNESS_PROFILES`, and what lands in `adapterConfig.harness`), while the supervisor's
15
+ * `AR_AGENT_HARNESS` takes `claude`. Translating here rather than widening either side keeps the
16
+ * refusal in one place — the supervisor dies by name on an unrecognised value, and so does this.
17
+ */
18
+ const SUPERVISOR_HARNESS = {
19
+ claude: "claude",
20
+ claude_code: "claude",
21
+ codex: "codex",
22
+ };
6
23
  async function postJson(fetchImpl, url, body) {
7
24
  return fetchImpl(url, {
8
25
  method: "POST",
@@ -10,9 +27,35 @@ async function postJson(fetchImpl, url, body) {
10
27
  body: JSON.stringify(body),
11
28
  });
12
29
  }
30
+ /**
31
+ * The server's own words for a refusal, when it sent any.
32
+ *
33
+ * 🔴 THE BODY WAS BEING THROWN AWAY ON THE FAILURE PATH, one function above the `console.error` that
34
+ * would have printed it. `POST /vendor/agent-setups` answers a configuration problem with
35
+ * `{ error: "vendor_setup_not_configured", message: "Vendor setup is unavailable until the public
36
+ * MCP and vendor portal URLs are configured." }` (`server/src/routes/vendor.ts`), and the vendor read
37
+ * `AgentRadar setup request failed (503)` — on the FIRST door they touch in the product.
38
+ *
39
+ * Reads `message` before `error` because this door sends both and only `message` is a sentence;
40
+ * elsewhere the servers' convention is a bare `{ error: <sentence> }`, so both are tried. A non-JSON
41
+ * body (an HTML error page from a proxy, an empty 502) throws in `json()` and falls back to the
42
+ * status line — which is why the parse is inside the try and not beside it.
43
+ */
44
+ async function failureMessage(response) {
45
+ const line = `AgentRadar setup request failed (${response.status})`;
46
+ try {
47
+ const body = await response.json();
48
+ const said = [body?.message, body?.error]
49
+ .find((value) => typeof value === "string" && value.trim().length > 0);
50
+ return said ? `${line}: ${said.trim()}` : line;
51
+ }
52
+ catch {
53
+ return line;
54
+ }
55
+ }
13
56
  async function responseJson(response) {
14
57
  if (!response.ok)
15
- throw new Error(`AgentRadar setup request failed (${response.status})`);
58
+ throw new Error(await failureMessage(response));
16
59
  return response.json();
17
60
  }
18
61
  /**
@@ -59,9 +102,17 @@ export function claudeConfigPath(home, env = process.env) {
59
102
  *
60
103
  * Returns the resolved project path so the caller can name it in the trust guidance.
61
104
  */
62
- async function installProjectMcp(configPath, cwd, installation, trust) {
63
- const endpoint = new URL(installation.mcpEndpoint);
64
- if (endpoint.protocol !== "https:" || !/^ar_[A-Za-z0-9_-]+$/.test(installation.bearer)) {
105
+ export function trustedMcpEndpoint(value) {
106
+ const endpoint = new URL(value);
107
+ const loopbackHttp = endpoint.protocol === "http:" && (endpoint.hostname === "localhost" ||
108
+ endpoint.hostname === "127.0.0.1" ||
109
+ endpoint.hostname === "[::1]" ||
110
+ endpoint.hostname.endsWith(".localhost"));
111
+ return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
112
+ }
113
+ async function installProjectMcp(configPath, cwd, installation, trust, channelRunnerId) {
114
+ const endpoint = trustedMcpEndpoint(installation.mcpEndpoint);
115
+ if (!endpoint || !BEARER_SHAPE.test(installation.bearer)) {
65
116
  throw new Error("AgentRadar returned an invalid setup payload");
66
117
  }
67
118
  const projectPath = await realpath(cwd);
@@ -89,7 +140,11 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
89
140
  const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
90
141
  ? project.mcpServers
91
142
  : {};
92
- mcpServers.agentradar = {
143
+ mcpServers.agentradar = channelRunnerId ? {
144
+ type: "stdio",
145
+ command: process.execPath,
146
+ args: [join(dirname(fileURLToPath(import.meta.url)), "index.js"), "channel", channelRunnerId],
147
+ } : {
93
148
  type: "http",
94
149
  url: endpoint.toString(),
95
150
  headers: { Authorization: `Bearer ${installation.bearer}` },
@@ -109,10 +164,10 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
109
164
  await chmod(configPath, 0o600);
110
165
  return projectPath;
111
166
  }
112
- function shellValue(value) {
167
+ export function shellValue(value) {
113
168
  return `'${value.replaceAll("'", "'\\''")}'`;
114
169
  }
115
- async function writePrivateFile(path, contents) {
170
+ export async function writePrivateFile(path, contents) {
116
171
  try {
117
172
  if ((await lstat(path)).isSymbolicLink())
118
173
  throw new Error(`Refusing to replace a symlinked ${path}`);
@@ -130,8 +185,8 @@ async function installRunner(home, projectPath, approved) {
130
185
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
131
186
  throw new Error("AgentRadar returned an invalid runner ID");
132
187
  }
133
- const endpoint = new URL(approved.installation.mcpEndpoint);
134
- if (endpoint.protocol !== "https:" || !/^ar_[A-Za-z0-9_-]+$/.test(approved.installation.bearer)) {
188
+ const endpoint = trustedMcpEndpoint(approved.installation.mcpEndpoint);
189
+ if (!endpoint || !BEARER_SHAPE.test(approved.installation.bearer)) {
135
190
  throw new Error("AgentRadar returned an invalid setup payload");
136
191
  }
137
192
  const agentDir = join(home, ".agentradar", "agents");
@@ -140,6 +195,10 @@ async function installRunner(home, projectPath, approved) {
140
195
  await mkdir(configDir, { recursive: true, mode: 0o700 });
141
196
  await chmod(agentDir, 0o700);
142
197
  await chmod(configDir, 0o700);
198
+ const declared = (approved.harness ?? "").trim().toLowerCase();
199
+ const harness = declared ? SUPERVISOR_HARNESS[declared] : undefined;
200
+ if (declared && !harness)
201
+ throw new Error(`AgentRadar returned a runtime this runner cannot start: ${JSON.stringify(declared)}`);
143
202
  const mcpConfig = join(configDir, "awp-only.json");
144
203
  const envPath = join(agentDir, `${approved.runnerId}.env`);
145
204
  await writePrivateFile(mcpConfig, `${JSON.stringify({
@@ -157,6 +216,10 @@ async function installRunner(home, projectPath, approved) {
157
216
  `AWP_MCP_URL=${shellValue(endpoint.toString())}`,
158
217
  `AWP_BEARER=${shellValue(approved.installation.bearer)}`,
159
218
  `MCP_CONFIG=${shellValue(mcpConfig)}`,
219
+ // Written only when the vendor declared a runtime. An absent variable is the supervisor's own
220
+ // default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
221
+ // every existing env file look as though a choice had been made.
222
+ ...(harness ? [`AR_AGENT_HARNESS=${shellValue(harness)}`] : []),
160
223
  "",
161
224
  ].join("\n"));
162
225
  return envPath;
@@ -175,7 +238,13 @@ export async function setupVendorAgent(deps = {}) {
175
238
  throw new Error("Run setup from the agent project folder");
176
239
  const apiBase = deps.apiUrl ?? process.env.AGENTRADAR_API_URL ?? DEFAULT_API_URL;
177
240
  const setupUrl = new URL("vendor/agent-setups", `${apiBase.replace(/\/+$/, "")}/`);
178
- const started = await responseJson(await postJson(fetchImpl, setupUrl, { name: agentName }));
241
+ const requestedHarness = (deps.harness ?? process.env.AGENTRADAR_AGENT_HARNESS ?? "").trim();
242
+ const started = await responseJson(await postJson(fetchImpl, setupUrl, {
243
+ name: agentName,
244
+ // Sent only when asked for. The server refuses a value it cannot run — by name, in its own words —
245
+ // so an unrecognised `--harness` fails at the first door rather than at `runner start`, hours later.
246
+ ...(requestedHarness ? { harness: requestedHarness } : {}),
247
+ }));
179
248
  if (!/^ars_[A-Za-z0-9_-]+$/.test(started.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(started.userCode)) {
180
249
  throw new Error("AgentRadar returned an invalid setup challenge");
181
250
  }
@@ -183,9 +252,31 @@ export async function setupVendorAgent(deps = {}) {
183
252
  log("Waiting for approval…");
184
253
  const tokenUrl = new URL("vendor/agent-setups/token", `${apiBase.replace(/\/+$/, "")}/`);
185
254
  const expiresAt = Date.now() + started.expiresIn * 1000;
255
+ // 🔴 A DROPPED CONNECTION MUST NOT DISCARD AN APPROVAL A HUMAN ALREADY GAVE. This poll used to be a
256
+ // bare `await` in the loop, so one blip — a laptop sleeping, wifi switching, a proxy hiccup — threw
257
+ // straight out of setup and the vendor had to re-run it and approve a SECOND code while the first sat
258
+ // pending server-side. Only a definitive answer from the server ends this loop now: approved, denied,
259
+ // or expired. A transport failure is retried, on a backoff that grows with consecutive failures.
260
+ //
261
+ // It cannot spin forever, and the bound is not new: `Date.now() < expiresAt` is the code's own
262
+ // ten-minute TTL, which the server sent as `expiresIn`. If that TTL ever grows enough that a retry
263
+ // loop could outlive the code's validity, this needs its own deadline as well.
264
+ let consecutiveFailures = 0;
186
265
  while (Date.now() < expiresAt) {
187
- await sleep(Math.max(1, started.interval) * 1000);
188
- const response = await postJson(fetchImpl, tokenUrl, { deviceCode: started.deviceCode });
266
+ await sleep(Math.max(1, started.interval) * 1000 * Math.min(consecutiveFailures + 1, 5));
267
+ let response;
268
+ try {
269
+ response = await postJson(fetchImpl, tokenUrl, { deviceCode: started.deviceCode });
270
+ }
271
+ catch {
272
+ consecutiveFailures += 1;
273
+ // Said out loud, and naming the HOST rather than the error: a silent retry is indistinguishable
274
+ // from a hang to the person watching, and `fetch` rejects with the two words "fetch failed",
275
+ // which name nothing. The host is the fact worth printing and we already hold it.
276
+ log(`Could not reach ${tokenUrl.host} — the code is still valid, retrying…`);
277
+ continue;
278
+ }
279
+ consecutiveFailures = 0;
189
280
  if (response.status === 202)
190
281
  continue;
191
282
  if (response.status === 410)
@@ -204,10 +295,151 @@ export async function setupVendorAgent(deps = {}) {
204
295
  ? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
205
296
  : `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just this MCP. Re-run with --trust for unattended onboarding.`);
206
297
  log(`Runner installed → ${runnerEnv}`);
298
+ if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
299
+ // Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
300
+ // think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
301
+ // `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
302
+ // a per-agent home the vendor supplies, and the supervisor fails closed without it.
303
+ log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares ONLY the AgentRadar MCP server; the runner refuses to start without it.`);
304
+ }
207
305
  log(`Start it with: agentradar runner start ${approved.runnerId}`);
208
306
  log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
209
307
  return;
210
308
  }
211
309
  throw new Error("AgentRadar setup code expired; run setup again");
212
310
  }
311
+ /**
312
+ * Mode A — the organisation's own agent, on its own compute, joining through an enrolment link.
313
+ *
314
+ * accept → wait for a human to approve → claim the key once → install the same MCP config and runner
315
+ * `setup` installs. The one-time claim secret is kept in a 0600 state file per link, so a re-run
316
+ * resumes the wait (or, once claimed, just starts the runner) instead of filing a second request.
317
+ * The key itself is written only into the two 0600 config files and is never printed.
318
+ */
319
+ export async function joinOrganisation(enrolmentUrl, deps = {}) {
320
+ const fetchImpl = deps.fetch ?? fetch;
321
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
322
+ const log = deps.log ?? console.log;
323
+ const cwd = deps.cwd ?? process.cwd();
324
+ const home = deps.home ?? homedir();
325
+ // Claude Code sets CLAUDECODE in its shell; a machine with both tools installed may export CODEX_HOME too.
326
+ const harness = deps.harness ?? (process.env.CLAUDECODE || !process.env.CODEX_HOME ? "claude" : "codex");
327
+ const startJoined = async (runnerId, firstPoll = false) => {
328
+ if (!firstPoll) {
329
+ log(`Connected. Runner: ${runnerId}. Now, in this same session, poll for your welcome and work: agentradar run ${runnerId} next_event`);
330
+ return 0;
331
+ }
332
+ // The first poll is the same call the agent makes next, so the welcome arrives without a second harness
333
+ // prompt and the agent's next bare poll acknowledges it. The key is already saved: a failed poll only says so.
334
+ log("Connected. Your welcome and first instructions:");
335
+ const { callToolOverCli } = await import("./tool-run.js");
336
+ await callToolOverCli(runnerId, "next_event", undefined, { home, fetch: fetchImpl, log })
337
+ .catch(() => log("Could not fetch them yet."));
338
+ log(`Runner: ${runnerId}. Do what the instructions above say. Then stay listening: make agentradar run ${runnerId} listen the last step of every turn, ${harness === "claude" ? "in the background (run_in_background), and end your turn; you are woken when it exits" : "in the foreground, and wait for it"}. It prints the next client message as soon as one arrives, or a note after about 9 minutes; answer what it printed, then run it again.`);
339
+ return 0;
340
+ };
341
+ let link;
342
+ try {
343
+ link = new URL(enrolmentUrl);
344
+ }
345
+ catch {
346
+ throw new Error("Pass the enrolment link your organisation gave you, e.g. https://…/invite/<token>");
347
+ }
348
+ const token = /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(link.pathname)?.[1];
349
+ if (!token || !trustedMcpEndpoint(link.origin)) {
350
+ throw new Error("That is not an AgentRadar enrolment link (expected https://…/invite/<token>)");
351
+ }
352
+ const api = (path) => new URL(`/api/${path}`, link.origin);
353
+ const mcpEndpoint = process.env.AWP_MCP_URL || api("mcp").toString();
354
+ const joinsDir = join(home, ".agentradar", "joins");
355
+ await mkdir(joinsDir, { recursive: true, mode: 0o700 });
356
+ const statePath = join(joinsDir, `${createHash("sha256").update(token).digest("hex")}.json`);
357
+ let state = null;
358
+ try {
359
+ state = JSON.parse(await readFile(statePath, "utf8"));
360
+ }
361
+ catch (error) {
362
+ if (error.code !== "ENOENT")
363
+ throw error;
364
+ }
365
+ if (state?.agentId) {
366
+ log("This enrolment link is already joined on this machine.");
367
+ const runnerId = `join-${state.requestId}`;
368
+ const env = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
369
+ const { envValue } = await import("./tool-run.js");
370
+ await installProjectMcp(claudeConfigPath(home), cwd, {
371
+ mcpEndpoint: envValue(env, "AWP_MCP_URL") ?? "", bearer: envValue(env, "AWP_BEARER") ?? "",
372
+ }, deps.trust === true, harness === "claude" ? runnerId : undefined);
373
+ return startJoined(runnerId);
374
+ }
375
+ if (!state) {
376
+ const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
377
+ if (!agentName)
378
+ throw new Error("Run join from the agent project folder");
379
+ // Codex has no env var for its model; its config's top-level `model` is the default a session starts on.
380
+ // A codex agent reporting "claude" is guessing (a gateway combo answers as Claude), so the config wins there too.
381
+ const codexModel = async () => !process.env.CODEX_HOME ? undefined : /^model\s*=\s*"([^"\n]+)"/m.exec((await readFile(join(process.env.CODEX_HOME, "config.toml"), "utf8").catch(() => "")).split(/^\[/m)[0])?.[1];
382
+ const model = (!deps.model || deps.model === "unknown" || /claude/i.test(deps.model)) && harness === "codex" ? (await codexModel()) ?? deps.model : deps.model;
383
+ const response = await postJson(fetchImpl, api(`invites/${encodeURIComponent(token)}/accept`), {
384
+ requestType: "agent", agentName, adapterType: "awp_remote",
385
+ agentDefaultsPayload: { harness, ...(model ? { model } : {}) },
386
+ });
387
+ if (!response.ok) {
388
+ throw new Error(response.status === 404
389
+ ? "This join link has expired, was already used, or was withdrawn. Ask your organisation for a new link."
390
+ : `This join link cannot be used. ${await failureMessage(response)}`);
391
+ }
392
+ const accepted = await response.json();
393
+ if (typeof accepted.id !== "string" || typeof accepted.claimSecret !== "string") {
394
+ throw new Error("AgentRadar returned an invalid join response");
395
+ }
396
+ state = { requestId: accepted.id, claimSecret: accepted.claimSecret, ...(typeof accepted.approvalUrl === "string" ? { approvalUrl: accepted.approvalUrl } : {}) };
397
+ await writePrivateFile(statePath, JSON.stringify(state));
398
+ }
399
+ log("Join request sent. Waiting for your organisation to approve it…");
400
+ if (state.approvalUrl)
401
+ log(`Approval link: ${state.approvalUrl}\nSend it to your own human once; they approve after signing in. Never send anything else from this folder.`);
402
+ const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
403
+ // Named off the join request, which exists before the key does, so the server can record it at claim.
404
+ const runnerId = `join-${state.requestId}`;
405
+ for (let poll = 1;; poll++) {
406
+ if (poll > (deps.polls ?? 9)) {
407
+ log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
408
+ return 0;
409
+ }
410
+ let response;
411
+ try {
412
+ response = await postJson(fetchImpl, claimUrl, { claimSecret: state.claimSecret, runnerId });
413
+ }
414
+ catch {
415
+ log(`Could not reach ${claimUrl.host}; retrying…`);
416
+ await sleep(10_000);
417
+ continue;
418
+ }
419
+ if (response.status === 201) {
420
+ const claimed = await response.json();
421
+ if (typeof claimed.token !== "string" || typeof claimed.agentId !== "string") {
422
+ throw new Error("AgentRadar returned an invalid key claim");
423
+ }
424
+ const installation = { mcpEndpoint, bearer: claimed.token };
425
+ const projectPath = await installProjectMcp(claudeConfigPath(home), cwd, installation, deps.trust === true, harness === "claude" ? runnerId : undefined);
426
+ const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId: claimed.agentId, runnerId, installation, harness });
427
+ await writePrivateFile(statePath, JSON.stringify({ ...state, agentId: claimed.agentId }));
428
+ log(`Approved. AgentRadar MCP installed for this project, runner installed → ${runnerEnv}`);
429
+ return startJoined(runnerId, true);
430
+ }
431
+ const said = await failureMessage(response);
432
+ if (response.status === 409 && said.includes("must be approved")) {
433
+ await sleep(10_000);
434
+ continue;
435
+ }
436
+ if (response.status === 409 && said.includes("rejected")) {
437
+ throw new Error("Your organisation declined this join request. Ask it for a new enrolment link.");
438
+ }
439
+ if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
440
+ throw new Error("This enrolment link's key was already claimed. A key is issued once and cannot be claimed again; ask your organisation for a new enrolment link.");
441
+ }
442
+ throw new Error(said);
443
+ }
444
+ }
213
445
  //# sourceMappingURL=setup.js.map
@@ -37,6 +37,8 @@ export async function installVendorSkill(deps = {}) {
37
37
  throw error;
38
38
  });
39
39
  }
40
+ // The runner keeps this skill current from AgentRadar; the marker says the folder is AgentRadar's to replace.
41
+ await writeFile(join(skillDir, ".agentradar-managed"), "Written by AgentRadar; replaced on every sync.\n");
40
42
  (deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
41
43
  }
42
44
  //# sourceMappingURL=skill-install.js.map
package/dist/sync.js ADDED
@@ -0,0 +1,89 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { homedir, platform } from "node:os";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ const run = promisify(execFile);
7
+ /**
8
+ * `agentradar sync [dir]` — fast-forward a git-scoped agent project dir to its remote branch.
9
+ *
10
+ * Safe by construction:
11
+ * - refuses when tracked files are locally MODIFIED (vendor edits are never clobbered);
12
+ * - `checkout -f -B <branch> <remote>/<branch>` rewrites tracked paths only, so untracked runtime
13
+ * state (sessions, .env, caches) survives;
14
+ * - uses the vendor's own git credentials — the CLI never carries any.
15
+ *
16
+ * Config (optional, via `git config`): `agentradar.syncRemote` (default `origin`),
17
+ * `agentradar.syncBranch` (default `main`).
18
+ */
19
+ export async function syncAgentDir(dir = process.cwd()) {
20
+ const git = (...args) => run("git", ["-C", dir, ...args]);
21
+ try {
22
+ await git("rev-parse", "--git-dir");
23
+ }
24
+ catch {
25
+ console.error(`Not a git repository: ${dir}`);
26
+ return 1;
27
+ }
28
+ const remote = (await git("config", "--get", "agentradar.syncRemote").catch(() => ({ stdout: "origin" }))).stdout.trim() || "origin";
29
+ const branch = (await git("config", "--get", "agentradar.syncBranch").catch(() => ({ stdout: "main" }))).stdout.trim() || "main";
30
+ const dirty = (await git("status", "--porcelain")).stdout
31
+ .split("\n")
32
+ .filter((l) => l && !l.startsWith("??"));
33
+ if (dirty.length > 0) {
34
+ console.error(`Refusing to sync: ${dirty.length} tracked file(s) locally modified in ${dir}. Commit, stash, or revert them first.`);
35
+ return 1;
36
+ }
37
+ await git("fetch", remote, branch);
38
+ const target = (await git("rev-parse", `${remote}/${branch}`)).stdout.trim();
39
+ const head = (await git("rev-parse", "HEAD").catch(() => ({ stdout: "" }))).stdout.trim();
40
+ if (head === target) {
41
+ console.log(`Up to date at ${target.slice(0, 8)}.`);
42
+ return 0;
43
+ }
44
+ await git("checkout", "-f", "-B", branch, `${remote}/${branch}`);
45
+ console.log(`Synced ${dir} to ${remote}/${branch} @ ${target.slice(0, 8)}.`);
46
+ return 0;
47
+ }
48
+ /**
49
+ * `agentradar sync install [dir] [--interval <seconds>]` — install a background timer that runs
50
+ * `agentradar sync <dir>` periodically. launchd LaunchAgent on macOS, systemd user timer on Linux.
51
+ */
52
+ export async function installSyncTimer(dir = process.cwd(), intervalSeconds = 600) {
53
+ const abs = dir;
54
+ const cli = process.argv[1] ?? "agentradar";
55
+ const node = process.execPath;
56
+ if (platform() === "darwin") {
57
+ const label = `com.agentradar.sync.${abs.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(-40)}`;
58
+ const plistDir = join(homedir(), "Library", "LaunchAgents");
59
+ await mkdir(plistDir, { recursive: true });
60
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
61
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62
+ <plist version="1.0"><dict>
63
+ <key>Label</key><string>${label}</string>
64
+ <key>ProgramArguments</key><array><string>${node}</string><string>${cli}</string><string>sync</string><string>${abs}</string></array>
65
+ <key>StartInterval</key><integer>${intervalSeconds}</integer>
66
+ <key>RunAtLoad</key><true/>
67
+ <key>StandardOutPath</key><string>${join(homedir(), "Library", "Logs", `${label}.log`)}</string>
68
+ <key>StandardErrorPath</key><string>${join(homedir(), "Library", "Logs", `${label}.log`)}</string>
69
+ </dict></plist>
70
+ `;
71
+ const plistPath = join(plistDir, `${label}.plist`);
72
+ await writeFile(plistPath, plist);
73
+ await run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${label}`]).catch(() => undefined);
74
+ await run("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, plistPath]);
75
+ console.log(`Installed launchd timer ${label} (every ${intervalSeconds}s) for ${abs}.`);
76
+ return 0;
77
+ }
78
+ // Linux: systemd user service + timer.
79
+ const unit = `agentradar-sync-${abs.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(-40)}`;
80
+ const unitDir = join(homedir(), ".config", "systemd", "user");
81
+ await mkdir(unitDir, { recursive: true });
82
+ await writeFile(join(unitDir, `${unit}.service`), `[Unit]\nDescription=AgentRadar sync ${abs}\n\n[Service]\nType=oneshot\nExecStart=${node} ${cli} sync ${abs}\n`);
83
+ await writeFile(join(unitDir, `${unit}.timer`), `[Unit]\nDescription=AgentRadar sync timer ${abs}\n\n[Timer]\nOnBootSec=60\nOnUnitActiveSec=${intervalSeconds}\n\n[Install]\nWantedBy=timers.target\n`);
84
+ await run("systemctl", ["--user", "daemon-reload"]);
85
+ await run("systemctl", ["--user", "enable", "--now", `${unit}.timer`]);
86
+ console.log(`Installed systemd user timer ${unit} (every ${intervalSeconds}s) for ${abs}.`);
87
+ return 0;
88
+ }
89
+ //# sourceMappingURL=sync.js.map