@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,289 @@
1
+ /**
2
+ * File Locking Utility
3
+ *
4
+ * Provides a simple file-based locking mechanism to prevent concurrent
5
+ * writes to shared files like progress.txt, feature-list.json, etc.
6
+ *
7
+ * Uses lock files (.lock suffix) with process info to track ownership.
8
+ */
9
+
10
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
11
+ import { join, dirname } from "path";
12
+ import { ensureDirSync } from "@/services/system/copy.ts";
13
+
14
+ // ============================================================================
15
+ // TYPES
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Lock file content structure.
20
+ */
21
+ interface LockInfo {
22
+ /** Process ID that holds the lock */
23
+ pid: number;
24
+ /** Session ID (if available) */
25
+ sessionId?: string;
26
+ /** Timestamp when lock was acquired */
27
+ acquiredAt: number;
28
+ /** Hostname where the lock was acquired */
29
+ hostname?: string;
30
+ }
31
+
32
+ /**
33
+ * Result of a lock acquisition attempt.
34
+ */
35
+ export interface LockResult {
36
+ /** Whether the lock was acquired */
37
+ acquired: boolean;
38
+ /** Lock file path */
39
+ lockPath: string;
40
+ /** Error message if lock wasn't acquired */
41
+ error?: string;
42
+ /** Info about the process holding the lock (if not acquired) */
43
+ holder?: LockInfo;
44
+ }
45
+
46
+ // ============================================================================
47
+ // CONSTANTS
48
+ // ============================================================================
49
+
50
+ /** Lock file suffix */
51
+ const LOCK_SUFFIX = ".lock";
52
+
53
+ /** Default lock timeout in milliseconds (30 seconds) */
54
+ const DEFAULT_LOCK_TIMEOUT_MS = 30000;
55
+
56
+ /** Retry interval for lock acquisition */
57
+ const LOCK_RETRY_INTERVAL_MS = 100;
58
+
59
+ // ============================================================================
60
+ // LOCK FUNCTIONS
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Get the lock file path for a given file.
65
+ */
66
+ export function getLockPath(filePath: string): string {
67
+ return `${filePath}${LOCK_SUFFIX}`;
68
+ }
69
+
70
+ /**
71
+ * Try to acquire a lock on a file.
72
+ *
73
+ * @param filePath - Path to the file to lock
74
+ * @param sessionId - Optional session ID for tracking
75
+ * @returns Lock result
76
+ */
77
+ export function tryAcquireLock(filePath: string, sessionId?: string): LockResult {
78
+ const lockPath = getLockPath(filePath);
79
+
80
+ // Check if lock file exists
81
+ if (existsSync(lockPath)) {
82
+ try {
83
+ const content = readFileSync(lockPath, "utf-8");
84
+ const holder = JSON.parse(content) as LockInfo;
85
+
86
+ // Check if the lock holder process is still alive
87
+ if (isProcessAlive(holder.pid)) {
88
+ return {
89
+ acquired: false,
90
+ lockPath,
91
+ error: `File is locked by process ${holder.pid}`,
92
+ holder,
93
+ };
94
+ }
95
+
96
+ // Lock holder process is dead, remove stale lock
97
+ unlinkSync(lockPath);
98
+ } catch {
99
+ // Invalid lock file, remove it
100
+ try {
101
+ unlinkSync(lockPath);
102
+ } catch {
103
+ // Ignore removal errors
104
+ }
105
+ }
106
+ }
107
+
108
+ // Try to create lock file
109
+ const lockInfo: LockInfo = {
110
+ pid: process.pid,
111
+ sessionId,
112
+ acquiredAt: Date.now(),
113
+ hostname: process.env.HOSTNAME,
114
+ };
115
+
116
+ try {
117
+ // Ensure directory exists
118
+ const dir = dirname(lockPath);
119
+ if (!existsSync(dir)) {
120
+ ensureDirSync(dir);
121
+ }
122
+
123
+ // Write lock file with exclusive flag to prevent race conditions
124
+ writeFileSync(lockPath, JSON.stringify(lockInfo, null, 2), { flag: "wx" });
125
+ return { acquired: true, lockPath };
126
+ } catch (error) {
127
+ // Another process might have created the lock
128
+ return {
129
+ acquired: false,
130
+ lockPath,
131
+ error: `Failed to acquire lock: ${error instanceof Error ? error.message : String(error)}`,
132
+ };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Acquire a lock with retry and timeout.
138
+ *
139
+ * @param filePath - Path to the file to lock
140
+ * @param options - Lock options
141
+ * @returns Lock result
142
+ */
143
+ export async function acquireLock(
144
+ filePath: string,
145
+ options: {
146
+ sessionId?: string;
147
+ timeoutMs?: number;
148
+ } = {}
149
+ ): Promise<LockResult> {
150
+ const { sessionId, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = options;
151
+ const startTime = Date.now();
152
+
153
+ while (Date.now() - startTime < timeoutMs) {
154
+ const result = tryAcquireLock(filePath, sessionId);
155
+ if (result.acquired) {
156
+ return result;
157
+ }
158
+
159
+ // Wait before retry
160
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
161
+ }
162
+
163
+ // Timeout
164
+ return tryAcquireLock(filePath, sessionId);
165
+ }
166
+
167
+ /**
168
+ * Release a lock on a file.
169
+ *
170
+ * @param filePath - Path to the file to unlock
171
+ * @param options - Release options
172
+ * @returns True if lock was released
173
+ */
174
+ export function releaseLock(
175
+ filePath: string,
176
+ options: { force?: boolean } = {}
177
+ ): boolean {
178
+ const lockPath = getLockPath(filePath);
179
+
180
+ if (!existsSync(lockPath)) {
181
+ return true; // Already unlocked
182
+ }
183
+
184
+ // Verify we own the lock (unless force)
185
+ if (!options.force) {
186
+ try {
187
+ const content = readFileSync(lockPath, "utf-8");
188
+ const holder = JSON.parse(content) as LockInfo;
189
+ if (holder.pid !== process.pid) {
190
+ return false; // We don't own this lock
191
+ }
192
+ } catch {
193
+ // Invalid lock file, safe to remove
194
+ }
195
+ }
196
+
197
+ try {
198
+ unlinkSync(lockPath);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Execute a function while holding a lock on a file.
207
+ *
208
+ * @param filePath - Path to the file to lock
209
+ * @param fn - Function to execute while holding the lock
210
+ * @param options - Lock options
211
+ * @returns Result of the function
212
+ */
213
+ export async function withLock<T>(
214
+ filePath: string,
215
+ fn: () => T | Promise<T>,
216
+ options: {
217
+ sessionId?: string;
218
+ timeoutMs?: number;
219
+ } = {}
220
+ ): Promise<T> {
221
+ const lockResult = await acquireLock(filePath, options);
222
+
223
+ if (!lockResult.acquired) {
224
+ throw new Error(lockResult.error ?? `Failed to acquire lock for ${filePath}`);
225
+ }
226
+
227
+ try {
228
+ return await fn();
229
+ } finally {
230
+ releaseLock(filePath);
231
+ }
232
+ }
233
+
234
+ // ============================================================================
235
+ // HELPER FUNCTIONS
236
+ // ============================================================================
237
+
238
+ /**
239
+ * Check if a process is still alive.
240
+ */
241
+ function isProcessAlive(pid: number): boolean {
242
+ try {
243
+ // Sending signal 0 doesn't kill the process, just checks if it exists
244
+ process.kill(pid, 0);
245
+ return true;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Clean up stale locks for a directory.
253
+ * Removes lock files whose holder processes are no longer alive.
254
+ *
255
+ * @param directory - Directory to clean up
256
+ * @returns Number of stale locks removed
257
+ */
258
+ export function cleanupStaleLocks(directory: string): number {
259
+ let removed = 0;
260
+
261
+ try {
262
+ const files = readdirSync(directory) as string[];
263
+ for (const file of files) {
264
+ if (file.endsWith(LOCK_SUFFIX)) {
265
+ const lockPath = join(directory, file);
266
+ try {
267
+ const content = readFileSync(lockPath, "utf-8");
268
+ const holder = JSON.parse(content) as LockInfo;
269
+ if (!isProcessAlive(holder.pid)) {
270
+ unlinkSync(lockPath);
271
+ removed++;
272
+ }
273
+ } catch {
274
+ // Invalid lock file, remove it
275
+ try {
276
+ unlinkSync(lockPath);
277
+ removed++;
278
+ } catch {
279
+ // Ignore
280
+ }
281
+ }
282
+ }
283
+ }
284
+ } catch {
285
+ // Ignore directory read errors
286
+ }
287
+
288
+ return removed;
289
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Global skills installation.
3
+ *
4
+ * Installs bundled agent skills globally via `npx skills`, then removes
5
+ * source-control skill variants so `atomic init` can install them
6
+ * locally per-project based on the user's selected SCM + active agent.
7
+ */
8
+
9
+ const SKILLS_REPO = "https://github.com/flora131/atomic.git";
10
+ const SKILLS_AGENTS = ["claude-code", "opencode", "github-copilot"] as const;
11
+ const SCM_SKILLS_TO_REMOVE_GLOBALLY = [
12
+ "gh-commit",
13
+ "gh-create-pr",
14
+ "sl-commit",
15
+ "sl-submit-diff",
16
+ ] as const;
17
+
18
+ async function runNpxSkills(args: string[]): Promise<boolean> {
19
+ const npxPath = Bun.which("npx");
20
+ if (!npxPath) {
21
+ console.warn("npx not found on PATH — skipping skills install");
22
+ return false;
23
+ }
24
+
25
+ const proc = Bun.spawn([npxPath, "--yes", "skills", ...args], {
26
+ stdio: ["ignore", "inherit", "inherit"],
27
+ });
28
+ const exitCode = await proc.exited;
29
+ return exitCode === 0;
30
+ }
31
+
32
+ export async function installGlobalSkills(): Promise<void> {
33
+ const agentFlags = SKILLS_AGENTS.flatMap((agent) => ["-a", agent]);
34
+
35
+ console.log("Installing bundled skills globally...");
36
+ const addOk = await runNpxSkills([
37
+ "add",
38
+ SKILLS_REPO,
39
+ "--skill",
40
+ "*",
41
+ "-g",
42
+ ...agentFlags,
43
+ "-y",
44
+ ]);
45
+ if (!addOk) {
46
+ console.warn("Warning: 'npx skills add' exited non-zero (non-fatal)");
47
+ return;
48
+ }
49
+
50
+ const removeSkillFlags = SCM_SKILLS_TO_REMOVE_GLOBALLY.flatMap((skill) => [
51
+ "--skill",
52
+ skill,
53
+ ]);
54
+ console.log(
55
+ "Removing source-control skill variants globally (added per-project by `atomic init`)...",
56
+ );
57
+ const removeOk = await runNpxSkills([
58
+ "remove",
59
+ ...removeSkillFlags,
60
+ "-g",
61
+ ...agentFlags,
62
+ "-y",
63
+ ]);
64
+ if (!removeOk) {
65
+ console.warn("Warning: 'npx skills remove' exited non-zero (non-fatal)");
66
+ }
67
+ }
@@ -0,0 +1,25 @@
1
+ import { supportsColor } from "@/services/system/detect.ts";
2
+
3
+ /**
4
+ * ANSI color and formatting codes for CLI output
5
+ * Respects the NO_COLOR environment variable
6
+ */
7
+ const ANSI_CODES = {
8
+ bold: "\x1b[1m",
9
+ dim: "\x1b[2m",
10
+ reset: "\x1b[0m",
11
+ red: "\x1b[31m",
12
+ green: "\x1b[32m",
13
+ yellow: "\x1b[33m",
14
+ } as const;
15
+
16
+ const NO_COLORS = {
17
+ bold: "",
18
+ dim: "",
19
+ reset: "",
20
+ red: "",
21
+ green: "",
22
+ yellow: "",
23
+ } as const;
24
+
25
+ export const COLORS = supportsColor() ? ANSI_CODES : NO_COLORS;
package/src/version.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Version - read from package.json
3
+ */
4
+ import packageJson from "../package.json";
5
+
6
+ export const VERSION = packageJson.version;
7
+ // test marker