@kendoo.agentdesk/agentdesk 0.26.0 → 0.28.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/bin/agentdesk.mjs +35 -45
  3. package/cli/agents.mjs +4 -256
  4. package/cli/bootstrap.mjs +40 -59
  5. package/cli/config.mjs +29 -4
  6. package/cli/daemon.mjs +148 -66
  7. package/cli/dotenv.mjs +96 -13
  8. package/cli/engine/agents/index.mjs +151 -0
  9. package/cli/engine/claude-auth.mjs +72 -0
  10. package/cli/engine/env.mjs +56 -0
  11. package/cli/engine/events.mjs +214 -0
  12. package/cli/engine/hooks.mjs +112 -0
  13. package/cli/engine/phases/EXECUTION.md +45 -0
  14. package/cli/engine/phases/INTAKE.md +34 -0
  15. package/cli/engine/phases/PLAN.md +26 -0
  16. package/cli/engine/phases/REVIEW.md +21 -0
  17. package/cli/engine/phases/SOLO.md +115 -0
  18. package/cli/engine/phases/SUMMARY.md +23 -0
  19. package/cli/engine/prompts.mjs +181 -0
  20. package/cli/engine/query.mjs +63 -0
  21. package/cli/engine/schemas.mjs +180 -0
  22. package/cli/engine/session.mjs +285 -0
  23. package/cli/engine/spawn.mjs +83 -0
  24. package/cli/engine/tracker/github.md +19 -0
  25. package/cli/engine/tracker/jira.md +23 -0
  26. package/cli/engine/tracker/linear.md +24 -0
  27. package/cli/engine/verdict.mjs +83 -0
  28. package/cli/init.mjs +295 -149
  29. package/cli/login.mjs +52 -6
  30. package/cli/phase-loop.mjs +78 -0
  31. package/cli/proc.mjs +131 -0
  32. package/cli/project-key.mjs +56 -0
  33. package/cli/projects.mjs +41 -6
  34. package/cli/prompt.mjs +9 -503
  35. package/cli/prompts.mjs +20 -1
  36. package/cli/security-check.mjs +1 -1
  37. package/cli/session-isolation.mjs +65 -9
  38. package/cli/session-sandbox.mjs +13 -1
  39. package/cli/setup-helpers.mjs +83 -36
  40. package/cli/team.mjs +41 -34
  41. package/cli/tracker-check.mjs +12 -2
  42. package/cli/tracker-project.mjs +93 -0
  43. package/cli/update-check.mjs +62 -0
  44. package/package.json +12 -3
  45. package/cli/orchestrator.mjs +0 -461
  46. package/cli/stream-parser.mjs +0 -216
  47. package/prompts/phased.md +0 -549
  48. package/prompts/team.md +0 -505
package/cli/config.mjs CHANGED
@@ -58,8 +58,13 @@ async function fetchServerConfig(projectName, apiKey, serverUrl) {
58
58
  }
59
59
  }
60
60
 
61
+ // opts.readOnly — return the merged view without the two side effects
62
+ // (auto-heal push to the server, rewrite of .agentdesk.json). Setup wizards
63
+ // use this: they read the config at the top of the flow, before the user has
64
+ // confirmed anything, and must not mutate disk or server state on the way in.
65
+ // Runtime callers (`team`, `daemon`) leave it off so the cache stays fresh.
61
66
  export async function loadConfig(dir, opts = {}) {
62
- const { apiKey, serverUrl, projectName, silent = false } = opts;
67
+ const { apiKey, serverUrl, projectName, silent = false, readOnly = false } = opts;
63
68
  const configPath = join(dir, ".agentdesk.json");
64
69
 
65
70
  // Load local .agentdesk.json
@@ -101,7 +106,7 @@ export async function loadConfig(dir, opts = {}) {
101
106
  // no row at all or a partial row), push the merged config up so the
102
107
  // server catches up. Fire-and-forget — we already have the right
103
108
  // answer in `config` for the current caller.
104
- const shouldHeal = localConfig && apiKey && serverUrl && projectName && (
109
+ const shouldHeal = !readOnly && localConfig && apiKey && serverUrl && projectName && (
105
110
  !serverConfig || hasFieldsServerLacks(localConfig, serverConfig)
106
111
  );
107
112
  if (shouldHeal) {
@@ -113,7 +118,7 @@ export async function loadConfig(dir, opts = {}) {
113
118
  // actually fetched from the server — offline runs must not silently
114
119
  // mutate the user's local file. With mergeNonNull above, we're
115
120
  // guaranteed this write never strips existing local fields.
116
- if (serverConfig) {
121
+ if (serverConfig && !readOnly) {
117
122
  writeLocalConfig(configPath, config);
118
123
  }
119
124
 
@@ -137,14 +142,34 @@ function hasFieldsServerLacks(local, server) {
137
142
  return false;
138
143
  }
139
144
 
145
+ // The fields PUT /api/projects/:id/settings accepts. Anything else makes the
146
+ // server reject the whole request with 400 "Unknown fields", and the local
147
+ // merged config carries at least `projectKey` (the project's own id) — so
148
+ // every heal-sync and init push used to fail silently, and the server never
149
+ // learned the Jira project / Linear team the wizard had picked.
150
+ const SETTINGS_FIELDS = [
151
+ "tracker", "linear", "jira", "github", "team", "commands", "projectAgents",
152
+ "instructions", "screenshots", "phaseModels", "identityBadge",
153
+ ];
154
+
155
+ export function toSettingsPayload(config) {
156
+ const clean = stripCredentials(config || {});
157
+ const out = {};
158
+ for (const key of SETTINGS_FIELDS) {
159
+ if (clean[key] !== undefined) out[key] = clean[key];
160
+ }
161
+ return out;
162
+ }
163
+
140
164
  // Push a config object to the server. Caller is responsible for deciding when.
165
+ // The payload is reduced to the settings fields the server accepts.
141
166
  export async function pushConfig(apiKey, serverUrl, projectName, payload) {
142
167
  if (!apiKey || !serverUrl || !projectName) return { ok: false, error: "missing_auth" };
143
168
  try {
144
169
  const res = await fetch(`${serverUrl}/api/projects/${projectName}/settings`, {
145
170
  method: "PUT",
146
171
  headers: { "Content-Type": "application/json", "x-api-key": apiKey },
147
- body: JSON.stringify(payload),
172
+ body: JSON.stringify(toSettingsPayload(payload)),
148
173
  signal: AbortSignal.timeout(5000),
149
174
  });
150
175
  if (!res.ok) {
package/cli/daemon.mjs CHANGED
@@ -1,19 +1,17 @@
1
1
  // `agentdesk daemon` — local background daemon for UI-triggered sessions
2
2
 
3
- import { spawn } from "child_process";
4
3
  import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "fs";
5
4
  import { createInterface } from "readline";
6
5
  import { join } from "path";
7
- import { randomUUID } from "crypto";
8
6
  import WebSocket from "ws";
9
7
  import { detectProject } from "./detect.mjs";
10
8
  import { loadConfig } from "./config.mjs";
11
- import { getStoredApiKey } from "./login.mjs";
12
- import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
13
- import { buildPrompt } from "./prompt.mjs";
14
- import { createStreamParser } from "./stream-parser.mjs";
15
- import { runOrchestrator, runPhasedOrchestrator } from "./orchestrator.mjs";
16
- import { getRegisteredProjects, registerLocalProject } from "./projects.mjs";
9
+ import { getStoredApiKey, ensureAccountIdentity } from "./login.mjs";
10
+ import { resolveTeam } from "./agents.mjs";
11
+ import { runSession } from "./engine/session.mjs";
12
+ import { killTree } from "./proc.mjs";
13
+ import { loadDotEnv } from "./dotenv.mjs";
14
+ import { getRegisteredProjects, registerLocalProject, claimLocalProjects } from "./projects.mjs";
17
15
  import { buildTrackerUrl } from "./tracker-url.mjs";
18
16
  import { fileURLToPath } from "url";
19
17
  import { dirname } from "path";
@@ -60,22 +58,6 @@ class RingBuffer {
60
58
  get length() { return this.items.length; }
61
59
  }
62
60
 
63
- // --- Dot-env loader ---
64
-
65
- function loadDotEnv(dir) {
66
- const envPath = join(dir, ".env");
67
- if (!existsSync(envPath)) return {};
68
- const vars = {};
69
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
70
- const trimmed = line.trim();
71
- if (!trimmed || trimmed.startsWith("#")) continue;
72
- const eq = trimmed.indexOf("=");
73
- if (eq === -1) continue;
74
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
75
- }
76
- return vars;
77
- }
78
-
79
61
  // --- Metadata logger ---
80
62
 
81
63
  function logSessionMetadata(sessionId, metadata) {
@@ -150,46 +132,100 @@ export async function runDaemon() {
150
132
  process.exit(1);
151
133
  }
152
134
 
153
- // 2. Load registered projects local registry first, then server fallback
135
+ // 2. Load registered projects, scoped to the logged-in account (AD-64).
136
+ // The local registry is machine-global — it accumulates projects from
137
+ // every account ever used on this machine — so the server's per-account
138
+ // project list is the authority on what this account may see.
154
139
  const agentdeskServer = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
155
- let allProjects = getRegisteredProjects();
140
+ const creds = await ensureAccountIdentity();
141
+ const accountId = creds?.accountId || null;
156
142
 
157
- // Fallback: fetch from server if local registry is empty (pre-0.7.0 projects)
158
- if (allProjects.length === 0) {
159
- try {
160
- const res = await fetch(`${agentdeskServer}/api/projects`, {
161
- headers: { "x-api-key": apiKey },
162
- signal: AbortSignal.timeout(5000),
163
- });
164
- if (res.ok) {
165
- const serverProjects = await res.json();
166
- if (Array.isArray(serverProjects) && serverProjects.length > 0) {
167
- console.log(` ${dim}Syncing ${serverProjects.length} project(s) from server...${reset}`);
168
- for (const sp of serverProjects) {
143
+ let serverIds = null;
144
+ try {
145
+ const res = await fetch(`${agentdeskServer}/api/projects`, {
146
+ headers: { "x-api-key": apiKey },
147
+ signal: AbortSignal.timeout(5000),
148
+ });
149
+ if (res.ok) {
150
+ const serverProjects = await res.json();
151
+ if (Array.isArray(serverProjects)) {
152
+ serverIds = new Set(serverProjects.map(sp => sp.id || sp.name));
153
+
154
+ // Untagged local entries with no server row — either they predate
155
+ // server-side registration or were initialized under another login.
156
+ // Offer them to the server: it accepts free ids and silently no-ops
157
+ // ids owned by another account (AD-23), so re-fetching the list
158
+ // tells us which ones are actually ours.
159
+ const unclaimed = getRegisteredProjects().filter(p =>
160
+ !p.accountId && !serverIds.has(p.id) &&
161
+ existsSync(p.path) && existsSync(join(p.path, ".agentdesk.json")));
162
+ if (unclaimed.length > 0) {
163
+ for (const p of unclaimed) {
164
+ try {
165
+ await fetch(`${agentdeskServer}/api/projects`, {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey },
168
+ body: JSON.stringify({ id: p.id, name: p.name, path: p.path }),
169
+ signal: AbortSignal.timeout(5000),
170
+ });
171
+ } catch {}
172
+ }
173
+ try {
174
+ const recheck = await fetch(`${agentdeskServer}/api/projects`, {
175
+ headers: { "x-api-key": apiKey },
176
+ signal: AbortSignal.timeout(5000),
177
+ });
178
+ if (recheck.ok) {
179
+ const confirmed = await recheck.json();
180
+ if (Array.isArray(confirmed)) {
181
+ serverIds = new Set(confirmed.map(sp => sp.id || sp.name));
182
+ }
183
+ }
184
+ } catch {}
185
+ for (const p of unclaimed) {
186
+ if (!serverIds.has(p.id)) {
187
+ console.log(` ${yellow}Skipping ${p.name}${reset} ${dim}— registered to a different AgentDesk account${reset}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ // Claim legacy untagged local entries the server confirmed as ours
193
+ claimLocalProjects([...serverIds], accountId);
194
+
195
+ // Sync server projects missing from the local registry (pre-0.7.0
196
+ // setups, or projects initialized on another machine)
197
+ const localIds = new Set(getRegisteredProjects().map(p => p.id));
198
+ const missing = serverProjects.filter(sp => !localIds.has(sp.id || sp.name));
199
+ if (missing.length > 0) {
200
+ console.log(` ${dim}Syncing ${missing.length} project(s) from server...${reset}`);
201
+ for (const sp of missing) {
169
202
  const projectName = sp.id || sp.name;
170
203
  // If server has a valid path, use it
171
204
  if (sp.path && existsSync(sp.path)) {
172
- registerLocalProject(projectName, sp.name, sp.path);
205
+ registerLocalProject(projectName, sp.name, sp.path, accountId);
173
206
  continue;
174
207
  }
175
208
  // Server has empty path — try to find the project locally
176
209
  const found = findProjectLocally(projectName);
177
210
  if (found) {
178
211
  console.log(` ${dim}Found ${projectName} at ${found}${reset}`);
179
- registerLocalProject(projectName, sp.name, found);
212
+ registerLocalProject(projectName, sp.name, found, accountId);
180
213
  } else {
181
214
  console.log(` ${yellow}Could not find ${projectName} locally.${reset} Run ${cyan}agentdesk init${reset} in its directory.`);
182
215
  }
183
216
  }
184
- allProjects = getRegisteredProjects();
185
217
  }
186
218
  }
187
- } catch {
188
- // Server not reachable — continue with local only
189
219
  }
220
+ } catch {
221
+ // Server not reachable — fall back to account-tagged local entries
190
222
  }
191
223
 
192
- const projects = allProjects.filter(p => {
224
+ const projects = getRegisteredProjects(accountId).filter(p => {
225
+ // Server reachable → it is authoritative: hide entries it doesn't own,
226
+ // even legacy untagged ones (they may belong to another account).
227
+ // Offline → tagged + untagged entries from getRegisteredProjects().
228
+ if (serverIds && !serverIds.has(p.id) && !(accountId && p.accountId === accountId)) return false;
193
229
  if (!existsSync(p.path)) return false;
194
230
  if (!existsSync(join(p.path, ".agentdesk.json"))) return false;
195
231
  return true;
@@ -350,7 +386,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
350
386
 
351
387
  // 4. Session handling
352
388
 
353
- async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, phased, screenshots: screenshotsOverride }) {
389
+ async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride }) {
354
390
  // Validate project against local allowlist
355
391
  const project = projects.find(p => p.id === projectId);
356
392
  if (!project) {
@@ -381,7 +417,16 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
381
417
  return;
382
418
  }
383
419
  // Claim the slot immediately (before any await) to prevent race conditions
384
- activeSession = { sessionId, projectId, child: null, startedAt: Date.now(), filePathsTouched: new Set() };
420
+ activeSession = {
421
+ sessionId, projectId,
422
+ child: null,
423
+ // Cancelling must stop the whole phase pipeline, not just the child that
424
+ // happens to be running: killing one phase's child would otherwise let
425
+ // the loop advance and spawn the next phase.
426
+ abort: new AbortController(),
427
+ startedAt: Date.now(),
428
+ filePathsTouched: new Set(),
429
+ };
385
430
 
386
431
  console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
387
432
  console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
@@ -406,25 +451,31 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
406
451
  // Build task link (only when a real tracker task ID was provided)
407
452
  const taskLink = remoteTaskId ? buildTrackerUrl({ tracker, config, taskId: remoteTaskId }) : null;
408
453
 
409
- // Resolve team
410
454
  const team = resolveTeam(config);
411
- const teamSections = generateTeamPrompt(team, { tracker, config });
412
455
 
413
456
  const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
414
457
 
415
- // Run orchestrator
416
- const orchestrate = phased ? runPhasedOrchestrator : runOrchestrator;
417
- const result = await orchestrate({
458
+ // Run the engine (real subagents, one query per phase). The server may
459
+ // still send `phased`; every session is phased now.
460
+ const sessionAbort = activeSession.abort;
461
+ const result = await runSession({
418
462
  taskId, taskLink,
419
463
  description: prompt || "",
420
464
  createTask: !remoteTaskId && !!prompt && !!tracker,
421
465
  tracker, config,
422
- project: detected, team, teamSections,
466
+ project: detected, team,
423
467
  sessionUrl,
424
468
  sessionId,
425
469
  cwd: project.path,
426
470
  apiKey,
427
471
  serverUrl: agentdeskServer,
472
+ abortSignal: sessionAbort.signal,
473
+ // Hand the live child up so cancel/shutdown can actually kill it.
474
+ // This was never wired: activeSession.child stayed null forever, so
475
+ // every cancel reported "stopped" while Claude kept writing to the repo.
476
+ onChild: (child) => {
477
+ if (activeSession?.sessionId === sessionId) activeSession.child = child;
478
+ },
428
479
  onEvent: (() => {
429
480
  // Stagger agent messages for real-time feel, flush on non-message events
430
481
  const MSG_STAGGER_MS = 400;
@@ -450,6 +501,9 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
450
501
 
451
502
  return (event) => {
452
503
  if (!activeSession || activeSession.sessionId !== sessionId) return;
504
+ // A cancelled session already sent its terminal event; the
505
+ // orchestrator's own unwind must not send a second one.
506
+ if (activeSession.cancelled) return;
453
507
 
454
508
  if (event.type === "agent:message") {
455
509
  msgQueue.push(event);
@@ -474,12 +528,21 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
474
528
  })(),
475
529
  });
476
530
 
477
- console.log(` ${green}Session complete${reset} ${dim}${sessionId}${reset} (${result.duration}, ${result.steps} steps)`);
531
+ const outcome = result.status || (result.handoff ? "handoff" : "complete");
532
+ const clean = outcome === "complete";
533
+ console.log(
534
+ ` ${clean ? green : yellow}Session ${outcome}${reset} ${dim}${sessionId}${reset} (${result.duration}, ${result.steps} steps)`
535
+ );
478
536
 
479
537
  logSessionMetadata(sessionId, {
480
538
  sessionId, projectId,
481
539
  startedAt, endedAt: Date.now(),
482
- duration: result.duration, exitCode: 0, steps: result.steps,
540
+ duration: result.duration,
541
+ // Was hardcoded to 0, so the metadata log claimed every session
542
+ // exited cleanly regardless of what actually happened.
543
+ exitCode: clean ? 0 : 1,
544
+ status: outcome,
545
+ steps: result.steps,
483
546
  filePathsTouched: [...filePathsTouched],
484
547
  });
485
548
 
@@ -493,24 +556,43 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
493
556
  }
494
557
  }
495
558
 
496
- function killChild(child) {
497
- if (!child) return;
498
- try { child.kill("SIGTERM"); } catch {}
499
- // Force kill if SIGTERM doesn't work within 5 seconds
500
- setTimeout(() => {
501
- try { if (!child.killed) child.kill("SIGKILL"); } catch {}
502
- }, 5000);
559
+ // Tear down a session's work: signal the orchestrator loop to stop enqueuing
560
+ // phases, then kill the running child's whole process group. Both halves are
561
+ // required — killing the child alone just makes the loop start the next phase.
562
+ function stopSessionWork(session) {
563
+ if (!session) return;
564
+ try { session.abort?.abort(); } catch {}
565
+ killTree(session.child);
503
566
  }
504
567
 
505
568
  function handleCancelSession({ sessionId }) {
506
569
  if (activeSession?.sessionId === sessionId) {
507
570
  console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
508
571
  const duration = `${((Date.now() - activeSession.startedAt) / 1000).toFixed(1)}s`;
509
- killChild(activeSession.child);
510
- // Clear active session BEFORE sending session:end this prevents
511
- // the orchestrator's onEvent callback from sending a duplicate
512
- activeSession = null;
572
+
573
+ // Mark rather than clear. Nulling activeSession here would free the
574
+ // concurrency slot immediately while the child is still inside its
575
+ // SIGTERM grace period — letting a second session start against the same
576
+ // working tree. The slot is released where it always was: after the
577
+ // orchestrator await unwinds, which cancellation now guarantees.
578
+ activeSession.cancelled = true;
579
+ stopSessionWork(activeSession);
580
+
581
+ // The `cancelled` flag also suppresses the orchestrator's own
582
+ // session:end, so this stays the single terminal event for the session.
513
583
  sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
584
+
585
+ // Safety valve: if the child somehow never dies, don't wedge the daemon
586
+ // into rejecting every future session.
587
+ const wedged = activeSession;
588
+ const timer = setTimeout(() => {
589
+ if (activeSession === wedged) {
590
+ console.log(` ${red}Cancelled session did not exit — releasing slot${reset}`);
591
+ killTree(wedged.child, { graceMs: 0 });
592
+ activeSession = null;
593
+ }
594
+ }, 30000);
595
+ timer.unref?.();
514
596
  }
515
597
  }
516
598
 
@@ -522,7 +604,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
522
604
  clearTimeout(reconnectTimer);
523
605
 
524
606
  if (activeSession) {
525
- killChild(activeSession.child);
607
+ stopSessionWork(activeSession);
526
608
  }
527
609
 
528
610
  send({ type: "daemon:disconnect" });
package/cli/dotenv.mjs CHANGED
@@ -1,22 +1,105 @@
1
- // Minimal .env reader used by orchestrator + preflight. Does not interpret
2
- // quotes or escapes — matches the pre-existing behavior of the duplicated
3
- // loaders in cli/team.mjs, cli/init.mjs, and cli/daemon.mjs (which we are
4
- // NOT consolidating in this PR out of scope).
1
+ // The one .env reader/writer for the CLI.
2
+ //
3
+ // There used to be five copies of the loader (here, setup-helpers, bootstrap,
4
+ // daemon, team) and two of the writer. None stripped quotes, so a perfectly
5
+ // conventional line like
6
+ //
7
+ // GITHUB_TOKEN="ghp_abc123"
8
+ //
9
+ // produced a token with literal quote characters in it and a baffling 401 from
10
+ // GitHub. A fix had to be applied five times to land. Every caller now imports
11
+ // from here.
12
+ //
13
+ // Deliberately minimal: quotes are stripped, `export KEY=…` is tolerated,
14
+ // comments and blanks are skipped. Escape sequences and `${VAR}` interpolation
15
+ // are NOT interpreted — a token is an opaque string and must round-trip as-is.
5
16
 
6
- import { existsSync, readFileSync } from "fs";
17
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "fs";
7
18
  import { join } from "path";
8
19
 
20
+ export function parseDotEnv(text) {
21
+ const vars = {};
22
+ for (const rawLine of String(text ?? "").split(/\r?\n/)) {
23
+ let line = rawLine.trim();
24
+ if (!line || line.startsWith("#")) continue;
25
+ if (line.startsWith("export ")) line = line.slice(7).trim();
26
+
27
+ const eq = line.indexOf("=");
28
+ if (eq === -1) continue;
29
+
30
+ const key = line.slice(0, eq).trim();
31
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
32
+
33
+ let value = line.slice(eq + 1).trim();
34
+ const quote = value[0];
35
+ if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ vars[key] = value;
39
+ }
40
+ return vars;
41
+ }
42
+
9
43
  export function loadDotEnv(dir) {
10
44
  if (!dir) return {};
11
45
  const envPath = join(dir, ".env");
12
46
  if (!existsSync(envPath)) return {};
13
- const vars = {};
14
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
15
- const trimmed = line.trim();
16
- if (!trimmed || trimmed.startsWith("#")) continue;
17
- const eq = trimmed.indexOf("=");
18
- if (eq === -1) continue;
19
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
47
+ try {
48
+ return parseDotEnv(readFileSync(envPath, "utf-8"));
49
+ } catch {
50
+ return {};
20
51
  }
21
- return vars;
52
+ }
53
+
54
+ function escapeRegex(s) {
55
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ }
57
+
58
+ // Upsert KEY=value in <dir>/.env, preserving every other line verbatim.
59
+ //
60
+ // The file ends up mode 0600 whether it was just created or already existed —
61
+ // `writeFileSync`'s `mode` option only applies on creation, so an existing
62
+ // world-readable .env would otherwise stay world-readable after we drop a
63
+ // GitHub token into it.
64
+ export function saveEnvVar(dir, key, value) {
65
+ const envPath = join(dir, ".env");
66
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
67
+ const re = new RegExp(`^(?:export\\s+)?${escapeRegex(key)}=.*$`, "m");
68
+ const entry = `${key}=${value}`;
69
+ if (re.test(content)) {
70
+ content = content.replace(re, entry);
71
+ } else {
72
+ content += `${content && !content.endsWith("\n") ? "\n" : ""}${entry}\n`;
73
+ }
74
+ writeFileSync(envPath, content, { mode: 0o600 });
75
+ try { chmodSync(envPath, 0o600); } catch {}
76
+ return envPath;
77
+ }
78
+
79
+ // Make sure each of `entries` is listed in <dir>/.gitignore. Returns the
80
+ // entries that had to be added (empty when everything was already covered).
81
+ //
82
+ // Matching is deliberately loose about the forms people actually write —
83
+ // `.env`, `/.env`, `.agentdesk`, `.agentdesk/` — and deliberately NOT clever
84
+ // about globs: `.env*` is not treated as covering `.env`, because a rule we
85
+ // can't reason about is a rule we shouldn't rely on for a credential file.
86
+ export function ensureGitignored(dir, entries) {
87
+ const path = join(dir, ".gitignore");
88
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
89
+ const present = new Set(
90
+ existing.split(/\r?\n/).map(l => l.trim().replace(/^\//, "").replace(/\/$/, "")).filter(Boolean)
91
+ );
92
+
93
+ const added = [];
94
+ for (const entry of entries) {
95
+ const norm = entry.replace(/^\//, "").replace(/\/$/, "");
96
+ if (present.has(norm)) continue;
97
+ added.push(entry);
98
+ present.add(norm);
99
+ }
100
+ if (added.length === 0) return added;
101
+
102
+ const nl = existing && !existing.endsWith("\n") ? "\n" : "";
103
+ writeFileSync(path, `${existing}${nl}${added.join("\n")}\n`);
104
+ return added;
22
105
  }