@bastani/atomic 0.5.14-0 → 0.5.15-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 (40) hide show
  1. package/.claude/settings.json +24 -0
  2. package/.opencode/opencode.json +10 -0
  3. package/README.md +10 -58
  4. package/assets/settings.schema.json +29 -0
  5. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  6. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +4 -1
  7. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
  8. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +4 -1
  9. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
  10. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +4 -1
  11. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
  12. package/dist/services/config/atomic-config.d.ts +44 -0
  13. package/dist/services/config/atomic-config.d.ts.map +1 -0
  14. package/dist/services/config/definitions.d.ts +18 -13
  15. package/dist/services/config/definitions.d.ts.map +1 -1
  16. package/dist/services/config/index.d.ts +7 -0
  17. package/dist/services/config/index.d.ts.map +1 -0
  18. package/dist/services/config/settings-schema.d.ts +2 -0
  19. package/dist/services/config/settings-schema.d.ts.map +1 -0
  20. package/dist/services/system/copy.d.ts +8 -1
  21. package/dist/services/system/copy.d.ts.map +1 -1
  22. package/package.json +3 -1
  23. package/src/cli.ts +1 -30
  24. package/src/commands/cli/chat/index.ts +21 -6
  25. package/src/commands/cli/init/index.ts +78 -323
  26. package/src/commands/cli/init/onboarding.ts +4 -10
  27. package/src/commands/cli/init/scm.ts +3 -34
  28. package/src/lib/common-ignore.ts +46 -0
  29. package/src/lib/merge.ts +28 -1
  30. package/src/sdk/runtime/executor.ts +85 -52
  31. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +9 -4
  32. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +12 -7
  33. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +12 -7
  34. package/src/services/config/atomic-config.ts +95 -1
  35. package/src/services/config/atomic-global-config.ts +8 -21
  36. package/src/services/config/definitions.ts +41 -44
  37. package/src/services/config/settings.ts +2 -1
  38. package/src/services/system/agents.ts +2 -1
  39. package/src/services/system/copy.ts +18 -7
  40. package/src/services/system/skills.ts +3 -1
@@ -33,7 +33,11 @@ import type {
33
33
  ProviderClient,
34
34
  ProviderSession,
35
35
  } from "../types.ts";
36
- import { isValidAgent } from "../../services/config/definitions.ts";
36
+ import {
37
+ isValidAgent,
38
+ type ProviderOverrides,
39
+ } from "../../services/config/definitions.ts";
40
+ import { getProviderOverrides } from "../../services/config/atomic-config.ts";
37
41
  import { ensureDir } from "../../services/system/copy.ts";
38
42
  import type { SessionEvent } from "@github/copilot-sdk";
39
43
  import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
@@ -62,13 +66,7 @@ const AGENT_CLI: Record<
62
66
  > = {
63
67
  copilot: {
64
68
  cmd: "copilot",
65
- chatFlags: [
66
- "--add-dir",
67
- ".",
68
- "--yolo",
69
- "--experimental",
70
- "--no-auto-update",
71
- ],
69
+ chatFlags: ["--add-dir", ".", "--yolo", "--experimental"],
72
70
  envVars: {
73
71
  COPILOT_ALLOW_ALL: "true",
74
72
  },
@@ -85,7 +83,6 @@ const AGENT_CLI: Record<
85
83
  // which the idle detection in claude.ts watches for to know when the
86
84
  // agent has finished processing a prompt.
87
85
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
88
- CLAUDE_CODE_NO_FLICKER: "1",
89
86
  },
90
87
  },
91
88
  };
@@ -110,7 +107,11 @@ export { errorMessage } from "../errors.ts";
110
107
  function isValidSavedMessage(msg: unknown): msg is SavedMessage {
111
108
  if (!msg || typeof msg !== "object") return false;
112
109
  const m = msg as Record<string, unknown>;
113
- return m.provider === "copilot" || m.provider === "opencode" || m.provider === "claude";
110
+ return (
111
+ m.provider === "copilot" ||
112
+ m.provider === "opencode" ||
113
+ m.provider === "claude"
114
+ );
114
115
  }
115
116
 
116
117
  export interface WorkflowRunOptions {
@@ -184,15 +185,28 @@ async function getRandomPort(): Promise<number> {
184
185
  function buildPaneCommand(
185
186
  agent: AgentType,
186
187
  port: number,
188
+ overrides: ProviderOverrides = {},
187
189
  ): { command: string; envVars: Record<string, string> } {
188
- const { cmd, chatFlags, envVars } = AGENT_CLI[agent];
190
+ const {
191
+ cmd,
192
+ chatFlags: defaultFlags,
193
+ envVars: defaultEnvVars,
194
+ } = AGENT_CLI[agent];
195
+ const chatFlags = overrides.chatFlags ?? defaultFlags;
196
+ const envVars = overrides.envVars
197
+ ? { ...defaultEnvVars, ...overrides.envVars }
198
+ : defaultEnvVars;
189
199
 
190
200
  switch (agent) {
191
201
  case "copilot":
192
202
  return {
193
- command: [cmd, "--ui-server", "--port", String(port), ...chatFlags].join(
194
- " ",
195
- ),
203
+ command: [
204
+ cmd,
205
+ "--ui-server",
206
+ "--port",
207
+ String(port),
208
+ ...chatFlags,
209
+ ].join(" "),
196
210
  envVars,
197
211
  };
198
212
  case "opencode":
@@ -286,7 +300,9 @@ export function escPwsh(s: string): string {
286
300
  * structured inputs are optional, so a corrupt payload must never
287
301
  * prevent free-form workflows from running.
288
302
  */
289
- export function parseInputsEnv(raw: string | undefined): Record<string, string> {
303
+ export function parseInputsEnv(
304
+ raw: string | undefined,
305
+ ): Record<string, string> {
290
306
  if (!raw) return {};
291
307
  try {
292
308
  const decoded = Buffer.from(raw, "base64").toString("utf-8");
@@ -487,8 +503,7 @@ function createTranscriptReader(
487
503
  const refName = resolveRef(ref);
488
504
  const prev = completedRegistry.get(refName);
489
505
  if (!prev) {
490
- const available =
491
- [...completedRegistry.keys()].join(", ") || "(none)";
506
+ const available = [...completedRegistry.keys()].join(", ") || "(none)";
492
507
  throw new Error(
493
508
  `No transcript for "${refName}". Available: ${available}`,
494
509
  );
@@ -510,11 +525,8 @@ function createMessagesReader(
510
525
  const refName = resolveRef(ref);
511
526
  const prev = completedRegistry.get(refName);
512
527
  if (!prev) {
513
- const available =
514
- [...completedRegistry.keys()].join(", ") || "(none)";
515
- throw new Error(
516
- `No messages for "${refName}". Available: ${available}`,
517
- );
528
+ const available = [...completedRegistry.keys()].join(", ") || "(none)";
529
+ throw new Error(`No messages for "${refName}". Available: ${available}`);
518
530
  }
519
531
  const filePath = join(prev.sessionDir, "messages.json");
520
532
  const raw = await Bun.file(filePath).text();
@@ -542,6 +554,8 @@ interface SharedRunnerState {
542
554
  * specifically via `ctx.inputs.prompt` for the free-form case.
543
555
  */
544
556
  inputs: Record<string, string>;
557
+ /** User-configured provider overrides (global + local merged). */
558
+ providerOverrides: ProviderOverrides;
545
559
  panel: OrchestratorPanel;
546
560
  /** Sessions that have been spawned (for name uniqueness + cleanup). */
547
561
  activeRegistry: Map<string, ActiveSession>;
@@ -615,7 +629,10 @@ async function initProviderClientAndSession<A extends AgentType>(
615
629
  }
616
630
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
617
631
  const ocClientOpts = clientOpts as StageClientOptions<"opencode">;
618
- const client = createOpencodeClient({ ...ocClientOpts, baseUrl: serverUrl });
632
+ const client = createOpencodeClient({
633
+ ...ocClientOpts,
634
+ baseUrl: serverUrl,
635
+ });
619
636
  const sessionResult = await client.session.create(ocSessionOpts);
620
637
  await client.tui.selectSession({ sessionID: sessionResult.data!.id });
621
638
  return { client, session: sessionResult.data! } as Result;
@@ -632,7 +649,11 @@ async function initProviderClientAndSession<A extends AgentType>(
632
649
  const claudeSessionOpts = sessionOpts as StageSessionOptions<"claude">;
633
650
  const client = new ClaudeClientWrapper(paneId, claudeClientOpts);
634
651
  await client.start();
635
- const session = new ClaudeSessionWrapper(paneId, sessionId, claudeSessionOpts);
652
+ const session = new ClaudeSessionWrapper(
653
+ paneId,
654
+ sessionId,
655
+ claudeSessionOpts,
656
+ );
636
657
  return { client, session } as Result;
637
658
  }
638
659
  default:
@@ -661,12 +682,16 @@ async function cleanupProvider<A extends AgentType>(
661
682
  try {
662
683
  await session.disconnect();
663
684
  } catch (e) {
664
- console.warn(`[cleanup] copilot session disconnect failed: ${errorMessage(e)}`);
685
+ console.warn(
686
+ `[cleanup] copilot session disconnect failed: ${errorMessage(e)}`,
687
+ );
665
688
  }
666
689
  try {
667
690
  await client.stop();
668
691
  } catch (e) {
669
- console.warn(`[cleanup] copilot client stop failed: ${errorMessage(e)}`);
692
+ console.warn(
693
+ `[cleanup] copilot client stop failed: ${errorMessage(e)}`,
694
+ );
670
695
  }
671
696
  break;
672
697
  }
@@ -756,12 +781,12 @@ function createSessionRunner(
756
781
  let panelSessionAdded = false;
757
782
 
758
783
  try {
759
-
760
784
  // ── 6. Allocate port ──
761
785
  const port = await getRandomPort();
762
786
  const { command: paneCmd, envVars: paneEnvVars } = buildPaneCommand(
763
787
  shared.agent,
764
788
  port,
789
+ shared.providerOverrides,
765
790
  );
766
791
 
767
792
  // ── 7. Create tmux window or headless execution ──
@@ -818,16 +843,13 @@ function createSessionRunner(
818
843
  arg: SessionEvent[] | SessionPromptResponse | string,
819
844
  ): Promise<SavedMessage[]> {
820
845
  if (typeof arg === "string") {
821
- const { getSessionMessages, listSessions } = await import(
822
- "@anthropic-ai/claude-agent-sdk"
823
- );
846
+ const { getSessionMessages, listSessions } =
847
+ await import("@anthropic-ai/claude-agent-sdk");
824
848
  const dir = process.cwd();
825
849
  const sessions = await listSessions({ dir });
826
850
 
827
851
  const newSessions = knownClaudeSessionIds
828
- ? sessions.filter(
829
- (s) => !knownClaudeSessionIds!.has(s.sessionId),
830
- )
852
+ ? sessions.filter((s) => !knownClaudeSessionIds!.has(s.sessionId))
831
853
  : sessions.filter(
832
854
  (s) => s.lastModified >= claudeSessionStartedAfter,
833
855
  );
@@ -890,16 +912,19 @@ function createSessionRunner(
890
912
  const getMessagesFn = createMessagesReader(shared.completedRegistry);
891
913
 
892
914
  // ── 12. Auto-create provider client and session ──
893
- const { client: providerClient, session: providerSession, cleanup: providerCleanup } =
894
- await initProviderClientAndSession(
895
- shared.agent,
896
- serverUrl,
897
- paneId,
898
- sessionId,
899
- clientOpts,
900
- sessionOpts,
901
- isHeadless,
902
- );
915
+ const {
916
+ client: providerClient,
917
+ session: providerSession,
918
+ cleanup: providerCleanup,
919
+ } = await initProviderClientAndSession(
920
+ shared.agent,
921
+ serverUrl,
922
+ paneId,
923
+ sessionId,
924
+ clientOpts,
925
+ sessionOpts,
926
+ isHeadless,
927
+ );
903
928
 
904
929
  // ── 12a. Copilot: wrap send() to await session.idle ──
905
930
  // Copilot's send() is fire-and-forget — it returns immediately after
@@ -924,7 +949,10 @@ function createSessionRunner(
924
949
  const idle = new Promise<void>((resolve, reject) => {
925
950
  let unsubIdle: (() => void) | undefined;
926
951
  let unsubError: (() => void) | undefined;
927
- const cleanup = () => { unsubIdle?.(); unsubError?.(); };
952
+ const cleanup = () => {
953
+ unsubIdle?.();
954
+ unsubError?.();
955
+ };
928
956
  unsubIdle = copilotSession.on("session.idle", () => {
929
957
  cleanup();
930
958
  resolve();
@@ -984,16 +1012,18 @@ function createSessionRunner(
984
1012
  callbackResult = await run(ctx);
985
1013
  if (pendingSaves.length > 0) await Promise.all(pendingSaves);
986
1014
  } catch (error) {
987
- const message =
988
- errorMessage(error);
989
- await Bun.write(join(sessionDir, "error.txt"), message).catch(
990
- () => {},
991
- );
1015
+ const message = errorMessage(error);
1016
+ await Bun.write(join(sessionDir, "error.txt"), message).catch(() => {});
992
1017
  if (!isHeadless) shared.panel.sessionError(name, message);
993
1018
  throw error;
994
1019
  } finally {
995
1020
  // ── 14a. Auto-cleanup provider resources ──
996
- await cleanupProvider(shared.agent, providerClient, providerSession, paneId);
1021
+ await cleanupProvider(
1022
+ shared.agent,
1023
+ providerClient,
1024
+ providerSession,
1025
+ paneId,
1026
+ );
997
1027
  if (providerCleanup) {
998
1028
  try {
999
1029
  providerCleanup();
@@ -1067,7 +1097,9 @@ export async function runOrchestrator(): Promise<void> {
1067
1097
  const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
1068
1098
  const rawAgent = process.env.ATOMIC_WF_AGENT!;
1069
1099
  if (!isValidAgent(rawAgent)) {
1070
- throw new Error(`Invalid ATOMIC_WF_AGENT: "${rawAgent}". Expected one of: copilot, opencode, claude`);
1100
+ throw new Error(
1101
+ `Invalid ATOMIC_WF_AGENT: "${rawAgent}". Expected one of: copilot, opencode, claude`,
1102
+ );
1071
1103
  }
1072
1104
  const agent: AgentType = rawAgent;
1073
1105
  // ATOMIC_WF_INPUTS carries the full input payload. Free-form
@@ -1084,6 +1116,7 @@ export async function runOrchestrator(): Promise<void> {
1084
1116
 
1085
1117
  process.chdir(cwd);
1086
1118
 
1119
+ const providerOverrides = await getProviderOverrides(agent, cwd);
1087
1120
  const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
1088
1121
  await ensureDir(sessionsBaseDir);
1089
1122
 
@@ -1114,6 +1147,7 @@ export async function runOrchestrator(): Promise<void> {
1114
1147
  sessionsBaseDir,
1115
1148
  agent,
1116
1149
  inputs,
1150
+ providerOverrides,
1117
1151
  panel,
1118
1152
  activeRegistry: new Map(),
1119
1153
  completedRegistry: new Map(),
@@ -1204,4 +1238,3 @@ export async function runOrchestrator(): Promise<void> {
1204
1238
  process.off("SIGINT", signalHandler);
1205
1239
  }
1206
1240
  }
1207
-
@@ -17,12 +17,15 @@
17
17
  * │
18
18
  * ▼
19
19
  * ┌──────────────────────────────────────────────────┐
20
- * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all)
20
+ * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all, headless)
21
21
  * └──────────────────────────────────────────────────┘
22
22
  * │
23
23
  * ▼
24
24
  * aggregator
25
25
  *
26
+ * Explorers run headless (in-process, no tmux window) — they are transparent
27
+ * to the graph, so the visible topology is: [scout, history] → aggregator.
28
+ *
26
29
  * Stage 1a — codebase-scout
27
30
  * Pure-TypeScript: lists files (git ls-files), counts LOC (batched wc -l),
28
31
  * renders a depth-bounded ASCII tree, and bin-packs directories into N
@@ -211,9 +214,10 @@ export default defineWorkflow({
211
214
  const scoutOverview = (await ctx.transcript(scout)).content;
212
215
  const historyOverview = (await ctx.transcript(history)).content;
213
216
 
214
- // ── Stage 2: parallel explorers ────────────────────────────────────────
215
- // Each explorer is a separate tmux pane / Claude session, running
216
- // concurrently via Promise.all. Each one receives:
217
+ // ── Stage 2: parallel headless explorers ─────────────────────────────────
218
+ // Each explorer runs headless (in-process, no tmux pane) via Promise.all.
219
+ // They are invisible in the workflow graph but tracked by the background
220
+ // task counter in the statusline. Each one receives:
217
221
  // - the original research question (top + bottom of prompt)
218
222
  // - the scout's architectural overview
219
223
  // - its OWN partition (never the full file list)
@@ -232,6 +236,7 @@ export default defineWorkflow({
232
236
  return ctx.stage(
233
237
  {
234
238
  name: `explorer-${i}`,
239
+ headless: true,
235
240
  description: `Explore ${partition
236
241
  .map((u) => u.path)
237
242
  .join(
@@ -18,12 +18,15 @@
18
18
  * │
19
19
  * ▼
20
20
  * ┌──────────────────────────────────────────────────┐
21
- * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all)
21
+ * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all, headless)
22
22
  * └──────────────────────────────────────────────────┘
23
23
  * │
24
24
  * ▼
25
25
  * aggregator
26
26
  *
27
+ * Explorers run headless (in-process, no tmux window) — they are transparent
28
+ * to the graph, so the visible topology is: [scout, history] → aggregator.
29
+ *
27
30
  * Copilot-specific concerns baked in:
28
31
  *
29
32
  *
@@ -177,12 +180,13 @@ export default defineWorkflow({
177
180
  const scoutOverview = (await ctx.transcript(scout)).content;
178
181
  const historyOverview = (await ctx.transcript(history)).content;
179
182
 
180
- // ── Stage 2: parallel explorers ────────────────────────────────────────
181
- // Each explorer is a separate Copilot session, running concurrently via
182
- // Promise.all. Because the session is fresh (F5), every piece of context
183
- // it needs question, architectural orientation, historical context,
184
- // partition assignment, scratch path is injected into the first prompt
185
- // via buildExplorerPromptGeneric.
183
+ // ── Stage 2: parallel headless explorers ─────────────────────────────────
184
+ // Each explorer runs headless (in-process, no tmux pane) via Promise.all.
185
+ // They are invisible in the workflow graph but tracked by the background
186
+ // task counter in the statusline. Because each session is fresh (F5),
187
+ // every piece of context it needs question, architectural orientation,
188
+ // historical context, partition assignment, scratch path — is injected
189
+ // into the first prompt via buildExplorerPromptGeneric.
186
190
  const explorerHandles = await Promise.all(
187
191
  partitions.map((partition, idx) => {
188
192
  const i = idx + 1;
@@ -190,6 +194,7 @@ export default defineWorkflow({
190
194
  return ctx.stage(
191
195
  {
192
196
  name: `explorer-${i}`,
197
+ headless: true,
193
198
  description: `Explore ${partition
194
199
  .map((u) => u.path)
195
200
  .join(", ")} (${partition.reduce((s, u) => s + u.fileCount, 0)} files)`,
@@ -18,12 +18,15 @@
18
18
  * │
19
19
  * ▼
20
20
  * ┌──────────────────────────────────────────────────┐
21
- * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all)
21
+ * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all, headless)
22
22
  * └──────────────────────────────────────────────────┘
23
23
  * │
24
24
  * ▼
25
25
  * aggregator
26
26
  *
27
+ * Explorers run headless (in-process, no tmux window) — they are transparent
28
+ * to the graph, so the visible topology is: [scout, history] → aggregator.
29
+ *
27
30
  * OpenCode-specific concerns baked in:
28
31
  *
29
32
  * • F5 — every `ctx.stage()` call is a FRESH session with no memory of
@@ -196,12 +199,13 @@ export default defineWorkflow({
196
199
  const scoutOverview = (await ctx.transcript(scout)).content;
197
200
  const historyOverview = (await ctx.transcript(history)).content;
198
201
 
199
- // ── Stage 2: parallel explorers ────────────────────────────────────────
200
- // Each explorer is a separate OpenCode session, running concurrently via
201
- // Promise.all. Because the session is fresh (F5), every piece of context
202
- // it needs question, architectural orientation, historical context,
203
- // partition assignment, scratch path is injected into the first prompt
204
- // via buildExplorerPromptGeneric.
202
+ // ── Stage 2: parallel headless explorers ─────────────────────────────────
203
+ // Each explorer runs headless (in-process, no tmux pane) via Promise.all.
204
+ // They are invisible in the workflow graph but tracked by the background
205
+ // task counter in the statusline. Because each session is fresh (F5),
206
+ // every piece of context it needs question, architectural orientation,
207
+ // historical context, partition assignment, scratch path — is injected
208
+ // into the first prompt via buildExplorerPromptGeneric.
205
209
  const explorerHandles = await Promise.all(
206
210
  partitions.map((partition, idx) => {
207
211
  const i = idx + 1;
@@ -209,6 +213,7 @@ export default defineWorkflow({
209
213
  return ctx.stage(
210
214
  {
211
215
  name: `explorer-${i}`,
216
+ headless: true,
212
217
  description: `Explore ${partition
213
218
  .map((u) => u.path)
214
219
  .join(", ")} (${partition.reduce((s, u) => s + u.fileCount, 0)} files)`,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { join, dirname } from "node:path";
11
11
  import { homedir } from "node:os";
12
- import { type SourceControlType } from "./index.ts";
12
+ import { type SourceControlType, type AgentKey, type ProviderOverrides } from "./index.ts";
13
13
  import { SETTINGS_SCHEMA_URL } from "./settings-schema.ts";
14
14
  import { ensureDir } from "../system/copy.ts";
15
15
 
@@ -26,6 +26,8 @@ export interface AtomicConfig {
26
26
  scm?: SourceControlType;
27
27
  /** Timestamp of last init */
28
28
  lastUpdated?: string;
29
+ /** Per-provider overrides for chatFlags and envVars */
30
+ providers?: Partial<Record<AgentKey, ProviderOverrides>>;
29
31
  }
30
32
 
31
33
  type JsonRecord = Record<string, unknown>;
@@ -47,6 +49,41 @@ async function readJsonFile(path: string): Promise<JsonRecord | null> {
47
49
  }
48
50
  }
49
51
 
52
+ function pickProviderOverrides(raw: unknown): ProviderOverrides | null {
53
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
54
+ const obj = raw as Record<string, unknown>;
55
+ const result: ProviderOverrides = {};
56
+
57
+ if (Array.isArray(obj.chatFlags) && obj.chatFlags.every((f): f is string => typeof f === "string")) {
58
+ result.chatFlags = obj.chatFlags;
59
+ }
60
+ if (obj.envVars && typeof obj.envVars === "object" && !Array.isArray(obj.envVars)) {
61
+ const envVars: Record<string, string> = {};
62
+ for (const [k, v] of Object.entries(obj.envVars)) {
63
+ if (typeof v === "string") envVars[k] = v;
64
+ }
65
+ if (Object.keys(envVars).length > 0) result.envVars = envVars;
66
+ }
67
+
68
+ return Object.keys(result).length > 0 ? result : null;
69
+ }
70
+
71
+ const VALID_AGENT_KEYS = new Set<string>(["claude", "opencode", "copilot"]);
72
+
73
+ function pickProviders(raw: unknown): Partial<Record<AgentKey, ProviderOverrides>> | null {
74
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
75
+ const obj = raw as Record<string, unknown>;
76
+ const result: Partial<Record<AgentKey, ProviderOverrides>> = {};
77
+
78
+ for (const [key, value] of Object.entries(obj)) {
79
+ if (!VALID_AGENT_KEYS.has(key)) continue;
80
+ const overrides = pickProviderOverrides(value);
81
+ if (overrides) result[key as AgentKey] = overrides;
82
+ }
83
+
84
+ return Object.keys(result).length > 0 ? result : null;
85
+ }
86
+
50
87
  function pickAtomicConfig(record: JsonRecord | null): AtomicConfig | null {
51
88
  if (!record) return null;
52
89
 
@@ -59,9 +96,42 @@ function pickAtomicConfig(record: JsonRecord | null): AtomicConfig | null {
59
96
  if (typeof scm === "string") config.scm = scm as SourceControlType;
60
97
  if (typeof lastUpdated === "string") config.lastUpdated = lastUpdated;
61
98
 
99
+ const providers = pickProviders(record.providers);
100
+ if (providers) config.providers = providers;
101
+
62
102
  return Object.keys(config).length > 0 ? config : null;
63
103
  }
64
104
 
105
+ /**
106
+ * Merge two ProviderOverrides, with `over` taking precedence.
107
+ * - chatFlags: later config replaces earlier
108
+ * - envVars: merged, later values win on conflict
109
+ */
110
+ function mergeProviderOverrides(
111
+ base: ProviderOverrides | undefined,
112
+ over: ProviderOverrides | undefined,
113
+ ): ProviderOverrides | undefined {
114
+ if (!base && !over) return undefined;
115
+ if (!base) return over;
116
+ if (!over) return base;
117
+
118
+ const result: ProviderOverrides = {};
119
+
120
+ // chatFlags: later replaces earlier entirely
121
+ if (over.chatFlags !== undefined) {
122
+ result.chatFlags = over.chatFlags;
123
+ } else if (base.chatFlags !== undefined) {
124
+ result.chatFlags = base.chatFlags;
125
+ }
126
+
127
+ // envVars: merged, later wins on conflict
128
+ if (base.envVars || over.envVars) {
129
+ result.envVars = { ...base.envVars, ...over.envVars };
130
+ }
131
+
132
+ return Object.keys(result).length > 0 ? result : undefined;
133
+ }
134
+
65
135
  function mergeConfigs(...configs: Array<AtomicConfig | null>): AtomicConfig | null {
66
136
  const merged: AtomicConfig = {};
67
137
  for (const config of configs) {
@@ -69,6 +139,14 @@ function mergeConfigs(...configs: Array<AtomicConfig | null>): AtomicConfig | nu
69
139
  if (config.version !== undefined) merged.version = config.version;
70
140
  if (config.scm !== undefined) merged.scm = config.scm;
71
141
  if (config.lastUpdated !== undefined) merged.lastUpdated = config.lastUpdated;
142
+
143
+ if (config.providers) {
144
+ if (!merged.providers) merged.providers = {};
145
+ for (const [key, overrides] of Object.entries(config.providers)) {
146
+ const agentKey = key as AgentKey;
147
+ merged.providers[agentKey] = mergeProviderOverrides(merged.providers[agentKey], overrides);
148
+ }
149
+ }
72
150
  }
73
151
  return Object.keys(merged).length > 0 ? merged : null;
74
152
  }
@@ -121,3 +199,19 @@ export async function getSelectedScm(projectDir: string): Promise<SourceControlT
121
199
  const config = await readAtomicConfig(projectDir);
122
200
  return config?.scm ?? null;
123
201
  }
202
+
203
+ /**
204
+ * Resolve provider overrides from global + local settings (local wins).
205
+ *
206
+ * Returns `{ chatFlags, envVars }` that are meant to be layered on top
207
+ * of the provider's hardcoded defaults:
208
+ * - `chatFlags`: when set, replaces the provider's default chat_flags entirely
209
+ * - `envVars`: merged on top of the provider's default env_vars (user values win)
210
+ */
211
+ export async function getProviderOverrides(
212
+ agentKey: AgentKey,
213
+ projectDir: string,
214
+ ): Promise<ProviderOverrides> {
215
+ const config = await readAtomicConfig(projectDir);
216
+ return config?.providers?.[agentKey] ?? {};
217
+ }
@@ -1,10 +1,11 @@
1
- import { copyFile, lstat, readdir, rm, rmdir } from "node:fs/promises";
1
+ import { lstat, readdir, rm, rmdir } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
 
5
5
  import { AGENT_CONFIG, getAgentKeys, type AgentKey } from "./index.ts";
6
- import { mergeJsonFile } from "../../lib/merge.ts";
7
- import { copyDir, ensureDir, pathExists } from "../system/copy.ts";
6
+ import { syncJsonFile } from "../../lib/merge.ts";
7
+ import { createCommonIgnoreFilter } from "../../lib/common-ignore.ts";
8
+ import { copyDir, ensureDir, pathExists, shouldExclude } from "../system/copy.ts";
8
9
 
9
10
 
10
11
  const ATOMIC_HOME_DIR = join(homedir(), ".atomic");
@@ -117,15 +118,7 @@ async function collectManagedTreeEntries(
117
118
  ? join(relativeDir, entry.name)
118
119
  : entry.name;
119
120
 
120
- if (exclude.includes(entry.name)) {
121
- continue;
122
- }
123
-
124
- const normalizedRelativePath = relativePath.replace(/\\/g, "/");
125
- if (exclude.some((excluded) =>
126
- normalizedRelativePath === excluded.replace(/\\/g, "/") ||
127
- normalizedRelativePath.startsWith(`${excluded.replace(/\\/g, "/")}/`)
128
- )) {
121
+ if (shouldExclude(relativePath, entry.name, [...exclude])) {
129
122
  continue;
130
123
  }
131
124
 
@@ -174,14 +167,7 @@ async function syncManagedGlobalFile(
174
167
  sourcePath: string,
175
168
  destinationPath: string,
176
169
  ): Promise<void> {
177
- await ensureDir(resolve(destinationPath, ".."));
178
-
179
- if (await pathExists(destinationPath)) {
180
- await mergeJsonFile(sourcePath, destinationPath);
181
- return;
182
- }
183
-
184
- await copyFile(sourcePath, destinationPath);
170
+ await syncJsonFile(sourcePath, destinationPath);
185
171
  }
186
172
 
187
173
  /**
@@ -264,10 +250,11 @@ export async function syncAtomicGlobalAgentConfigs(
264
250
  const destinationFolder = getAtomicManagedAgentDir(agentKey, baseDir);
265
251
  await ensureDir(destinationFolder);
266
252
 
253
+ const ignoreFilter = createCommonIgnoreFilter();
267
254
  for (const subdirectory of GLOBAL_SYNC_SUBDIRECTORIES) {
268
255
  const sourceSubdir = join(sourceFolder, subdirectory);
269
256
  if (await pathExists(sourceSubdir)) {
270
- await copyDir(sourceSubdir, join(destinationFolder, subdirectory));
257
+ await copyDir(sourceSubdir, join(destinationFolder, subdirectory), { ignoreFilter });
271
258
  }
272
259
  }
273
260