@bastani/atomic 0.5.0-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.
Files changed (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Init command - Interactive setup flow for atomic CLI
3
+ */
4
+
5
+ import {
6
+ intro,
7
+ outro,
8
+ select,
9
+ confirm,
10
+ spinner,
11
+ isCancel,
12
+ cancel,
13
+ note,
14
+ log,
15
+ } from "@clack/prompts";
16
+ import { join, resolve } from "path";
17
+
18
+ import {
19
+ AGENT_CONFIG,
20
+ type AgentKey,
21
+ getAgentKeys,
22
+ isValidAgent,
23
+ SCM_CONFIG,
24
+ type SourceControlType,
25
+ getScmKeys,
26
+ isValidScm,
27
+ } from "@/services/config/index.ts";
28
+ import { pathExists } from "@/services/system/copy.ts";
29
+ import { getConfigRoot } from "@/services/config/config-path.ts";
30
+ import { isWindows, isWslInstalled, WSL_INSTALL_URL } from "@/services/system/detect.ts";
31
+ import { saveAtomicConfig } from "@/services/config/atomic-config.ts";
32
+ import { upsertTrustedWorkspacePath } from "@/services/config/settings.ts";
33
+ import {
34
+ ensureAtomicGlobalAgentConfigs,
35
+ getTemplateAgentFolder,
36
+ } from "@/services/config/atomic-global-config.ts";
37
+ import {
38
+ getScmPrefix,
39
+ installLocalScmSkills,
40
+ reconcileScmVariants,
41
+ syncProjectScmSkills,
42
+ } from "./scm.ts";
43
+ import {
44
+ applyManagedOnboardingFiles,
45
+ hasProjectOnboardingFiles,
46
+ } from "./onboarding.ts";
47
+ import { supportsTrueColor, supports256Color, supportsColor } from "@/services/system/detect.ts";
48
+
49
+ const ATOMIC_BLOCK_LOGO = [
50
+ "█▀▀█ ▀▀█▀▀ █▀▀█ █▀▄▀█ ▀█▀ █▀▀",
51
+ "█▄▄█ █ █ █ █ ▀ █ █ █ ",
52
+ "▀ ▀ ▀ ▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀",
53
+ ];
54
+
55
+ // Catppuccin-inspired gradient (dark terminal)
56
+ const GRADIENT_DARK = [
57
+ "#f5e0dc", "#f2cdcd", "#f5c2e7", "#cba6f7",
58
+ "#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5",
59
+ ];
60
+
61
+ // Catppuccin-inspired gradient (light terminal)
62
+ const GRADIENT_LIGHT = [
63
+ "#dc8a78", "#dd7878", "#ea76cb", "#8839ef",
64
+ "#7287fd", "#1e66f5", "#209fb5", "#04a5e5", "#179299",
65
+ ];
66
+
67
+ // 256-color approximation of the gradient
68
+ const GRADIENT_256 = [224, 218, 219, 183, 147, 111, 117, 159, 115];
69
+
70
+ function hexToRgb(hex: string): [number, number, number] {
71
+ const h = hex.replace("#", "");
72
+ return [
73
+ parseInt(h.substring(0, 2), 16),
74
+ parseInt(h.substring(2, 4), 16),
75
+ parseInt(h.substring(4, 6), 16),
76
+ ];
77
+ }
78
+
79
+ function interpolateHex(gradient: string[], t: number): [number, number, number] {
80
+ const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
81
+ const lo = Math.floor(pos);
82
+ const hi = Math.min(lo + 1, gradient.length - 1);
83
+ const frac = pos - lo;
84
+ const [r1, g1, b1] = hexToRgb(gradient[lo]!);
85
+ const [r2, g2, b2] = hexToRgb(gradient[hi]!);
86
+ return [
87
+ Math.round(r1 + (r2 - r1) * frac),
88
+ Math.round(g1 + (g2 - g1) * frac),
89
+ Math.round(b1 + (b2 - b1) * frac),
90
+ ];
91
+ }
92
+
93
+ function interpolate256(gradient: number[], t: number): number {
94
+ const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
95
+ const lo = Math.floor(pos);
96
+ return gradient[lo]!;
97
+ }
98
+
99
+ function colorizeLineTrueColor(line: string, gradient: string[]): string {
100
+ let out = "";
101
+ const len = line.length;
102
+ for (let i = 0; i < len; i++) {
103
+ const ch = line[i]!;
104
+ if (ch === " ") {
105
+ out += ch;
106
+ continue;
107
+ }
108
+ const [r, g, b] = interpolateHex(gradient, len > 1 ? i / (len - 1) : 0);
109
+ out += `\x1b[38;2;${r};${g};${b}m${ch}`;
110
+ }
111
+ return out + "\x1b[0m";
112
+ }
113
+
114
+ function colorizeLine256(line: string, gradient: number[]): string {
115
+ let out = "";
116
+ const len = line.length;
117
+ for (let i = 0; i < len; i++) {
118
+ const ch = line[i]!;
119
+ if (ch === " ") {
120
+ out += ch;
121
+ continue;
122
+ }
123
+ const code = interpolate256(gradient, len > 1 ? i / (len - 1) : 0);
124
+ out += `\x1b[38;5;${code}m${ch}`;
125
+ }
126
+ return out + "\x1b[0m";
127
+ }
128
+
129
+ function displayBlockBanner(): void {
130
+ const isDark = !(process.env.COLORFGBG ?? "").startsWith("0;");
131
+ const truecolor = supportsTrueColor();
132
+ const color256 = supports256Color();
133
+ const hasColor = supportsColor();
134
+
135
+ console.log();
136
+ for (const line of ATOMIC_BLOCK_LOGO) {
137
+ if (truecolor) {
138
+ const gradient = isDark ? GRADIENT_DARK : GRADIENT_LIGHT;
139
+ console.log(` ${colorizeLineTrueColor(line, gradient)}`);
140
+ } else if (color256 && hasColor) {
141
+ console.log(` ${colorizeLine256(line, GRADIENT_256)}`);
142
+ } else {
143
+ console.log(` ${line}`);
144
+ }
145
+ }
146
+ console.log();
147
+ }
148
+
149
+ /**
150
+ * Thrown when the user cancels an interactive prompt during init.
151
+ *
152
+ * When `initCommand` is invoked from a caller that sets
153
+ * `callerHandlesExit: true` (e.g. the auto-init path inside
154
+ * `chatCommand`), cancellation throws this error instead of calling
155
+ * `process.exit(0)` so the caller can decide what to do.
156
+ */
157
+ export class InitCancelledError extends Error {
158
+ constructor(message = "Operation cancelled.") {
159
+ super(message);
160
+ this.name = "InitCancelledError";
161
+ }
162
+ }
163
+
164
+ interface InitOptions {
165
+ showBanner?: boolean;
166
+ preSelectedAgent?: AgentKey;
167
+ /** Pre-selected source control type (skip SCM selection prompt) */
168
+ preSelectedScm?: SourceControlType;
169
+ configNotFoundMessage?: string;
170
+ /** Force overwrite of preserved files (bypass preservation/merge logic) */
171
+ force?: boolean;
172
+ /** Auto-confirm all prompts (non-interactive mode for CI/testing) */
173
+ yes?: boolean;
174
+ /**
175
+ * When true, throw `InitCancelledError` instead of calling
176
+ * `process.exit()` on user cancellation. This allows callers like
177
+ * `chatCommand` auto-init to handle the cancellation gracefully.
178
+ */
179
+ callerHandlesExit?: boolean;
180
+ }
181
+
182
+ export {
183
+ applyManagedOnboardingFiles,
184
+ hasProjectOnboardingFiles,
185
+ } from "./onboarding.ts";
186
+ export {
187
+ getScmPrefix,
188
+ reconcileScmVariants,
189
+ } from "./scm.ts";
190
+
191
+ /**
192
+ * Run the interactive init command
193
+ */
194
+ export async function initCommand(options: InitOptions = {}): Promise<void> {
195
+ const { showBanner = true, configNotFoundMessage, callerHandlesExit = false } = options;
196
+
197
+ /** Exit-or-throw helper: when a caller (e.g. chatCommand auto-init) sets
198
+ * `callerHandlesExit`, we throw so the caller can handle the cancellation.
199
+ * Otherwise we call `process.exit()` directly (standalone `atomic init`). */
200
+ function exitOrThrow(code: number, message?: string): never {
201
+ if (callerHandlesExit) {
202
+ throw new InitCancelledError(message);
203
+ }
204
+ process.exit(code);
205
+ }
206
+
207
+ // Display banner
208
+ if (showBanner) {
209
+ displayBlockBanner();
210
+ }
211
+
212
+ // Show intro
213
+ intro("Atomic: Automated Procedures and Memory for AI Coding Agents");
214
+ log.message(
215
+ "Enable multi-hour autonomous coding sessions with the Ralph Wiggum\nMethod using research, plan, implement methodology."
216
+ );
217
+
218
+ // Show config not found message if provided (after intro, before agent selection)
219
+ if (configNotFoundMessage) {
220
+ log.info(configNotFoundMessage);
221
+ }
222
+
223
+ // Select agent
224
+ let agentKey: AgentKey;
225
+
226
+ if (options.preSelectedAgent) {
227
+ // Pre-selected agent - validate and skip selection prompt
228
+ if (!isValidAgent(options.preSelectedAgent)) {
229
+ cancel(`Unknown agent: ${options.preSelectedAgent}`);
230
+ exitOrThrow(1, `Unknown agent: ${options.preSelectedAgent}`);
231
+ }
232
+ agentKey = options.preSelectedAgent;
233
+ log.info(`Configuring ${AGENT_CONFIG[agentKey].name}...`);
234
+ } else {
235
+ // Interactive selection
236
+ const agentKeys = getAgentKeys();
237
+ const agentOptions = agentKeys.map((key) => ({
238
+ value: key,
239
+ label: AGENT_CONFIG[key].name,
240
+ hint: AGENT_CONFIG[key].install_url.replace("https://", ""),
241
+ }));
242
+
243
+ const selectedAgent = await select({
244
+ message: "Select a coding agent to configure:",
245
+ options: agentOptions,
246
+ });
247
+
248
+ if (isCancel(selectedAgent)) {
249
+ cancel("Operation cancelled.");
250
+ exitOrThrow(0);
251
+ }
252
+
253
+ agentKey = selectedAgent as AgentKey;
254
+ }
255
+ const agent = AGENT_CONFIG[agentKey];
256
+ const targetDir = process.cwd();
257
+
258
+ // Auto-confirm mode for CI/testing
259
+ const autoConfirm = options.yes ?? false;
260
+
261
+ // Select source control type (after agent selection)
262
+ let scmType: SourceControlType;
263
+
264
+ if (options.preSelectedScm) {
265
+ // Pre-selected SCM - validate and skip selection prompt
266
+ if (!isValidScm(options.preSelectedScm)) {
267
+ cancel(`Unknown source control: ${options.preSelectedScm}`);
268
+ exitOrThrow(1, `Unknown source control: ${options.preSelectedScm}`);
269
+ }
270
+ scmType = options.preSelectedScm;
271
+ log.info(`Using ${SCM_CONFIG[scmType].displayName} for source control...`);
272
+ } else if (autoConfirm) {
273
+ // Auto-confirm mode defaults to GitHub
274
+ scmType = "github";
275
+ log.info("Defaulting to GitHub/Git for source control...");
276
+ } else {
277
+ // Interactive selection
278
+ const scmOptions = getScmKeys().map((key) => ({
279
+ value: key,
280
+ label: SCM_CONFIG[key].displayName,
281
+ hint: `Uses ${SCM_CONFIG[key].cliTool} + ${SCM_CONFIG[key].reviewSystem}`,
282
+ }));
283
+
284
+ const selectedScm = await select({
285
+ message: "Select your source control system:",
286
+ options: scmOptions,
287
+ });
288
+
289
+ if (isCancel(selectedScm)) {
290
+ cancel("Operation cancelled.");
291
+ exitOrThrow(0);
292
+ }
293
+
294
+ scmType = selectedScm as SourceControlType;
295
+ }
296
+
297
+ // Show Phabricator configuration warning if Sapling is selected
298
+ if (scmType === "sapling") {
299
+ const arcconfigPath = join(targetDir, ".arcconfig");
300
+ const hasArcconfig = await pathExists(arcconfigPath);
301
+
302
+ if (!hasArcconfig) {
303
+ log.warn(
304
+ "Note: Sapling + Phabricator requires .arcconfig in your repository root.\n" +
305
+ "See: https://www.phacility.com/phabricator/ for Phabricator setup."
306
+ );
307
+ }
308
+ }
309
+
310
+ // Confirm directory
311
+ let confirmDir: boolean | symbol = true;
312
+ if (!autoConfirm) {
313
+ confirmDir = await confirm({
314
+ message: `Configure ${agent.name} source control skills in ${targetDir}?`,
315
+ initialValue: true,
316
+ });
317
+
318
+ if (isCancel(confirmDir)) {
319
+ cancel("Operation cancelled.");
320
+ exitOrThrow(0);
321
+ }
322
+
323
+ if (!confirmDir) {
324
+ cancel("Operation cancelled.");
325
+ exitOrThrow(0);
326
+ }
327
+ }
328
+
329
+ // Check if folder already exists
330
+ const targetFolder = join(targetDir, agent.folder);
331
+ const folderExists = await pathExists(targetFolder);
332
+
333
+ // --force bypasses update confirmation prompts.
334
+ const shouldForce = options.force ?? false;
335
+
336
+ if (folderExists && !shouldForce && !autoConfirm) {
337
+ const update = await confirm({
338
+ message: `${agent.folder} already exists. Update source control skills?`,
339
+ initialValue: true,
340
+ active: "Yes, update",
341
+ inactive: "No, cancel",
342
+ });
343
+
344
+ if (isCancel(update)) {
345
+ cancel("Operation cancelled.");
346
+ exitOrThrow(0);
347
+ }
348
+
349
+ if (!update) {
350
+ cancel("Operation cancelled. Existing config preserved.");
351
+ exitOrThrow(0);
352
+ }
353
+ }
354
+
355
+ // Configure source control skills with spinner
356
+ const s = spinner();
357
+ s.start("Configuring source control skills...");
358
+
359
+ try {
360
+ const configRoot = getConfigRoot();
361
+
362
+ await ensureAtomicGlobalAgentConfigs(configRoot);
363
+
364
+ const templateAgentFolder = getTemplateAgentFolder(agentKey);
365
+ const sourceSkillsDir = join(configRoot, templateAgentFolder, "skills");
366
+ const targetSkillsDir = join(targetFolder, "skills");
367
+
368
+ // Best-effort template copy: source checkouts still carry the bundled
369
+ // gh-*/sl-* skill templates, but binary and npm installs no longer do
370
+ // (they live in the skills CLI repo). `installLocalScmSkills` below
371
+ // handles the binary/npm case by invoking `npx skills add` — so a zero
372
+ // copy here is not an error, just a signal that the template isn't
373
+ // bundled for this install type.
374
+ await syncProjectScmSkills({
375
+ scmType,
376
+ sourceSkillsDir,
377
+ targetSkillsDir,
378
+ });
379
+
380
+ // Keep SCM-specific managed command/skill variants aligned with selected SCM
381
+ await reconcileScmVariants({
382
+ scmType,
383
+ agentFolder: agent.folder,
384
+ skillsSubfolder: "skills",
385
+ targetDir,
386
+ configRoot,
387
+ });
388
+
389
+ await applyManagedOnboardingFiles(agentKey, targetDir, configRoot);
390
+
391
+ // Save SCM selection to .atomic/settings.json
392
+ await saveAtomicConfig(targetDir, {
393
+ scm: scmType,
394
+ });
395
+ upsertTrustedWorkspacePath(resolve(targetDir), agentKey);
396
+
397
+ s.stop("Source control skills configured successfully!");
398
+
399
+ // Install SCM-specific skill variants locally for the active agent via
400
+ // `npx skills add` (best-effort: a failure is surfaced as a warning).
401
+ //
402
+ // Source checkouts already have the bundled skills on disk and the
403
+ // template-copy above has placed the selected variants into `targetDir`;
404
+ // skip the network-backed skills CLI in that case to keep dev iteration
405
+ // fast and offline-friendly.
406
+ if (import.meta.dir.includes("node_modules")) {
407
+ const skillsSpinner = spinner();
408
+ skillsSpinner.start(
409
+ `Installing ${getScmPrefix(scmType)}* skills locally for ${agent.name}...`,
410
+ );
411
+ const skillsResult = await installLocalScmSkills({
412
+ scmType,
413
+ agentKey,
414
+ cwd: targetDir,
415
+ });
416
+ if (skillsResult.success) {
417
+ skillsSpinner.stop(
418
+ `Installed ${getScmPrefix(scmType)}* skills locally for ${agent.name}`,
419
+ );
420
+ } else {
421
+ skillsSpinner.stop(
422
+ `Skipped local ${getScmPrefix(scmType)}* skills install (${skillsResult.details})`,
423
+ );
424
+ }
425
+ }
426
+ } catch (error) {
427
+ s.stop("Failed to configure source control skills");
428
+ console.error(
429
+ error instanceof Error ? error.message : "Unknown error occurred"
430
+ );
431
+ exitOrThrow(1, error instanceof Error ? error.message : "Unknown error occurred");
432
+ }
433
+
434
+ // Check for WSL on Windows
435
+ if (isWindows() && !isWslInstalled()) {
436
+ note(
437
+ `WSL is not installed. Some scripts may require WSL.\n` +
438
+ `Install WSL: ${WSL_INSTALL_URL}`,
439
+ "Warning"
440
+ );
441
+ }
442
+
443
+ // Success message
444
+ note(
445
+ `${agent.name} source control skills configured in ${agent.folder}/skills\n\n` +
446
+ `Selected workflow: ${SCM_CONFIG[scmType].displayName}\n\n` +
447
+ `Run '${agent.cmd}' to start the agent.`,
448
+ "Success"
449
+ );
450
+
451
+ outro("You're all set!");
452
+ }
@@ -0,0 +1,45 @@
1
+ import { dirname, join } from "path";
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";
5
+
6
+ export async function applyManagedOnboardingFiles(
7
+ agentKey: AgentKey,
8
+ projectRoot: string,
9
+ configRoot: string,
10
+ ): Promise<void> {
11
+ const onboardingFiles = AGENT_CONFIG[agentKey].onboarding_files;
12
+
13
+ for (const managedFile of onboardingFiles) {
14
+ const sourcePath = join(configRoot, managedFile.source);
15
+ if (!(await pathExists(sourcePath))) {
16
+ continue;
17
+ }
18
+
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
+ }
27
+ }
28
+ }
29
+
30
+ export async function hasProjectOnboardingFiles(
31
+ agentKey: AgentKey,
32
+ projectRoot: string,
33
+ ): Promise<boolean> {
34
+ const onboardingFiles = AGENT_CONFIG[agentKey].onboarding_files;
35
+ if (onboardingFiles.length === 0) {
36
+ return true;
37
+ }
38
+
39
+ const checks = await Promise.all(
40
+ onboardingFiles.map((managedFile) =>
41
+ pathExists(join(projectRoot, managedFile.destination))
42
+ ),
43
+ );
44
+ return checks.every(Boolean);
45
+ }
@@ -0,0 +1,190 @@
1
+ import { join } from "path";
2
+ import { readdir } from "fs/promises";
3
+ import { copyFile, pathExists, ensureDir } from "@/services/system/copy.ts";
4
+ import { getOppositeScriptExtension } from "@/services/system/detect.ts";
5
+ import type { AgentKey, SourceControlType } from "@/services/config/index.ts";
6
+
7
+ export const SCM_PREFIX_BY_TYPE: Record<SourceControlType, "gh-" | "sl-"> = {
8
+ github: "gh-",
9
+ sapling: "sl-",
10
+ };
11
+
12
+ export function getScmPrefix(scmType: SourceControlType): "gh-" | "sl-" {
13
+ return SCM_PREFIX_BY_TYPE[scmType];
14
+ }
15
+
16
+ export function isManagedScmEntry(name: string): boolean {
17
+ return name.startsWith("gh-") || name.startsWith("sl-");
18
+ }
19
+
20
+ export interface ReconcileScmVariantsOptions {
21
+ scmType: SourceControlType;
22
+ agentFolder: string;
23
+ skillsSubfolder: string;
24
+ targetDir: string;
25
+ configRoot: string;
26
+ }
27
+
28
+ export async function reconcileScmVariants(options: ReconcileScmVariantsOptions): Promise<void> {
29
+ const { agentFolder, skillsSubfolder, targetDir, configRoot } = options;
30
+ const srcDir = join(configRoot, agentFolder, skillsSubfolder);
31
+ const destDir = join(targetDir, agentFolder, skillsSubfolder);
32
+
33
+ if (!(await pathExists(srcDir)) || !(await pathExists(destDir))) {
34
+ return;
35
+ }
36
+
37
+ const sourceEntries = await readdir(srcDir, { withFileTypes: true });
38
+ const managedEntries = sourceEntries.filter((entry) => isManagedScmEntry(entry.name));
39
+
40
+ if (process.env.DEBUG === "1" && managedEntries.length > 0) {
41
+ console.log(
42
+ `[DEBUG] Preserving existing managed SCM variants in ${destDir}: ${managedEntries
43
+ .map((entry) => entry.name)
44
+ .join(", ")}`
45
+ );
46
+ }
47
+ }
48
+
49
+ interface CopyDirPreservingOptions {
50
+ exclude?: string[];
51
+ }
52
+
53
+ async function copyDirPreserving(
54
+ src: string,
55
+ dest: string,
56
+ options: CopyDirPreservingOptions = {}
57
+ ): Promise<void> {
58
+ const { exclude = [] } = options;
59
+
60
+ await ensureDir(dest);
61
+
62
+ const entries = await readdir(src, { withFileTypes: true });
63
+ const oppositeExt = getOppositeScriptExtension();
64
+
65
+ for (const entry of entries) {
66
+ const srcPath = join(src, entry.name);
67
+ const destPath = join(dest, entry.name);
68
+
69
+ if (exclude.includes(entry.name)) continue;
70
+ if (entry.name.endsWith(oppositeExt)) continue;
71
+
72
+ if (entry.isDirectory()) {
73
+ await copyDirPreserving(srcPath, destPath, options);
74
+ } else {
75
+ await copyFile(srcPath, destPath);
76
+ }
77
+ }
78
+ }
79
+
80
+ export interface SyncProjectScmSkillsOptions {
81
+ scmType: SourceControlType;
82
+ sourceSkillsDir: string;
83
+ targetSkillsDir: string;
84
+ }
85
+
86
+ export async function syncProjectScmSkills(options: SyncProjectScmSkillsOptions): Promise<number> {
87
+ const { scmType, sourceSkillsDir, targetSkillsDir } = options;
88
+ const selectedPrefix = getScmPrefix(scmType);
89
+
90
+ if (!(await pathExists(sourceSkillsDir))) {
91
+ return 0;
92
+ }
93
+
94
+ await ensureDir(targetSkillsDir);
95
+
96
+ const entries = await readdir(sourceSkillsDir, { withFileTypes: true });
97
+ let copiedCount = 0;
98
+
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) continue;
101
+ if (!entry.name.startsWith(selectedPrefix)) continue;
102
+
103
+ const srcPath = join(sourceSkillsDir, entry.name);
104
+ const destPath = join(targetSkillsDir, entry.name);
105
+ await copyDirPreserving(srcPath, destPath);
106
+ copiedCount += 1;
107
+ }
108
+
109
+ return copiedCount;
110
+ }
111
+
112
+ /** Skills-CLI agent identifiers (match `npx skills -a <value>`). */
113
+ const SKILLS_AGENT_BY_KEY: Record<AgentKey, string> = {
114
+ claude: "claude-code",
115
+ opencode: "opencode",
116
+ copilot: "github-copilot",
117
+ };
118
+
119
+ const SKILLS_REPO = "https://github.com/flora131/atomic.git";
120
+
121
+ export interface InstallLocalScmSkillsOptions {
122
+ scmType: SourceControlType;
123
+ agentKey: AgentKey;
124
+ /** The directory to run `npx skills add` in (the project root). */
125
+ cwd: string;
126
+ }
127
+
128
+ export interface InstallLocalScmSkillsResult {
129
+ success: boolean;
130
+ /** Non-empty when `success` is false. */
131
+ details: string;
132
+ }
133
+
134
+ /**
135
+ * Install the SCM skill variants (gh-* or sl-*) locally into the current
136
+ * project via `npx skills add`. The `-g` flag is intentionally omitted so
137
+ * the skills are installed per-project (in the given `cwd`).
138
+ *
139
+ * This is best-effort: callers should treat a failed result as a warning,
140
+ * not as a fatal error.
141
+ */
142
+ export async function installLocalScmSkills(
143
+ options: InstallLocalScmSkillsOptions,
144
+ ): Promise<InstallLocalScmSkillsResult> {
145
+ const { scmType, agentKey, cwd } = options;
146
+
147
+ const npxPath = Bun.which("npx");
148
+ if (!npxPath) {
149
+ return { success: false, details: "npx not found on PATH" };
150
+ }
151
+
152
+ const pattern = `${getScmPrefix(scmType)}*`;
153
+ const agentFlag = SKILLS_AGENT_BY_KEY[agentKey];
154
+
155
+ try {
156
+ const proc = Bun.spawn({
157
+ cmd: [
158
+ npxPath,
159
+ "--yes",
160
+ "skills",
161
+ "add",
162
+ SKILLS_REPO,
163
+ "--skill",
164
+ pattern,
165
+ "-a",
166
+ agentFlag,
167
+ "-y",
168
+ ],
169
+ cwd,
170
+ stdout: "pipe",
171
+ stderr: "pipe",
172
+ env: process.env,
173
+ });
174
+ const [stderr, stdout, exitCode] = await Promise.all([
175
+ new Response(proc.stderr).text(),
176
+ new Response(proc.stdout).text(),
177
+ proc.exited,
178
+ ]);
179
+ if (exitCode === 0) {
180
+ return { success: true, details: "" };
181
+ }
182
+ const details = stderr.trim().length > 0 ? stderr.trim() : stdout.trim();
183
+ return { success: false, details: details || `exit code ${exitCode}` };
184
+ } catch (error) {
185
+ return {
186
+ success: false,
187
+ details: error instanceof Error ? error.message : String(error),
188
+ };
189
+ }
190
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compatibility barrel for the init CLI command.
3
+ *
4
+ * The implementation now lives under `commands/cli/init/`, while the
5
+ * historical `commands/cli/init.ts` path remains stable.
6
+ */
7
+
8
+ export * from "@/commands/cli/init/index.ts";