@bastani/atomic 0.6.6-0 → 0.6.6-1

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,21 @@ 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 { resolveCopilotCliPath } from "../../../sdk/providers/copilot.ts";
53
+
54
+ export {
55
+ buildLauncherEnv,
56
+ buildSpawnEnv,
57
+ buildTmuxEnv,
58
+ TERMINAL_ENV_KEYS,
59
+ type TerminalEnvKey,
60
+ } from "../../../lib/terminal-env.ts";
45
61
 
46
62
  // ============================================================================
47
63
  // Types
@@ -118,6 +134,15 @@ export function getAdditionalInstructionsDir(
118
134
  return path ? dirname(path) : undefined;
119
135
  }
120
136
 
137
+ export function resolveChatCommand(agentType: AgentType): string | undefined {
138
+ if (agentType === "copilot") {
139
+ return resolveCopilotCliPath();
140
+ }
141
+
142
+ const config = AGENT_CONFIG[agentType];
143
+ return getCommandPath(config.cmd) ?? undefined;
144
+ }
145
+
121
146
  function generateChatId(): string {
122
147
  return crypto.randomUUID().slice(0, 8);
123
148
  }
@@ -132,6 +157,28 @@ function escPwsh(s: string): string {
132
157
  return s.replace(/[`"$]/g, "`$&");
133
158
  }
134
159
 
160
+ const POSIX_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
161
+
162
+ function assertBashEnvKey(key: string): void {
163
+ if (!POSIX_ENV_KEY_RE.test(key)) {
164
+ throw new Error(
165
+ `Invalid Bash env key "${key}": must match /^[A-Za-z_][A-Za-z0-9_]*$/`,
166
+ );
167
+ }
168
+ }
169
+
170
+ function escPwshEnvKey(key: string): string {
171
+ return key.replace(/}/g, "`}");
172
+ }
173
+
174
+ async function removeLauncher(path: string): Promise<void> {
175
+ try {
176
+ await rm(path, { force: true });
177
+ } catch {
178
+ // Cleanup best effort; attach/fallback result should remain authoritative.
179
+ }
180
+ }
181
+
135
182
  /**
136
183
  * Build a launcher script that preserves cwd and properly quotes args.
137
184
  * This avoids shell-injection risks from passthrough args.
@@ -149,7 +196,7 @@ export function buildLauncherScript(
149
196
  // PowerShell: use array splatting for safe arg passing
150
197
  const argList = args.map((a) => `"${escPwsh(a)}"`).join(", ");
151
198
  const envLines = envEntries.map(
152
- ([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
199
+ ([key, value]) => `\${env:${escPwshEnvKey(key)}} = "${escPwsh(value)}"`,
153
200
  );
154
201
  const script = [
155
202
  `Set-Location "${escPwsh(projectRoot)}"`,
@@ -164,18 +211,19 @@ export function buildLauncherScript(
164
211
  return { script, ext: "ps1" };
165
212
  }
166
213
 
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
- );
214
+ const quotedCommand = [
215
+ `"${escBash(cmd)}"`,
216
+ ...args.map((arg) => `"${escBash(arg)}"`),
217
+ ].join(" ");
218
+ const envLines = envEntries.map(([key, value]) => {
219
+ assertBashEnvKey(key);
220
+ return `export ${key}="${escBash(value)}"`;
221
+ });
174
222
  const script = [
175
223
  "#!/bin/bash",
176
224
  `cd "${escBash(projectRoot)}"`,
177
225
  ...envLines,
178
- `"${escBash(cmd)}" ${quotedArgs}`,
226
+ quotedCommand,
179
227
  "atomic_exit_code=$?",
180
228
  'exit "$atomic_exit_code"',
181
229
  ].join("\n");
@@ -206,8 +254,10 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
206
254
 
207
255
  const config = AGENT_CONFIG[agentType];
208
256
 
257
+ const executable = resolveChatCommand(agentType);
258
+
209
259
  // Check the agent CLI is installed
210
- if (!isCommandInstalled(config.cmd)) {
260
+ if (!executable) {
211
261
  console.error(
212
262
  `${COLORS.red}Error: '${config.cmd}' is not installed or not in PATH.${COLORS.reset}`
213
263
  );
@@ -236,12 +286,14 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
236
286
 
237
287
  // ── Build argv ──
238
288
  const args = await buildAgentArgs(agentType, passthroughArgs, projectRoot);
239
- const cmd = [config.cmd, ...args];
289
+ const cmd = [executable, ...args];
240
290
  const overrides = await getProviderOverrides(agentType, projectRoot);
291
+ const claudeTempEnv = agentType === "claude" ? atomicTempEnv() : {};
241
292
  // ATOMIC_AGENT must be baked into the launcher env so the agent CLI
242
293
  // and anything it spawns can read it from process start.
243
294
  const envVars: Record<string, string> = {
244
295
  ...config.env_vars,
296
+ ...claudeTempEnv,
245
297
  ...overrides.envVars,
246
298
  ATOMIC_AGENT: agentType,
247
299
  };
@@ -267,9 +319,13 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
267
319
  }
268
320
  }
269
321
 
322
+ const spawnEnv = buildSpawnEnv(envVars);
323
+ const launcherEnv = buildLauncherEnv(envVars);
324
+ const tmuxEnv = buildTmuxEnv(envVars);
325
+
270
326
  // ── No TTY: tmux attach requires a real terminal ──
271
327
  if (!process.stdin.isTTY) {
272
- return spawnDirect(cmd, projectRoot, envVars);
328
+ return spawnDirect(cmd, projectRoot, spawnEnv);
273
329
  }
274
330
 
275
331
  // ── Ensure tmux is available ──
@@ -283,7 +339,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
283
339
  }
284
340
  if (!isTmuxInstalled()) {
285
341
  // No tmux available — fall back to direct spawn
286
- return spawnDirect(cmd, projectRoot, envVars);
342
+ return spawnDirect(cmd, projectRoot, spawnEnv);
287
343
  }
288
344
  }
289
345
 
@@ -294,10 +350,10 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
294
350
  const sessionsDir = join(homedir(), ".atomic", "sessions", "chat");
295
351
  await mkdir(sessionsDir, { recursive: true });
296
352
  const { script, ext } = buildLauncherScript(
297
- config.cmd,
353
+ executable,
298
354
  args,
299
355
  projectRoot,
300
- envVars,
356
+ launcherEnv,
301
357
  );
302
358
  const launcherPath = join(sessionsDir, `${windowName}.${ext}`);
303
359
  await writeFile(launcherPath, script, { mode: 0o755 });
@@ -308,14 +364,14 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
308
364
 
309
365
  // ── Create session on the atomic socket and attach ──
310
366
  try {
311
- const paneId = createSession(windowName, shellCmd, undefined, projectRoot);
367
+ const paneId = createSession(windowName, shellCmd, undefined, projectRoot, tmuxEnv);
312
368
  spawnAttachedFooter(windowName, paneId, agentType);
313
369
  killSessionOnPaneExit(windowName, paneId);
314
370
 
315
371
  if (isInsideAtomicSocket()) {
316
372
  // Already on the atomic server — just switch to the new session.
317
373
  switchClient(windowName);
318
- try { await rm(launcherPath, { force: true }); } catch {}
374
+ await removeLauncher(launcherPath);
319
375
  return 0;
320
376
  }
321
377
 
@@ -323,30 +379,29 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
323
379
  // Inside a different tmux server — detach and replace the client
324
380
  // with an attach to the atomic socket (no nesting).
325
381
  detachAndAttachAtomic(windowName);
326
- try { await rm(launcherPath, { force: true }); } catch {}
382
+ await removeLauncher(launcherPath);
327
383
  return 0;
328
384
  }
329
385
 
330
386
  const attachProc = spawnMuxAttach(windowName);
331
387
  const exitCode = await attachProc.exited;
332
388
 
333
- // Clean up launcher
334
- try { await rm(launcherPath, { force: true }); } catch {}
389
+ await removeLauncher(launcherPath);
335
390
 
336
391
  // If tmux attach itself failed (e.g. lost TTY), clean up and fall back
337
392
  if (exitCode !== 0) {
338
393
  try { killSession(windowName); } catch {}
339
- return spawnDirect(cmd, projectRoot, envVars);
394
+ return spawnDirect(cmd, projectRoot, spawnEnv);
340
395
  }
341
396
 
342
397
  return exitCode;
343
398
  } catch (error) {
344
- try { await rm(launcherPath, { force: true }); } catch {}
399
+ await removeLauncher(launcherPath);
345
400
  const message = error instanceof Error ? error.message : String(error);
346
401
  console.error(
347
402
  `${COLORS.yellow}Warning: Failed to create tmux session (${message}). Falling back to direct spawn.${COLORS.reset}`
348
403
  );
349
- return spawnDirect(cmd, projectRoot, envVars);
404
+ return spawnDirect(cmd, projectRoot, spawnEnv);
350
405
  }
351
406
  }
352
407
 
@@ -357,12 +412,12 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
357
412
  async function spawnDirect(
358
413
  cmd: string[],
359
414
  projectRoot: string,
360
- envVars: Record<string, string> = {},
415
+ env: Record<string, string> = {},
361
416
  ): Promise<number> {
362
417
  const proc = Bun.spawn(cmd, {
363
418
  stdio: ["inherit", "inherit", "inherit"],
364
419
  cwd: projectRoot,
365
- env: { ...process.env, ...envVars },
420
+ env,
366
421
  });
367
422
 
368
423
  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
+ }