@bastani/atomic 0.6.6-0 → 0.6.6

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.
@@ -10,7 +10,7 @@
10
10
  * atomic chat -a <agent> [native-args...]
11
11
  */
12
12
 
13
- import { join } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { mkdir, writeFile, rm } from "node:fs/promises";
16
16
  import { AGENT_CONFIG, type AgentKey } from "../../../services/config/index.ts";
@@ -19,10 +19,11 @@ import { getCopilotScmDisableFlags } from "../../../services/config/scm-sync.ts"
19
19
  import {
20
20
  resolveAdditionalInstructionsPath,
21
21
  } from "../../../services/config/additional-instructions.ts";
22
- import { dirname } from "node:path";
23
22
  import { ensureProjectSetup } from "../init/index.ts";
24
23
  import { COLORS } from "../../../theme/colors.ts";
25
- import { isCommandInstalled } from "../../../services/system/detect.ts";
24
+ import {
25
+ getCommandPath,
26
+ } from "../../../services/system/detect.ts";
26
27
  import { checkAgentAuth, printAuthError } from "../../../services/system/auth.ts";
27
28
  import {
28
29
  ensureAtomicGlobalAgentConfigs,
@@ -42,6 +43,24 @@ import {
42
43
  } from "../../../sdk/runtime/tmux.ts";
43
44
  import { spawnAttachedFooter } from "../../../sdk/runtime/attached-footer.ts";
44
45
  import { ensureTmuxInstalled } from "../../../lib/spawn.ts";
46
+ import {
47
+ buildLauncherEnv,
48
+ buildSpawnEnv,
49
+ buildTmuxEnv,
50
+ } from "../../../lib/terminal-env.ts";
51
+ import { atomicTempEnv } from "../../../lib/atomic-temp.ts";
52
+ import {
53
+ type CommandPathResolver,
54
+ resolveCopilotCliPath,
55
+ } from "../../../sdk/providers/copilot.ts";
56
+
57
+ export {
58
+ buildLauncherEnv,
59
+ buildSpawnEnv,
60
+ buildTmuxEnv,
61
+ TERMINAL_ENV_KEYS,
62
+ type TerminalEnvKey,
63
+ } from "../../../lib/terminal-env.ts";
45
64
 
46
65
  // ============================================================================
47
66
  // Types
@@ -118,6 +137,18 @@ export function getAdditionalInstructionsDir(
118
137
  return path ? dirname(path) : undefined;
119
138
  }
120
139
 
140
+ export function resolveChatCommand(
141
+ agentType: AgentType,
142
+ resolveCommandPath: CommandPathResolver = getCommandPath,
143
+ ): string | undefined {
144
+ if (agentType === "copilot") {
145
+ return resolveCopilotCliPath(resolveCommandPath);
146
+ }
147
+
148
+ const config = AGENT_CONFIG[agentType];
149
+ return resolveCommandPath(config.cmd) ?? undefined;
150
+ }
151
+
121
152
  function generateChatId(): string {
122
153
  return crypto.randomUUID().slice(0, 8);
123
154
  }
@@ -132,6 +163,28 @@ function escPwsh(s: string): string {
132
163
  return s.replace(/[`"$]/g, "`$&");
133
164
  }
134
165
 
166
+ const POSIX_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
167
+
168
+ function assertBashEnvKey(key: string): void {
169
+ if (!POSIX_ENV_KEY_RE.test(key)) {
170
+ throw new Error(
171
+ `Invalid Bash env key "${key}": must match /^[A-Za-z_][A-Za-z0-9_]*$/`,
172
+ );
173
+ }
174
+ }
175
+
176
+ function escPwshEnvKey(key: string): string {
177
+ return key.replace(/}/g, "`}");
178
+ }
179
+
180
+ async function removeLauncher(path: string): Promise<void> {
181
+ try {
182
+ await rm(path, { force: true });
183
+ } catch {
184
+ // Cleanup best effort; attach/fallback result should remain authoritative.
185
+ }
186
+ }
187
+
135
188
  /**
136
189
  * Build a launcher script that preserves cwd and properly quotes args.
137
190
  * This avoids shell-injection risks from passthrough args.
@@ -149,7 +202,7 @@ export function buildLauncherScript(
149
202
  // PowerShell: use array splatting for safe arg passing
150
203
  const argList = args.map((a) => `"${escPwsh(a)}"`).join(", ");
151
204
  const envLines = envEntries.map(
152
- ([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
205
+ ([key, value]) => `\${env:${escPwshEnvKey(key)}} = "${escPwsh(value)}"`,
153
206
  );
154
207
  const script = [
155
208
  `Set-Location "${escPwsh(projectRoot)}"`,
@@ -164,18 +217,19 @@ export function buildLauncherScript(
164
217
  return { script, ext: "ps1" };
165
218
  }
166
219
 
167
- // Bash: use proper quoting for each arg
168
- const quotedArgs = args
169
- .map((a) => `"${escBash(a)}"`)
170
- .join(" ");
171
- const envLines = envEntries.map(
172
- ([key, value]) => `export ${key}="${escBash(value)}"`,
173
- );
220
+ const quotedCommand = [
221
+ `"${escBash(cmd)}"`,
222
+ ...args.map((arg) => `"${escBash(arg)}"`),
223
+ ].join(" ");
224
+ const envLines = envEntries.map(([key, value]) => {
225
+ assertBashEnvKey(key);
226
+ return `export ${key}="${escBash(value)}"`;
227
+ });
174
228
  const script = [
175
229
  "#!/bin/bash",
176
230
  `cd "${escBash(projectRoot)}"`,
177
231
  ...envLines,
178
- `"${escBash(cmd)}" ${quotedArgs}`,
232
+ quotedCommand,
179
233
  "atomic_exit_code=$?",
180
234
  'exit "$atomic_exit_code"',
181
235
  ].join("\n");
@@ -206,8 +260,10 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
206
260
 
207
261
  const config = AGENT_CONFIG[agentType];
208
262
 
263
+ const executable = resolveChatCommand(agentType);
264
+
209
265
  // Check the agent CLI is installed
210
- if (!isCommandInstalled(config.cmd)) {
266
+ if (!executable) {
211
267
  console.error(
212
268
  `${COLORS.red}Error: '${config.cmd}' is not installed or not in PATH.${COLORS.reset}`
213
269
  );
@@ -236,12 +292,14 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
236
292
 
237
293
  // ── Build argv ──
238
294
  const args = await buildAgentArgs(agentType, passthroughArgs, projectRoot);
239
- const cmd = [config.cmd, ...args];
295
+ const cmd = [executable, ...args];
240
296
  const overrides = await getProviderOverrides(agentType, projectRoot);
297
+ const claudeTempEnv = agentType === "claude" ? atomicTempEnv() : {};
241
298
  // ATOMIC_AGENT must be baked into the launcher env so the agent CLI
242
299
  // and anything it spawns can read it from process start.
243
300
  const envVars: Record<string, string> = {
244
301
  ...config.env_vars,
302
+ ...claudeTempEnv,
245
303
  ...overrides.envVars,
246
304
  ATOMIC_AGENT: agentType,
247
305
  };
@@ -267,9 +325,13 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
267
325
  }
268
326
  }
269
327
 
328
+ const spawnEnv = buildSpawnEnv(envVars);
329
+ const launcherEnv = buildLauncherEnv(envVars);
330
+ const tmuxEnv = buildTmuxEnv(envVars);
331
+
270
332
  // ── No TTY: tmux attach requires a real terminal ──
271
333
  if (!process.stdin.isTTY) {
272
- return spawnDirect(cmd, projectRoot, envVars);
334
+ return spawnDirect(cmd, projectRoot, spawnEnv);
273
335
  }
274
336
 
275
337
  // ── Ensure tmux is available ──
@@ -283,7 +345,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
283
345
  }
284
346
  if (!isTmuxInstalled()) {
285
347
  // No tmux available — fall back to direct spawn
286
- return spawnDirect(cmd, projectRoot, envVars);
348
+ return spawnDirect(cmd, projectRoot, spawnEnv);
287
349
  }
288
350
  }
289
351
 
@@ -294,10 +356,10 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
294
356
  const sessionsDir = join(homedir(), ".atomic", "sessions", "chat");
295
357
  await mkdir(sessionsDir, { recursive: true });
296
358
  const { script, ext } = buildLauncherScript(
297
- config.cmd,
359
+ executable,
298
360
  args,
299
361
  projectRoot,
300
- envVars,
362
+ launcherEnv,
301
363
  );
302
364
  const launcherPath = join(sessionsDir, `${windowName}.${ext}`);
303
365
  await writeFile(launcherPath, script, { mode: 0o755 });
@@ -308,14 +370,14 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
308
370
 
309
371
  // ── Create session on the atomic socket and attach ──
310
372
  try {
311
- const paneId = createSession(windowName, shellCmd, undefined, projectRoot);
373
+ const paneId = createSession(windowName, shellCmd, undefined, projectRoot, tmuxEnv);
312
374
  spawnAttachedFooter(windowName, paneId, agentType);
313
375
  killSessionOnPaneExit(windowName, paneId);
314
376
 
315
377
  if (isInsideAtomicSocket()) {
316
378
  // Already on the atomic server — just switch to the new session.
317
379
  switchClient(windowName);
318
- try { await rm(launcherPath, { force: true }); } catch {}
380
+ await removeLauncher(launcherPath);
319
381
  return 0;
320
382
  }
321
383
 
@@ -323,30 +385,29 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
323
385
  // Inside a different tmux server — detach and replace the client
324
386
  // with an attach to the atomic socket (no nesting).
325
387
  detachAndAttachAtomic(windowName);
326
- try { await rm(launcherPath, { force: true }); } catch {}
388
+ await removeLauncher(launcherPath);
327
389
  return 0;
328
390
  }
329
391
 
330
392
  const attachProc = spawnMuxAttach(windowName);
331
393
  const exitCode = await attachProc.exited;
332
394
 
333
- // Clean up launcher
334
- try { await rm(launcherPath, { force: true }); } catch {}
395
+ await removeLauncher(launcherPath);
335
396
 
336
397
  // If tmux attach itself failed (e.g. lost TTY), clean up and fall back
337
398
  if (exitCode !== 0) {
338
399
  try { killSession(windowName); } catch {}
339
- return spawnDirect(cmd, projectRoot, envVars);
400
+ return spawnDirect(cmd, projectRoot, spawnEnv);
340
401
  }
341
402
 
342
403
  return exitCode;
343
404
  } catch (error) {
344
- try { await rm(launcherPath, { force: true }); } catch {}
405
+ await removeLauncher(launcherPath);
345
406
  const message = error instanceof Error ? error.message : String(error);
346
407
  console.error(
347
408
  `${COLORS.yellow}Warning: Failed to create tmux session (${message}). Falling back to direct spawn.${COLORS.reset}`
348
409
  );
349
- return spawnDirect(cmd, projectRoot, envVars);
410
+ return spawnDirect(cmd, projectRoot, spawnEnv);
350
411
  }
351
412
  }
352
413
 
@@ -357,12 +418,12 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
357
418
  async function spawnDirect(
358
419
  cmd: string[],
359
420
  projectRoot: string,
360
- envVars: Record<string, string> = {},
421
+ env: Record<string, string> = {},
361
422
  ): Promise<number> {
362
423
  const proc = Bun.spawn(cmd, {
363
424
  stdio: ["inherit", "inherit", "inherit"],
364
425
  cwd: projectRoot,
365
- env: { ...process.env, ...envVars },
426
+ env,
366
427
  });
367
428
 
368
429
  return await proc.exited;
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ atomicContentTempPath,
7
+ atomicTempDir,
8
+ atomicTempEnv,
9
+ atomicTempPath,
10
+ ensureAtomicTempDir,
11
+ withAtomicTempEnv,
12
+ } from "./atomic-temp.ts";
13
+
14
+ const createdDirs: string[] = [];
15
+
16
+ function makeTempRoot(): string {
17
+ const dir = mkdtempSync(join(tmpdir(), "atomic-temp-test-"));
18
+ createdDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const dir of createdDirs.splice(0)) {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ describe("atomic temp helpers", () => {
29
+ test("uses a per-user directory under ~/.atomic/tmp", () => {
30
+ expect(atomicTempDir("/home/alice")).toBe("/home/alice/.atomic/tmp");
31
+ });
32
+
33
+ test("creates the temp directory with owner-only permissions", () => {
34
+ const dir = join(makeTempRoot(), "owned-tmp");
35
+
36
+ expect(ensureAtomicTempDir(dir)).toBe(dir);
37
+ expect(statSync(dir).isDirectory()).toBe(true);
38
+ if (process.platform !== "win32") {
39
+ expect(statSync(dir).mode & 0o777).toBe(0o700);
40
+ }
41
+ });
42
+
43
+ test("builds all Node temp env aliases from the same directory", () => {
44
+ const dir = join(makeTempRoot(), "env-tmp");
45
+
46
+ expect(atomicTempEnv(dir)).toEqual({
47
+ TMPDIR: dir,
48
+ TMP: dir,
49
+ TEMP: dir,
50
+ });
51
+ });
52
+
53
+ test("builds random and content-addressed paths inside the Atomic temp dir", () => {
54
+ const dir = join(makeTempRoot(), "paths");
55
+
56
+ expect(atomicTempPath("prompt", ".txt", "abc", dir)).toBe(
57
+ join(dir, "prompt-abc.txt"),
58
+ );
59
+ expect(atomicContentTempPath("settings", ".json", "same", dir)).toBe(
60
+ atomicContentTempPath("settings", ".json", "same", dir),
61
+ );
62
+ expect(atomicContentTempPath("settings", ".json", "same", dir)).not.toBe(
63
+ atomicContentTempPath("settings", ".json", "different", dir),
64
+ );
65
+ });
66
+
67
+ test("scopes process temp env while an async operation runs", async () => {
68
+ const dir = join(makeTempRoot(), "scoped");
69
+ const before = {
70
+ TMPDIR: process.env.TMPDIR,
71
+ TMP: process.env.TMP,
72
+ TEMP: process.env.TEMP,
73
+ };
74
+
75
+ const seen = await withAtomicTempEnv(async () => ({
76
+ TMPDIR: process.env.TMPDIR,
77
+ TMP: process.env.TMP,
78
+ TEMP: process.env.TEMP,
79
+ }), dir);
80
+
81
+ expect(seen).toEqual({ TMPDIR: dir, TMP: dir, TEMP: dir });
82
+ expect(process.env.TMPDIR).toBe(before.TMPDIR);
83
+ expect(process.env.TMP).toBe(before.TMP);
84
+ expect(process.env.TEMP).toBe(before.TEMP);
85
+ });
86
+ });
@@ -0,0 +1,62 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const ATOMIC_TEMP_ENV_KEYS = ["TMPDIR", "TMP", "TEMP"] as const;
7
+
8
+ export function atomicTempDir(homeDir: string = homedir()): string {
9
+ return join(homeDir, ".atomic", "tmp");
10
+ }
11
+
12
+ export function ensureAtomicTempDir(dir: string = atomicTempDir()): string {
13
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
14
+ if (process.platform !== "win32") {
15
+ chmodSync(dir, 0o700);
16
+ }
17
+ return dir;
18
+ }
19
+
20
+ export function atomicTempEnv(dir: string = ensureAtomicTempDir()): Record<string, string> {
21
+ return Object.fromEntries(ATOMIC_TEMP_ENV_KEYS.map((key) => [key, dir]));
22
+ }
23
+
24
+ export function atomicTempPath(
25
+ prefix: string,
26
+ extension: string,
27
+ id: string = randomUUID(),
28
+ dir: string = ensureAtomicTempDir(),
29
+ ): string {
30
+ return join(dir, `${prefix}-${id}${extension}`);
31
+ }
32
+
33
+ export function atomicContentTempPath(
34
+ prefix: string,
35
+ extension: string,
36
+ content: string,
37
+ dir: string = ensureAtomicTempDir(),
38
+ ): string {
39
+ const id = createHash("sha256").update(content).digest("hex").slice(0, 16);
40
+ return atomicTempPath(prefix, extension, id, dir);
41
+ }
42
+
43
+ export async function withAtomicTempEnv<T>(
44
+ fn: () => Promise<T>,
45
+ dir: string = ensureAtomicTempDir(),
46
+ ): Promise<T> {
47
+ const previous = Object.fromEntries(
48
+ ATOMIC_TEMP_ENV_KEYS.map((key) => [key, process.env[key]]),
49
+ );
50
+ for (const key of ATOMIC_TEMP_ENV_KEYS) {
51
+ process.env[key] = dir;
52
+ }
53
+ try {
54
+ return await fn();
55
+ } finally {
56
+ for (const key of ATOMIC_TEMP_ENV_KEYS) {
57
+ const value = previous[key];
58
+ if (value === undefined) delete process.env[key];
59
+ else process.env[key] = value;
60
+ }
61
+ }
62
+ }