@bastani/atomic 0.5.0-1 → 0.5.0-2

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 (67) hide show
  1. package/.atomic/workflows/hello/claude/index.ts +44 -0
  2. package/.atomic/workflows/hello/copilot/index.ts +58 -0
  3. package/.atomic/workflows/hello/opencode/index.ts +58 -0
  4. package/.atomic/workflows/hello-parallel/claude/index.ts +76 -0
  5. package/.atomic/workflows/hello-parallel/copilot/index.ts +105 -0
  6. package/.atomic/workflows/hello-parallel/opencode/index.ts +115 -0
  7. package/.atomic/workflows/ralph/claude/index.ts +149 -0
  8. package/.atomic/workflows/ralph/copilot/index.ts +162 -0
  9. package/.atomic/workflows/ralph/helpers/git.ts +34 -0
  10. package/.atomic/workflows/ralph/helpers/prompts.ts +538 -0
  11. package/.atomic/workflows/ralph/helpers/review.ts +32 -0
  12. package/.atomic/workflows/ralph/opencode/index.ts +164 -0
  13. package/.atomic/workflows/tsconfig.json +22 -0
  14. package/.claude/agents/code-simplifier.md +52 -0
  15. package/.claude/agents/codebase-analyzer.md +166 -0
  16. package/.claude/agents/codebase-locator.md +122 -0
  17. package/.claude/agents/codebase-online-researcher.md +148 -0
  18. package/.claude/agents/codebase-pattern-finder.md +247 -0
  19. package/.claude/agents/codebase-research-analyzer.md +179 -0
  20. package/.claude/agents/codebase-research-locator.md +145 -0
  21. package/.claude/agents/debugger.md +91 -0
  22. package/.claude/agents/orchestrator.md +19 -0
  23. package/.claude/agents/planner.md +106 -0
  24. package/.claude/agents/reviewer.md +97 -0
  25. package/.claude/agents/worker.md +165 -0
  26. package/.github/agents/code-simplifier.md +52 -0
  27. package/.github/agents/codebase-analyzer.md +166 -0
  28. package/.github/agents/codebase-locator.md +122 -0
  29. package/.github/agents/codebase-online-researcher.md +146 -0
  30. package/.github/agents/codebase-pattern-finder.md +247 -0
  31. package/.github/agents/codebase-research-analyzer.md +179 -0
  32. package/.github/agents/codebase-research-locator.md +145 -0
  33. package/.github/agents/debugger.md +98 -0
  34. package/.github/agents/orchestrator.md +27 -0
  35. package/.github/agents/planner.md +131 -0
  36. package/.github/agents/reviewer.md +94 -0
  37. package/.github/agents/worker.md +237 -0
  38. package/.github/lsp.json +93 -0
  39. package/.opencode/agents/code-simplifier.md +62 -0
  40. package/.opencode/agents/codebase-analyzer.md +171 -0
  41. package/.opencode/agents/codebase-locator.md +127 -0
  42. package/.opencode/agents/codebase-online-researcher.md +152 -0
  43. package/.opencode/agents/codebase-pattern-finder.md +252 -0
  44. package/.opencode/agents/codebase-research-analyzer.md +183 -0
  45. package/.opencode/agents/codebase-research-locator.md +149 -0
  46. package/.opencode/agents/debugger.md +99 -0
  47. package/.opencode/agents/orchestrator.md +27 -0
  48. package/.opencode/agents/planner.md +146 -0
  49. package/.opencode/agents/reviewer.md +102 -0
  50. package/.opencode/agents/worker.md +165 -0
  51. package/README.md +355 -299
  52. package/assets/settings.schema.json +0 -5
  53. package/package.json +7 -2
  54. package/src/cli.ts +16 -8
  55. package/src/commands/cli/workflow.ts +209 -15
  56. package/src/lib/spawn.ts +106 -31
  57. package/src/sdk/runtime/loader.ts +1 -1
  58. package/src/services/config/config-path.ts +1 -1
  59. package/src/services/config/settings.ts +0 -9
  60. package/src/services/system/agents.ts +94 -0
  61. package/src/services/system/auto-sync.ts +131 -0
  62. package/src/services/system/install-ui.ts +158 -0
  63. package/src/services/system/skills.ts +26 -17
  64. package/src/services/system/workflows.ts +105 -0
  65. package/src/theme/colors.ts +2 -0
  66. package/src/commands/cli/update.ts +0 -46
  67. package/src/services/system/download.ts +0 -325
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Merge-copy bundled Atomic agents from the installed package into the
3
+ * provider-native global roots.
4
+ *
5
+ * Mirrors `install_global_agents()` from the production install.sh /
6
+ * install.ps1 bootstrap installers. The bundled agent definitions ship
7
+ * with the npm package (see the `files` array in package.json) at:
8
+ *
9
+ * <pkg-root>/.claude/agents → ~/.claude/agents
10
+ * <pkg-root>/.opencode/agents → ~/.opencode/agents
11
+ * <pkg-root>/.github/agents → ~/.copilot/agents (rename: github → copilot)
12
+ * <pkg-root>/.github/lsp.json → ~/.copilot/lsp-config.json (rename per atomic-global-config.ts)
13
+ *
14
+ * Copy semantics: files sharing a name with a bundled file are overwritten;
15
+ * unrelated user-added files in those directories are preserved (the
16
+ * `copyDir()` helper iterates source entries, so anything not in the
17
+ * source is left untouched).
18
+ */
19
+
20
+ import { join, dirname } from "path";
21
+ import { homedir } from "os";
22
+ import {
23
+ copyDir,
24
+ copyFile,
25
+ ensureDir,
26
+ pathExists,
27
+ } from "@/services/system/copy.ts";
28
+
29
+ /**
30
+ * Locate the package root by walking up from this module. Both in installed
31
+ * (`<pkg>/src/services/system/agents.ts`) and dev checkout layouts the
32
+ * package root is three directories up.
33
+ */
34
+ function packageRoot(): string {
35
+ return join(import.meta.dir, "..", "..", "..");
36
+ }
37
+
38
+ /** Honors ATOMIC_SETTINGS_HOME so tests can point at a temp dir. */
39
+ function homeRoot(): string {
40
+ return process.env.ATOMIC_SETTINGS_HOME ?? homedir();
41
+ }
42
+
43
+ interface AgentSyncPair {
44
+ /** Source path relative to package root. */
45
+ src: string;
46
+ /** Destination path relative to home root. */
47
+ dest: string;
48
+ }
49
+
50
+ const AGENT_DIR_PAIRS: AgentSyncPair[] = [
51
+ { src: ".claude/agents", dest: ".claude/agents" },
52
+ { src: ".opencode/agents", dest: ".opencode/agents" },
53
+ { src: ".github/agents", dest: ".copilot/agents" },
54
+ ];
55
+
56
+ /**
57
+ * Sync bundled agents and the copilot lsp-config to the provider global
58
+ * roots. Throws on hard failures (a single sync failing); missing source
59
+ * directories are warned about and skipped, not thrown.
60
+ */
61
+ export async function installGlobalAgents(): Promise<void> {
62
+ const pkg = packageRoot();
63
+ const home = homeRoot();
64
+
65
+ const warnings: string[] = [];
66
+ for (const pair of AGENT_DIR_PAIRS) {
67
+ const src = join(pkg, pair.src);
68
+ const dest = join(home, pair.dest);
69
+
70
+ if (!(await pathExists(src))) {
71
+ warnings.push(`bundled agents missing at ${src} — skipping ${dest}`);
72
+ continue;
73
+ }
74
+
75
+ await copyDir(src, dest);
76
+ }
77
+
78
+ // Surface skipped sources via a non-fatal thrown error only if ALL sources
79
+ // were missing. A partial miss (e.g. one provider folder absent in a dev
80
+ // checkout) is normal and shouldn't mark the step as failed. The spinner
81
+ // UI shows the thrown message beneath the failing row.
82
+ if (warnings.length === AGENT_DIR_PAIRS.length) {
83
+ throw new Error(warnings.join("\n"));
84
+ }
85
+
86
+ // Copilot's lsp.json is renamed to ~/.copilot/lsp-config.json on disk
87
+ // (see atomic-global-config.ts for the in-binary rename rationale).
88
+ const lspSrc = join(pkg, ".github", "lsp.json");
89
+ const lspDest = join(home, ".copilot", "lsp-config.json");
90
+ if (await pathExists(lspSrc)) {
91
+ await ensureDir(dirname(lspDest));
92
+ await copyFile(lspSrc, lspDest);
93
+ }
94
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Lazy first-run sync of tooling deps, bundled agents, and global skills.
3
+ *
4
+ * Why this exists: bun's package manager does NOT execute the top-level
5
+ * package's `postinstall` script on `bun add -g` / `bun update -g` — see
6
+ * `src/install/PackageManager/install_with_manager.zig` (the
7
+ * `!manager.options.global` guard around root lifecycle scripts). So
8
+ * there's no install-time hook we can register from `package.json`.
9
+ *
10
+ * Instead, we detect a fresh install or upgrade lazily on CLI startup by
11
+ * comparing the bundled `VERSION` constant against a marker file at
12
+ * `~/.atomic/.synced-version`. On a mismatch we run the same setup the
13
+ * production bootstrap installers (`install.sh` / `install.ps1`) provide:
14
+ *
15
+ * 1. Node.js / npm (gates everything that needs npm/npx)
16
+ * 2. tmux / psmux (terminal multiplexer for `chat` and `workflow`)
17
+ * 3. @playwright/cli (global npm package used by skills)
18
+ * 4. @llamaindex/liteparse (global npm package used by skills)
19
+ * 5. global agent configs (~/.claude/agents, ~/.opencode/agents, ~/.copilot/agents, lsp-config.json)
20
+ * 6. global workflows (~/.atomic/workflows/{hello,hello-parallel,ralph,...})
21
+ * 7. global skills (npx skills add ...)
22
+ *
23
+ * Each step runs sequentially. Failures are collected and reported as a
24
+ * summary at the end, but never abort the run — partial setup matches the
25
+ * production installer's "best-effort" semantics. The marker is written
26
+ * after every run (success or partial) so we don't re-attempt the whole
27
+ * setup on every subsequent launch.
28
+ */
29
+
30
+ import { join } from "path";
31
+ import { homedir } from "os";
32
+ import { VERSION } from "@/version.ts";
33
+ import { COLORS } from "@/theme/colors.ts";
34
+ import {
35
+ ensureNpmInstalled,
36
+ ensureTmuxInstalled,
37
+ upgradePlaywrightCli,
38
+ upgradeLiteparse,
39
+ } from "@/lib/spawn.ts";
40
+ import { installGlobalAgents } from "@/services/system/agents.ts";
41
+ import { installGlobalWorkflows } from "@/services/system/workflows.ts";
42
+ import { installGlobalSkills } from "@/services/system/skills.ts";
43
+ import { runSteps, printSummary } from "@/services/system/install-ui.ts";
44
+
45
+ /** Path to the version marker. Honors ATOMIC_SETTINGS_HOME for tests. */
46
+ function syncMarkerPath(): string {
47
+ const home = process.env.ATOMIC_SETTINGS_HOME ?? homedir();
48
+ return join(home, ".atomic", ".synced-version");
49
+ }
50
+
51
+ /**
52
+ * True when running from an installed package (under `node_modules/`),
53
+ * false on a dev checkout. Avoids triggering a full global setup on every
54
+ * `bun run dev` in the repo.
55
+ */
56
+ function isInstalledPackage(): boolean {
57
+ return import.meta.dir.includes("node_modules");
58
+ }
59
+
60
+ /**
61
+ * Write the version marker. Best-effort: a failed write just means the
62
+ * next launch will re-sync, which is wasteful but not broken.
63
+ */
64
+ export async function markSynced(): Promise<void> {
65
+ try {
66
+ await Bun.write(syncMarkerPath(), VERSION);
67
+ } catch {
68
+ // Swallow — see docstring.
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Sync tooling deps, bundled agents, and global skills if the marker
74
+ * doesn't match the bundled VERSION. No-op in dev checkouts and when the
75
+ * marker already matches the current version.
76
+ */
77
+ export async function autoSyncIfStale(): Promise<void> {
78
+ if (!isInstalledPackage()) return;
79
+
80
+ let stored = "";
81
+ const marker = Bun.file(syncMarkerPath());
82
+ if (await marker.exists()) {
83
+ stored = (await marker.text()).trim();
84
+ }
85
+
86
+ if (stored === VERSION) return;
87
+
88
+ console.log(
89
+ `\n ${COLORS.dim}Setting up atomic ${COLORS.reset}${COLORS.bold}v${VERSION}${COLORS.reset}${COLORS.dim}…${COLORS.reset}\n`,
90
+ );
91
+
92
+ // Ordering notes:
93
+ // - Phase 1 (npm, tmux): core tools. npm comes first because
94
+ // @playwright/cli, @llamaindex/liteparse, and `npx skills` all need it.
95
+ // tmux/psmux is independent but installed sequentially to avoid
96
+ // contention with system package-manager locks (apt-get, dnf, etc).
97
+ // - Phase 2 (global npm packages): will fail loudly if Phase 1's npm
98
+ // install failed, which is fine — the spinner summary records their
99
+ // failure too.
100
+ // - Phase 3 (bundled atomic content): file copies + npx skills install.
101
+ //
102
+ // Each step's failure is caught inside `runSteps` (not thrown), so
103
+ // subsequent steps still run even if one fails — matches install.sh's
104
+ // best-effort contract.
105
+ const results = await runSteps([
106
+ { label: "Node.js / npm", fn: () => ensureNpmInstalled({ quiet: true }) },
107
+ { label: "tmux / psmux", fn: () => ensureTmuxInstalled({ quiet: true }) },
108
+ { label: "@playwright/cli", fn: upgradePlaywrightCli },
109
+ { label: "@llamaindex/liteparse", fn: upgradeLiteparse },
110
+ { label: "global agent configs", fn: installGlobalAgents },
111
+ { label: "global workflows", fn: installGlobalWorkflows },
112
+ { label: "global skills", fn: installGlobalSkills },
113
+ ]);
114
+
115
+ // Always write the marker — partial setup is the production-installer
116
+ // contract. Re-running the bootstrap installer or `bun update -g
117
+ // @bastani/atomic` is the recovery path for a failed setup.
118
+ await markSynced();
119
+
120
+ console.log();
121
+ printSummary(results);
122
+
123
+ const failures = results.filter((r) => !r.ok);
124
+ if (failures.length > 0) {
125
+ console.log(
126
+ `\n ${COLORS.dim}Re-run \`bun install -g @bastani/atomic\` after resolving the issues to retry.${COLORS.reset}\n`,
127
+ );
128
+ } else {
129
+ console.log();
130
+ }
131
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Progress UI primitives for the first-run install flow (auto-sync).
3
+ *
4
+ * Renders a single persistent line:
5
+ *
6
+ * ⠋ [██████▒▒▒▒▒▒] 3/7 tmux / psmux
7
+ *
8
+ * where the braille spinner animation is provided by `@clack/prompts`
9
+ * and the bracketed bar tracks overall step progress (completed / total).
10
+ * A final summary (✓/✗ per step) is printed after all steps finish, and
11
+ * any captured stderr/stdout from a failed step is shown beneath it.
12
+ *
13
+ * Kept intentionally small — this is not a general-purpose progress
14
+ * library, just what auto-sync needs to stop being visually noisy.
15
+ */
16
+
17
+ import { spinner } from "@clack/prompts";
18
+ import { COLORS } from "@/theme/colors.ts";
19
+
20
+ const BAR_WIDTH = 18;
21
+ const BAR_FILLED = "█";
22
+ const BAR_EMPTY = "░";
23
+
24
+ /**
25
+ * Semantic bar states:
26
+ * progress → blue (Catppuccin accent; "in flight")
27
+ * success → green (universal "completed")
28
+ * error → red (universal "failed")
29
+ *
30
+ * The empty track stays dim regardless — only the filled portion carries
31
+ * the status signal, which keeps the bar legible while still telegraphing
32
+ * the outcome at a glance.
33
+ */
34
+ type BarState = "progress" | "success" | "error";
35
+
36
+ function fillColor(state: BarState): string {
37
+ switch (state) {
38
+ case "success":
39
+ return COLORS.green;
40
+ case "error":
41
+ return COLORS.red;
42
+ case "progress":
43
+ default:
44
+ return COLORS.blue;
45
+ }
46
+ }
47
+
48
+ /** Render a bracketed step-progress bar. `completed` is capped at `total`. */
49
+ function renderBar(
50
+ completed: number,
51
+ total: number,
52
+ state: BarState,
53
+ ): string {
54
+ const safeTotal = Math.max(1, total);
55
+ const ratio = Math.max(0, Math.min(1, completed / safeTotal));
56
+ const filled = Math.round(ratio * BAR_WIDTH);
57
+ const empty = BAR_WIDTH - filled;
58
+ return (
59
+ COLORS.bold +
60
+ fillColor(state) +
61
+ BAR_FILLED.repeat(filled) +
62
+ COLORS.reset +
63
+ COLORS.dim +
64
+ BAR_EMPTY.repeat(empty) +
65
+ COLORS.reset
66
+ );
67
+ }
68
+
69
+ function formatLine(
70
+ completed: number,
71
+ total: number,
72
+ label: string,
73
+ state: BarState = "progress",
74
+ ): string {
75
+ const bar = renderBar(completed, total, state);
76
+ const counter = `${COLORS.dim}${completed}/${total}${COLORS.reset}`;
77
+ return `${bar} ${counter} ${label}`;
78
+ }
79
+
80
+ export interface StepResult {
81
+ label: string;
82
+ ok: boolean;
83
+ /** Error message (if any) surfaced in the final summary. */
84
+ error?: string;
85
+ }
86
+
87
+ /**
88
+ * Runs a sequence of async steps with a single persistent spinner line
89
+ * showing stepped progress. Each step's failure is collected rather than
90
+ * thrown, mirroring auto-sync's "best-effort" contract.
91
+ *
92
+ * Returns the per-step results in submission order so the caller can
93
+ * render a summary.
94
+ */
95
+ export async function runSteps(
96
+ steps: Array<{ label: string; fn: () => Promise<unknown> }>,
97
+ ): Promise<StepResult[]> {
98
+ const total = steps.length;
99
+ const results: StepResult[] = [];
100
+ const s = spinner();
101
+
102
+ // Start with 0/total so the user sees the bar immediately.
103
+ s.start(formatLine(0, total, steps[0]?.label ?? ""));
104
+
105
+ for (let i = 0; i < steps.length; i++) {
106
+ const step = steps[i]!;
107
+ s.message(formatLine(i, total, step.label));
108
+
109
+ try {
110
+ await step.fn();
111
+ results.push({ label: step.label, ok: true });
112
+ } catch (error) {
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ results.push({ label: step.label, ok: false, error: message });
115
+ }
116
+ }
117
+
118
+ // Stop with a filled bar + final label so the user sees we reached the
119
+ // end rather than leaving the last in-flight step pinned. Bar color
120
+ // flips to the universal status colors: green when every step
121
+ // succeeded, red when any step failed. The per-step ✓/✗ rows printed
122
+ // afterwards provide the detailed breakdown.
123
+ const okCount = results.filter((r) => r.ok).length;
124
+ const allOk = okCount === total;
125
+ const finalLabel = allOk
126
+ ? `${COLORS.green}Setup complete${COLORS.reset}`
127
+ : `${COLORS.red}Setup finished with errors${COLORS.reset}`;
128
+ s.stop(formatLine(total, total, finalLabel, allOk ? "success" : "error"));
129
+
130
+ return results;
131
+ }
132
+
133
+ /**
134
+ * Print a compact per-step summary after `runSteps`. Successes render as
135
+ * a single dim line; failures render with a red cross and an indented
136
+ * excerpt of the captured error.
137
+ */
138
+ export function printSummary(results: StepResult[]): void {
139
+ for (const result of results) {
140
+ if (result.ok) {
141
+ console.log(
142
+ ` ${COLORS.green}✓${COLORS.reset} ${COLORS.dim}${result.label}${COLORS.reset}`,
143
+ );
144
+ } else {
145
+ console.log(
146
+ ` ${COLORS.red}✗${COLORS.reset} ${result.label}`,
147
+ );
148
+ if (result.error) {
149
+ // Indent the first ~4 lines of the error so it reads as a nested
150
+ // block rather than wall-of-text.
151
+ const lines = result.error.split("\n").slice(0, 4);
152
+ for (const line of lines) {
153
+ console.log(` ${COLORS.dim}${line}${COLORS.reset}`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
@@ -15,25 +15,38 @@ const SCM_SKILLS_TO_REMOVE_GLOBALLY = [
15
15
  "sl-submit-diff",
16
16
  ] as const;
17
17
 
18
- async function runNpxSkills(args: string[]): Promise<boolean> {
18
+ interface NpxSkillsResult {
19
+ ok: boolean;
20
+ /** Tail of captured stderr/stdout — surfaced by the spinner on failure. */
21
+ details: string;
22
+ }
23
+
24
+ async function runNpxSkills(args: string[]): Promise<NpxSkillsResult> {
19
25
  const npxPath = Bun.which("npx");
20
26
  if (!npxPath) {
21
- console.warn("npx not found on PATH — skipping skills install");
22
- return false;
27
+ return { ok: false, details: "npx not found on PATH" };
23
28
  }
24
29
 
30
+ // Capture stdout/stderr so the outer spinner UI owns terminal output and
31
+ // can surface the tail of any failure. `npx skills add` is otherwise
32
+ // very chatty.
25
33
  const proc = Bun.spawn([npxPath, "--yes", "skills", ...args], {
26
- stdio: ["ignore", "inherit", "inherit"],
34
+ stdout: "pipe",
35
+ stderr: "pipe",
27
36
  });
28
- const exitCode = await proc.exited;
29
- return exitCode === 0;
37
+ const [stderr, stdout, exitCode] = await Promise.all([
38
+ new Response(proc.stderr).text(),
39
+ new Response(proc.stdout).text(),
40
+ proc.exited,
41
+ ]);
42
+ const details = (stderr.trim() || stdout.trim()).slice(-800);
43
+ return { ok: exitCode === 0, details };
30
44
  }
31
45
 
32
46
  export async function installGlobalSkills(): Promise<void> {
33
47
  const agentFlags = SKILLS_AGENTS.flatMap((agent) => ["-a", agent]);
34
48
 
35
- console.log("Installing bundled skills globally...");
36
- const addOk = await runNpxSkills([
49
+ const addResult = await runNpxSkills([
37
50
  "add",
38
51
  SKILLS_REPO,
39
52
  "--skill",
@@ -42,26 +55,22 @@ export async function installGlobalSkills(): Promise<void> {
42
55
  ...agentFlags,
43
56
  "-y",
44
57
  ]);
45
- if (!addOk) {
46
- console.warn("Warning: 'npx skills add' exited non-zero (non-fatal)");
47
- return;
58
+ if (!addResult.ok) {
59
+ throw new Error(`npx skills add failed: ${addResult.details}`);
48
60
  }
49
61
 
50
62
  const removeSkillFlags = SCM_SKILLS_TO_REMOVE_GLOBALLY.flatMap((skill) => [
51
63
  "--skill",
52
64
  skill,
53
65
  ]);
54
- console.log(
55
- "Removing source-control skill variants globally (added per-project by `atomic init`)...",
56
- );
57
- const removeOk = await runNpxSkills([
66
+ const removeResult = await runNpxSkills([
58
67
  "remove",
59
68
  ...removeSkillFlags,
60
69
  "-g",
61
70
  ...agentFlags,
62
71
  "-y",
63
72
  ]);
64
- if (!removeOk) {
65
- console.warn("Warning: 'npx skills remove' exited non-zero (non-fatal)");
73
+ if (!removeResult.ok) {
74
+ throw new Error(`npx skills remove failed: ${removeResult.details}`);
66
75
  }
67
76
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Sync bundled Atomic workflow templates from the installed package into
3
+ * the user's global `~/.atomic/workflows/` directory.
4
+ *
5
+ * Each bundled workflow directory (`hello/`, `hello-parallel/`, `ralph/`,
6
+ * etc.) is a full overwrite of its destination — `rm -rf` followed by a
7
+ * fresh copy — so files removed upstream don't linger after an upgrade.
8
+ * User-created workflows whose names don't collide with bundled names are
9
+ * left untouched (we only iterate the bundled source, never the user's
10
+ * destination).
11
+ *
12
+ * Root-level files (`tsconfig.json`, etc.) are also overwritten on each
13
+ * sync.
14
+ */
15
+
16
+ import { join, sep } from "path";
17
+ import { readdir, rm } from "fs/promises";
18
+ import { homedir } from "os";
19
+ import {
20
+ copyDir,
21
+ copyFile,
22
+ ensureDir,
23
+ pathExists,
24
+ } from "@/services/system/copy.ts";
25
+ import { assertPathWithinRoot } from "@/lib/path-root-guard.ts";
26
+
27
+ /**
28
+ * Reject any entry name that could redirect a write outside destRoot.
29
+ * `readdir` should never return `.`, `..`, or names with separators, but
30
+ * this is a cheap belt-and-braces check in case the installed package has
31
+ * been tampered with or sits on an exotic filesystem.
32
+ */
33
+ function isSafeEntryName(name: string): boolean {
34
+ if (name === "" || name === "." || name === "..") return false;
35
+ if (name.includes("/") || name.includes("\\")) return false;
36
+ if (sep !== "/" && name.includes(sep)) return false;
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Locate the package root by walking up from this module. Both in installed
42
+ * (`<pkg>/src/services/system/workflows.ts`) and dev checkout layouts the
43
+ * package root is three directories up.
44
+ */
45
+ function packageRoot(): string {
46
+ return join(import.meta.dir, "..", "..", "..");
47
+ }
48
+
49
+ /** Honors ATOMIC_SETTINGS_HOME so tests can point at a temp dir. */
50
+ function homeRoot(): string {
51
+ return process.env.ATOMIC_SETTINGS_HOME ?? homedir();
52
+ }
53
+
54
+ /**
55
+ * Sync bundled workflow templates to `~/.atomic/workflows/`. Returns the
56
+ * number of bundled workflows installed (for logging). Best-effort on
57
+ * individual entries — readdir failures throw, per-entry copy failures
58
+ * propagate up to the caller (auto-sync's runStep).
59
+ */
60
+ export async function installGlobalWorkflows(): Promise<void> {
61
+ const srcRoot = join(packageRoot(), ".atomic", "workflows");
62
+ const destRoot = join(homeRoot(), ".atomic", "workflows");
63
+
64
+ if (!(await pathExists(srcRoot))) {
65
+ // Treat a missing bundled source as a non-fatal skip: dev checkouts
66
+ // and partial installs legitimately hit this path. Surfaced via a
67
+ // thrown error so the spinner UI marks the step red with context.
68
+ throw new Error(`bundled workflows missing at ${srcRoot} — skipping ${destRoot}`);
69
+ }
70
+
71
+ await ensureDir(destRoot);
72
+
73
+ // Safety invariant: we enumerate the BUNDLED source, never the user's
74
+ // destination. This guarantees that `rm(dest)` can only ever target a
75
+ // path whose basename exists in the bundled workflows — user-created
76
+ // workflows with different names are structurally invisible to this loop.
77
+ const entries = await readdir(srcRoot, { withFileTypes: true });
78
+
79
+ for (const entry of entries) {
80
+ if (!isSafeEntryName(entry.name)) {
81
+ console.warn(
82
+ ` skipping unsafe bundled workflow entry name: ${JSON.stringify(entry.name)}`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ const src = join(srcRoot, entry.name);
88
+ const dest = join(destRoot, entry.name);
89
+
90
+ // Belt-and-braces: confirm the computed destination actually lives
91
+ // inside destRoot. Throws if something conspired to produce an escape.
92
+ assertPathWithinRoot(destRoot, dest, "Workflow destination");
93
+
94
+ if (entry.isFile()) {
95
+ // Root files (tsconfig.json, etc.) — overwrite in place.
96
+ await copyFile(src, dest);
97
+ } else if (entry.isDirectory()) {
98
+ // Bundled workflow — full overwrite of the destination directory so
99
+ // files removed upstream don't linger across upgrades. User-created
100
+ // workflows under names that don't collide are untouched.
101
+ await rm(dest, { recursive: true, force: true });
102
+ await copyDir(src, dest);
103
+ }
104
+ }
105
+ }
@@ -11,6 +11,7 @@ const ANSI_CODES = {
11
11
  red: "\x1b[31m",
12
12
  green: "\x1b[32m",
13
13
  yellow: "\x1b[33m",
14
+ blue: "\x1b[34m",
14
15
  } as const;
15
16
 
16
17
  const NO_COLORS = {
@@ -20,6 +21,7 @@ const NO_COLORS = {
20
21
  red: "",
21
22
  green: "",
22
23
  yellow: "",
24
+ blue: "",
23
25
  } as const;
24
26
 
25
27
  export const COLORS = supportsColor() ? ANSI_CODES : NO_COLORS;
@@ -1,46 +0,0 @@
1
- /**
2
- * Update command — upgrades atomic to the latest version via bun and
3
- * reinstalls global skills.
4
- *
5
- * Usage:
6
- * atomic update
7
- */
8
-
9
- import { COLORS } from "@/theme/colors.ts";
10
- import { VERSION } from "@/version.ts";
11
- import { installGlobalSkills } from "@/services/system/skills.ts";
12
-
13
- export async function updateCommand(): Promise<number> {
14
- const bunPath = Bun.which("bun");
15
- if (!bunPath) {
16
- console.error(`${COLORS.red}Error: bun is not installed.${COLORS.reset}`);
17
- console.error("Install bun: https://bun.sh");
18
- return 1;
19
- }
20
-
21
- console.log(`Current version: ${VERSION}`);
22
- console.log("Updating atomic...\n");
23
-
24
- // Upgrade the package
25
- const proc = Bun.spawn([bunPath, "add", "-g", "atomic@latest"], {
26
- stdio: ["ignore", "inherit", "inherit"],
27
- });
28
- const exitCode = await proc.exited;
29
-
30
- if (exitCode !== 0) {
31
- console.error(`\n${COLORS.red}Failed to update atomic (exit ${exitCode}).${COLORS.reset}`);
32
- return 1;
33
- }
34
-
35
- // Reinstall global skills
36
- console.log("\nUpdating skills...");
37
- try {
38
- await installGlobalSkills();
39
- } catch (error) {
40
- const message = error instanceof Error ? error.message : String(error);
41
- console.warn(`\n${COLORS.yellow}Warning: failed to install skills: ${message}${COLORS.reset}`);
42
- }
43
-
44
- console.log(`\n${COLORS.green}Update complete.${COLORS.reset}`);
45
- return 0;
46
- }