@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
@@ -1,351 +1,106 @@
1
1
  /**
2
- * Init command - Interactive setup flow for atomic CLI
2
+ * Automatic project setup replaces the interactive `atomic init` command.
3
3
  *
4
- * Uses Catppuccin Mocha palette for visual hierarchy and brand alignment.
5
- * All color output respects the NO_COLOR environment variable.
4
+ * Detects the repo's SCM, applies onboarding files (MCP configs, settings),
5
+ * registers the workspace as trusted, and installs SCM-specific skills.
6
+ *
7
+ * Called transparently during `atomic chat` preflight so users never need
8
+ * to think about initialization.
6
9
  */
7
10
 
8
- import {
9
- intro,
10
- outro,
11
- select,
12
- confirm,
13
- spinner,
14
- isCancel,
15
- cancel,
16
- note,
17
- log,
18
- } from "@clack/prompts";
19
11
  import { join, resolve } from "node:path";
20
-
21
12
  import {
22
13
  AGENT_CONFIG,
23
14
  type AgentKey,
24
- getAgentKeys,
25
- isValidAgent,
26
- SCM_CONFIG,
27
- SCM_SKILLS_BY_TYPE,
28
15
  type SourceControlType,
29
- getScmKeys,
30
- isValidScm,
16
+ SCM_SKILLS_BY_TYPE,
17
+ detectScmType,
31
18
  } from "../../../services/config/index.ts";
32
19
  import { pathExists } from "../../../services/system/copy.ts";
33
20
  import { getConfigRoot } from "../../../services/config/config-path.ts";
34
- import { isWindows, isWslInstalled, WSL_INSTALL_URL } from "../../../services/system/detect.ts";
35
- import { saveAtomicConfig } from "../../../services/config/atomic-config.ts";
21
+ import { getTemplateAgentFolder } from "../../../services/config/atomic-global-config.ts";
36
22
  import { upsertTrustedWorkspacePath } from "../../../services/config/settings.ts";
37
- import {
38
- ensureAtomicGlobalAgentConfigs,
39
- getTemplateAgentFolder,
40
- } from "../../../services/config/atomic-global-config.ts";
41
- import {
42
- installLocalScmSkills,
43
- reconcileScmVariants,
44
- syncProjectScmSkills,
45
- } from "./scm.ts";
46
- import {
47
- applyManagedOnboardingFiles,
48
- hasProjectOnboardingFiles,
49
- } from "./onboarding.ts";
50
- import { displayBlockBanner } from "../../../theme/logo.ts";
51
- import { createPainter } from "../../../theme/colors.ts";
23
+ import { applyManagedOnboardingFiles } from "./onboarding.ts";
24
+ import { installLocalScmSkills, syncProjectScmSkills } from "./scm.ts";
52
25
 
53
26
  /**
54
- * Thrown when the user cancels an interactive prompt during init.
55
- *
56
- * When `initCommand` is invoked from a caller that sets
57
- * `callerHandlesExit: true` (e.g. the auto-init path inside
58
- * `chatCommand`), cancellation throws this error instead of calling
59
- * `process.exit(0)` so the caller can decide what to do.
27
+ * Check whether all expected SCM skills are already present on disk.
60
28
  */
61
- export class InitCancelledError extends Error {
62
- constructor(message = "Operation cancelled.") {
63
- super(message);
64
- this.name = "InitCancelledError";
29
+ async function areScmSkillsInstalled(
30
+ agentKey: AgentKey,
31
+ projectRoot: string,
32
+ scmType: SourceControlType,
33
+ ): Promise<boolean> {
34
+ const skillNames = SCM_SKILLS_BY_TYPE[scmType];
35
+ const skillsDir = join(projectRoot, AGENT_CONFIG[agentKey].folder, "skills");
36
+
37
+ for (const name of skillNames) {
38
+ if (!(await pathExists(join(skillsDir, name)))) {
39
+ return false;
40
+ }
65
41
  }
42
+ return true;
66
43
  }
67
44
 
68
- interface InitOptions {
69
- showBanner?: boolean;
70
- preSelectedAgent?: AgentKey;
71
- /** Pre-selected source control type (skip SCM selection prompt) */
72
- preSelectedScm?: SourceControlType;
73
- configNotFoundMessage?: string;
74
- /** Auto-confirm all prompts (non-interactive mode for CI/testing) */
75
- yes?: boolean;
76
- /**
77
- * When true, throw `InitCancelledError` instead of calling
78
- * `process.exit()` on user cancellation. This allows callers like
79
- * `chatCommand` auto-init to handle the cancellation gracefully.
80
- */
81
- callerHandlesExit?: boolean;
45
+ function isInstalledPackage(): boolean {
46
+ return import.meta.dir.includes("node_modules");
82
47
  }
83
48
 
84
- export {
85
- applyManagedOnboardingFiles,
86
- hasProjectOnboardingFiles,
87
- } from "./onboarding.ts";
88
- export {
89
- getScmPrefix,
90
- reconcileScmVariants,
91
- } from "./scm.ts";
92
-
93
49
  /**
94
- * Run the interactive init command
50
+ * Ensure the project is configured for the given agent.
51
+ *
52
+ * Idempotent — safe to call on every `atomic chat` invocation. Expensive
53
+ * operations (skill installation via `bunx skills add`) are skipped when
54
+ * the skills are already present on disk. Onboarding file merges are
55
+ * always applied since they are cheap and self-healing.
56
+ *
57
+ * Errors in skill installation are swallowed so the agent can still launch.
95
58
  */
96
- export async function initCommand(options: InitOptions = {}): Promise<void> {
97
- const { showBanner = true, configNotFoundMessage, callerHandlesExit = false } = options;
98
- const paint = createPainter();
99
-
100
- /** Exit-or-throw helper: when a caller (e.g. chatCommand auto-init) sets
101
- * `callerHandlesExit`, we throw so the caller can handle the cancellation.
102
- * Otherwise we call `process.exit()` directly (standalone `atomic init`). */
103
- function exitOrThrow(code: number, message?: string): never {
104
- if (callerHandlesExit) {
105
- throw new InitCancelledError(message);
106
- }
107
- process.exit(code);
108
- }
109
-
110
- // Display banner
111
- if (showBanner) {
112
- displayBlockBanner();
113
- }
114
-
115
- intro(paint("accent", "Configure agent skills & source control", { bold: true }));
116
-
117
- if (configNotFoundMessage) {
118
- log.info(configNotFoundMessage);
119
- }
120
-
121
- // ── Agent selection ────────────────────────────────────────────────
122
- let agentKey: AgentKey;
123
-
124
- if (options.preSelectedAgent) {
125
- if (!isValidAgent(options.preSelectedAgent)) {
126
- cancel(`Unknown agent: ${options.preSelectedAgent}`);
127
- exitOrThrow(1, `Unknown agent: ${options.preSelectedAgent}`);
128
- }
129
- agentKey = options.preSelectedAgent;
130
- log.info(`${paint("accent", "→")} Agent: ${paint("text", AGENT_CONFIG[agentKey].name, { bold: true })}`);
131
- } else {
132
- const agentKeys = getAgentKeys();
133
- const agentOptions = agentKeys.map((key) => ({
134
- value: key,
135
- label: AGENT_CONFIG[key].name,
136
- hint: AGENT_CONFIG[key].install_url.replace("https://", ""),
137
- }));
138
-
139
- const selectedAgent = await select({
140
- message: "Which coding agent?",
141
- options: agentOptions,
142
- });
143
-
144
- if (isCancel(selectedAgent)) {
145
- cancel("Cancelled.");
146
- exitOrThrow(0);
147
- }
148
-
149
- agentKey = selectedAgent as AgentKey;
150
- }
151
- const agent = AGENT_CONFIG[agentKey];
152
- const targetDir = process.cwd();
153
- const autoConfirm = options.yes ?? false;
154
-
155
- // ── SCM selection ──────────────────────────────────────────────────
156
- let scmType: SourceControlType;
157
-
158
- if (options.preSelectedScm) {
159
- if (!isValidScm(options.preSelectedScm)) {
160
- cancel(`Unknown source control: ${options.preSelectedScm}`);
161
- exitOrThrow(1, `Unknown source control: ${options.preSelectedScm}`);
162
- }
163
- scmType = options.preSelectedScm;
164
- log.info(`${paint("accent", "→")} SCM: ${paint("text", SCM_CONFIG[scmType].displayName, { bold: true })}`);
165
- } else if (autoConfirm) {
166
- scmType = "github";
167
- log.info(`${paint("accent", "→")} SCM: ${paint("text", "GitHub / Git", { bold: true })} ${paint("dim", "(default)")}`);
168
- } else {
169
- const scmOptions = getScmKeys().map((key) => ({
170
- value: key,
171
- label: SCM_CONFIG[key].displayName,
172
- hint: `${SCM_CONFIG[key].cliTool} + ${SCM_CONFIG[key].reviewSystem}`,
173
- }));
174
-
175
- const selectedScm = await select({
176
- message: "Which source control?",
177
- options: scmOptions,
178
- });
179
-
180
- if (isCancel(selectedScm)) {
181
- cancel("Cancelled.");
182
- exitOrThrow(0);
183
- }
184
-
185
- scmType = selectedScm as SourceControlType;
186
- }
187
-
188
- // Sapling-specific warning
189
- if (scmType === "sapling") {
190
- const arcconfigPath = join(targetDir, ".arcconfig");
191
- const hasArcconfig = await pathExists(arcconfigPath);
192
-
193
- if (!hasArcconfig) {
194
- log.warn(
195
- `Sapling + Phabricator requires ${paint("text", ".arcconfig", { bold: true })} in your repo root.\n` +
196
- `${paint("dim", "See: https://www.phacility.com/phabricator/")}`
197
- );
198
- }
199
- }
200
-
201
- // ── Preflight summary ──────────────────────────────────────────────
202
- const targetFolder = join(targetDir, agent.folder);
203
- const folderExists = await pathExists(targetFolder);
204
- const configAction = folderExists ? "update" : "create";
205
-
206
- if (!autoConfirm) {
207
- const summaryLines = [
208
- `${paint("dim", "Agent")} ${paint("text", agent.name, { bold: true })}`,
209
- `${paint("dim", "SCM")} ${paint("text", SCM_CONFIG[scmType].displayName, { bold: true })}`,
210
- `${paint("dim", "Target")} ${paint("text", targetDir)}`,
211
- `${paint("dim", "Action")} ${paint(folderExists ? "warning" : "success", configAction)}`,
212
- ];
213
- note(summaryLines.join("\n"), paint("accent", "Setup", { bold: true }));
214
-
215
- const shouldProceed = await confirm({
216
- message: folderExists
217
- ? `${agent.folder} exists — update source control skills?`
218
- : "Proceed with setup?",
219
- initialValue: true,
220
- });
221
-
222
- if (isCancel(shouldProceed) || !shouldProceed) {
223
- cancel("Cancelled.");
224
- exitOrThrow(0);
225
- }
226
- }
227
-
228
- // ── Configure ──────────────────────────────────────────────────────
229
- const s = spinner();
230
- s.start("Configuring skills…");
231
-
232
- let skillsInstalled = false;
233
- let skillsSkipReason = "";
234
-
235
- try {
236
- const configRoot = getConfigRoot();
237
-
238
- await ensureAtomicGlobalAgentConfigs(configRoot);
239
-
240
- const templateAgentFolder = getTemplateAgentFolder(agentKey);
241
- const sourceSkillsDir = join(configRoot, templateAgentFolder, "skills");
242
- const targetSkillsDir = join(targetFolder, "skills");
243
-
244
- // Best-effort template copy: source checkouts still carry the bundled
245
- // gh-*/sl-* skill templates, but binary and npm installs no longer do
246
- // (they live in the skills CLI repo). `installLocalScmSkills` below
247
- // handles the binary/npm case by invoking `bunx skills add` — so a zero
248
- // copy here is not an error, just a signal that the template isn't
249
- // bundled for this install type.
250
- await syncProjectScmSkills({
251
- scmType,
252
- sourceSkillsDir,
253
- targetSkillsDir,
254
- });
255
-
256
- // Keep SCM-specific managed command/skill variants aligned with selected SCM
257
- await reconcileScmVariants({
258
- scmType,
259
- agentFolder: agent.folder,
260
- skillsSubfolder: "skills",
261
- targetDir,
262
- configRoot,
263
- });
264
-
265
- await applyManagedOnboardingFiles(agentKey, targetDir, configRoot);
266
-
267
- // Save SCM selection to .atomic/settings.json
268
- await saveAtomicConfig(targetDir, {
269
- scm: scmType,
270
- });
271
- await upsertTrustedWorkspacePath(resolve(targetDir), agentKey);
272
-
273
- s.stop(paint("success", "✓", { bold: true }) + " Skills configured");
274
-
275
- // Install SCM-specific skill variants locally for the active agent via
276
- // `bunx skills add` (best-effort: a failure is surfaced as a warning).
277
- //
278
- // Source checkouts already have the bundled skills on disk and the
279
- // template-copy above has placed the selected variants into `targetDir`;
280
- // skip the network-backed skills CLI in that case to keep dev iteration
281
- // fast and offline-friendly.
282
- if (import.meta.dir.includes("node_modules")) {
283
- const skillsToInstall = SCM_SKILLS_BY_TYPE[scmType];
284
- const skillsLabel = skillsToInstall.join(", ");
285
- const skillsSpinner = spinner();
286
- skillsSpinner.start(
287
- `Installing ${paint("text", skillsLabel, { bold: true })}…`,
288
- );
289
- const skillsResult = await installLocalScmSkills({
290
- scmType,
59
+ export async function ensureProjectSetup(
60
+ agentKey: AgentKey,
61
+ projectRoot: string,
62
+ ): Promise<void> {
63
+ const configRoot = getConfigRoot();
64
+ const detectedScm = await detectScmType(projectRoot);
65
+
66
+ // Apply onboarding files (idempotent merge, SCM-gated entries handled internally)
67
+ await applyManagedOnboardingFiles(agentKey, projectRoot, configRoot);
68
+
69
+ // Register trusted workspace
70
+ await upsertTrustedWorkspacePath(resolve(projectRoot), agentKey);
71
+
72
+ // Install SCM skills if detected and not yet present (best-effort)
73
+ if (detectedScm) {
74
+ try {
75
+ const alreadyInstalled = await areScmSkillsInstalled(
291
76
  agentKey,
292
- cwd: targetDir,
293
- });
294
- if (skillsResult.success) {
295
- skillsInstalled = true;
296
- skillsSpinner.stop(
297
- paint("success", "✓", { bold: true }) + ` ${skillsLabel} installed`,
298
- );
299
- } else {
300
- skillsSkipReason = skillsResult.details;
301
- skillsSpinner.stop(
302
- paint("warning", "○") + ` ${skillsLabel} skipped ${paint("dim", `(${skillsResult.details})`)}`,
303
- );
304
- }
305
- }
306
- } catch (error) {
307
- s.stop(paint("error", "✗", { bold: true }) + " Configuration failed");
308
- console.error(
309
- error instanceof Error ? error.message : "Unknown error occurred"
310
- );
311
- exitOrThrow(1, error instanceof Error ? error.message : "Unknown error occurred");
312
- }
313
-
314
- // ── WSL warning ────────────────────────────────────────────────────
315
- if (isWindows() && !isWslInstalled()) {
316
- log.warn(
317
- `WSL not detected. Some scripts may require it.\n` +
318
- `${paint("dim", WSL_INSTALL_URL)}`
319
- );
320
- }
321
-
322
- // ── Summary ────────────────────────────────────────────────────────
323
- const resultLines: string[] = [];
324
- resultLines.push(
325
- `${paint("success", "✓")} ${agent.name} skills ${paint("dim", "→")} ${paint("text", agent.folder + "/skills")}`,
326
- );
327
- resultLines.push(
328
- `${paint("success", "✓")} SCM workflow ${paint("dim", "→")} ${paint("text", SCM_CONFIG[scmType].displayName)}`,
329
- );
330
-
331
- if (import.meta.dir.includes("node_modules")) {
332
- if (skillsInstalled) {
333
- resultLines.push(
334
- `${paint("success", "✓")} Local skills installed`,
335
- );
336
- } else {
337
- resultLines.push(
338
- `${paint("warning", "○")} Local skills skipped ${paint("dim", skillsSkipReason ? `(${skillsSkipReason})` : "")}`,
77
+ projectRoot,
78
+ detectedScm,
339
79
  );
80
+ if (!alreadyInstalled) {
81
+ if (isInstalledPackage()) {
82
+ // npm/bunx install: fetch via the skills CLI
83
+ await installLocalScmSkills({
84
+ scmType: detectedScm,
85
+ agentKey,
86
+ cwd: projectRoot,
87
+ });
88
+ } else {
89
+ // Source checkout: copy from bundled templates
90
+ const templateFolder = getTemplateAgentFolder(agentKey);
91
+ await syncProjectScmSkills({
92
+ scmType: detectedScm,
93
+ sourceSkillsDir: join(configRoot, templateFolder, "skills"),
94
+ targetSkillsDir: join(
95
+ projectRoot,
96
+ AGENT_CONFIG[agentKey].folder,
97
+ "skills",
98
+ ),
99
+ });
100
+ }
101
+ }
102
+ } catch {
103
+ // Skills installation is best-effort — don't block the agent launch
340
104
  }
341
105
  }
342
-
343
- resultLines.push("");
344
- resultLines.push(
345
- `${paint("accent", "→")} Run ${paint("text", agent.cmd, { bold: true })} to start the agent`,
346
- );
347
-
348
- note(resultLines.join("\n"), paint("success", "Ready", { bold: true }));
349
-
350
- outro(paint("dim", "Happy coding ⚛"));
351
106
  }
@@ -1,7 +1,7 @@
1
- import { dirname, join } from "node:path";
1
+ import { join } from "node:path";
2
2
  import { AGENT_CONFIG, type AgentKey } from "../../../services/config/index.ts";
3
- import { copyFile, pathExists, ensureDir } from "../../../services/system/copy.ts";
4
- import { mergeJsonFile } from "../../../lib/merge.ts";
3
+ import { pathExists } from "../../../services/system/copy.ts";
4
+ import { syncJsonFile } from "../../../lib/merge.ts";
5
5
 
6
6
  export async function applyManagedOnboardingFiles(
7
7
  agentKey: AgentKey,
@@ -17,13 +17,7 @@ export async function applyManagedOnboardingFiles(
17
17
  }
18
18
 
19
19
  const destinationPath = join(projectRoot, managedFile.destination);
20
- await ensureDir(dirname(destinationPath));
21
-
22
- if (managedFile.merge && (await pathExists(destinationPath))) {
23
- await mergeJsonFile(sourcePath, destinationPath);
24
- } else {
25
- await copyFile(sourcePath, destinationPath);
26
- }
20
+ await syncJsonFile(sourcePath, destinationPath, managedFile.merge);
27
21
  }
28
22
  }
29
23
 
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { readdir } from "node:fs/promises";
3
- import { copyFile, pathExists, ensureDir } from "../../../services/system/copy.ts";
4
- import { getOppositeScriptExtension } from "../../../services/system/detect.ts";
3
+ import { copyDir, pathExists, ensureDir } from "../../../services/system/copy.ts";
4
+ import { createCommonIgnoreFilter } from "../../../lib/common-ignore.ts";
5
5
  import {
6
6
  SCM_SKILLS_BY_TYPE,
7
7
  type AgentKey,
@@ -50,37 +50,6 @@ export async function reconcileScmVariants(options: ReconcileScmVariantsOptions)
50
50
  }
51
51
  }
52
52
 
53
- interface CopyDirPreservingOptions {
54
- exclude?: string[];
55
- }
56
-
57
- async function copyDirPreserving(
58
- src: string,
59
- dest: string,
60
- options: CopyDirPreservingOptions = {}
61
- ): Promise<void> {
62
- const { exclude = [] } = options;
63
-
64
- await ensureDir(dest);
65
-
66
- const entries = await readdir(src, { withFileTypes: true });
67
- const oppositeExt = getOppositeScriptExtension();
68
-
69
- for (const entry of entries) {
70
- const srcPath = join(src, entry.name);
71
- const destPath = join(dest, entry.name);
72
-
73
- if (exclude.includes(entry.name)) continue;
74
- if (entry.name.endsWith(oppositeExt)) continue;
75
-
76
- if (entry.isDirectory()) {
77
- await copyDirPreserving(srcPath, destPath, options);
78
- } else {
79
- await copyFile(srcPath, destPath);
80
- }
81
- }
82
- }
83
-
84
53
  export interface SyncProjectScmSkillsOptions {
85
54
  scmType: SourceControlType;
86
55
  sourceSkillsDir: string;
@@ -106,7 +75,7 @@ export async function syncProjectScmSkills(options: SyncProjectScmSkillsOptions)
106
75
 
107
76
  const srcPath = join(sourceSkillsDir, entry.name);
108
77
  const destPath = join(targetSkillsDir, entry.name);
109
- await copyDirPreserving(srcPath, destPath);
78
+ await copyDir(srcPath, destPath, { ignoreFilter: createCommonIgnoreFilter() });
110
79
  copiedCount += 1;
111
80
  }
112
81
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Common gitignore-style filter for agent config copy operations.
3
+ *
4
+ * Uses the `ignore` package (gitignore-compatible glob matching) so that
5
+ * per-agent `exclude` lists only need to contain meaningful,
6
+ * domain-specific entries — OS junk, dependency dirs, lockfiles, and
7
+ * similar noise are handled here.
8
+ */
9
+
10
+ import ignore, { type Ignore } from "ignore";
11
+
12
+ /**
13
+ * Patterns that should never be copied during agent config operations.
14
+ *
15
+ * These mirror the most common entries found in a typical `.gitignore`
16
+ * and cover OS-generated files, dependency directories, lockfiles, and
17
+ * build artifacts.
18
+ */
19
+ const COMMON_IGNORE_PATTERNS: readonly string[] = [
20
+ // macOS
21
+ ".DS_Store",
22
+ "__MACOSX/",
23
+ "._*",
24
+
25
+ // Windows
26
+ "Thumbs.db",
27
+
28
+ // Dependencies
29
+ "node_modules/",
30
+
31
+ // Lockfiles
32
+ "bun.lock",
33
+
34
+ // Logs
35
+ "*.log",
36
+ ];
37
+
38
+ /**
39
+ * Create an {@link Ignore} filter pre-loaded with common gitignore
40
+ * patterns. Pass the returned instance as `ignoreFilter` in
41
+ * {@link CopyOptions} so agent-specific `exclude` lists stay focused
42
+ * on meaningful entries.
43
+ */
44
+ export function createCommonIgnoreFilter(): Ignore {
45
+ return ignore().add(COMMON_IGNORE_PATTERNS);
46
+ }
package/src/lib/merge.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Utilities for merging JSON configuration files
3
3
  */
4
4
 
5
- import { resolve } from "node:path";
5
+ import { resolve, dirname } from "node:path";
6
+ import { ensureDir, pathExists, copyFile } from "../services/system/copy.ts";
6
7
 
7
8
  type McpConfig = Record<string, unknown>;
8
9
 
@@ -49,3 +50,29 @@ export async function mergeJsonFile(
49
50
 
50
51
  await Bun.write(destPath, JSON.stringify(mergedConfig, null, 2) + "\n");
51
52
  }
53
+
54
+ /**
55
+ * Sync a JSON file from source to destination.
56
+ *
57
+ * - Creates the destination's parent directory if needed
58
+ * - When the destination exists and `merge` is true (the default),
59
+ * merges via {@link mergeJsonFile} (source keys win, server maps
60
+ * are merged individually)
61
+ * - Otherwise copies the source as-is
62
+ *
63
+ * This is the single entry-point for the merge-or-copy pattern used
64
+ * by both project-level onboarding and global config sync.
65
+ */
66
+ export async function syncJsonFile(
67
+ srcPath: string,
68
+ destPath: string,
69
+ merge: boolean = true,
70
+ ): Promise<void> {
71
+ await ensureDir(dirname(destPath));
72
+
73
+ if (merge && (await pathExists(destPath))) {
74
+ await mergeJsonFile(srcPath, destPath);
75
+ } else {
76
+ await copyFile(srcPath, destPath);
77
+ }
78
+ }