@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,415 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ renderMessagesToText,
4
+ hasContent,
5
+ isTextBlockArray,
6
+ escBash,
7
+ escPwsh,
8
+ } from "./executor.ts";
9
+ import type { SavedMessage } from "../types.ts";
10
+ import type { SessionEvent } from "@github/copilot-sdk";
11
+ import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
12
+ import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Test helpers — minimal cast factories
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeCopilotAssistantEvent(content: string): SavedMessage {
19
+ return {
20
+ provider: "copilot",
21
+ data: {
22
+ id: "evt-001",
23
+ timestamp: "2024-01-01T00:00:00Z",
24
+ parentId: null,
25
+ type: "assistant.message",
26
+ data: {
27
+ messageId: "msg-001",
28
+ content,
29
+ toolCalls: [],
30
+ },
31
+ } as unknown as SessionEvent,
32
+ };
33
+ }
34
+
35
+ function makeCopilotSessionStartEvent(): SavedMessage {
36
+ return {
37
+ provider: "copilot",
38
+ data: {
39
+ id: "evt-000",
40
+ timestamp: "2024-01-01T00:00:00Z",
41
+ parentId: null,
42
+ type: "session.start",
43
+ data: {
44
+ sessionId: "sess-001",
45
+ version: 1,
46
+ producer: "copilot-agent",
47
+ copilotVersion: "1.0.0",
48
+ startTime: "2024-01-01T00:00:00Z",
49
+ },
50
+ } as unknown as SessionEvent,
51
+ };
52
+ }
53
+
54
+ function makeOpenCodeMessage(parts: Array<{ type: string; text?: string; id?: string }>): SavedMessage {
55
+ return {
56
+ provider: "opencode",
57
+ data: {
58
+ info: {
59
+ id: "msg-oc-001",
60
+ sessionID: "sess-oc-001",
61
+ role: "assistant",
62
+ time: { created: 1000 },
63
+ parentID: "parent-001",
64
+ modelID: "gpt-4",
65
+ providerID: "openai",
66
+ mode: "auto",
67
+ agent: "agent",
68
+ path: { cwd: "/tmp" },
69
+ tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
70
+ },
71
+ parts: parts.map((p, i) =>
72
+ p.type === "text"
73
+ ? { id: p.id ?? `part-${i}`, sessionID: "sess-oc-001", messageID: "msg-oc-001", type: "text" as const, text: p.text ?? "" }
74
+ : { id: `part-${i}`, sessionID: "sess-oc-001", messageID: "msg-oc-001", type: p.type as "reasoning", text: "" },
75
+ ),
76
+ } as unknown as SessionPromptResponse,
77
+ };
78
+ }
79
+
80
+ function makeClaudeMessage(
81
+ type: "user" | "assistant" | "system",
82
+ message: unknown,
83
+ ): SavedMessage {
84
+ return {
85
+ provider: "claude",
86
+ data: {
87
+ type,
88
+ uuid: "uuid-001",
89
+ session_id: "sess-cl-001",
90
+ message,
91
+ parent_tool_use_id: null,
92
+ } as SessionMessage,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // renderMessagesToText
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("renderMessagesToText", () => {
101
+ test("returns empty string for empty array", () => {
102
+ expect(renderMessagesToText([])).toBe("");
103
+ });
104
+
105
+ // --- Copilot ---
106
+
107
+ test("extracts content from a copilot assistant.message event", () => {
108
+ const messages: SavedMessage[] = [makeCopilotAssistantEvent("Hello from Copilot")];
109
+ expect(renderMessagesToText(messages)).toBe("Hello from Copilot");
110
+ });
111
+
112
+ test("skips copilot non-assistant events (session.start)", () => {
113
+ const messages: SavedMessage[] = [makeCopilotSessionStartEvent()];
114
+ expect(renderMessagesToText(messages)).toBe("");
115
+ });
116
+
117
+ test("only includes copilot assistant.message events when mixed with other event types", () => {
118
+ const messages: SavedMessage[] = [
119
+ makeCopilotSessionStartEvent(),
120
+ makeCopilotAssistantEvent("First response"),
121
+ makeCopilotSessionStartEvent(),
122
+ makeCopilotAssistantEvent("Second response"),
123
+ ];
124
+ expect(renderMessagesToText(messages)).toBe("First response\n\nSecond response");
125
+ });
126
+
127
+ // --- OpenCode ---
128
+
129
+ test("joins opencode text parts with newlines", () => {
130
+ const messages: SavedMessage[] = [
131
+ makeOpenCodeMessage([
132
+ { type: "text", text: "Line one" },
133
+ { type: "text", text: "Line two" },
134
+ ]),
135
+ ];
136
+ expect(renderMessagesToText(messages)).toBe("Line one\nLine two");
137
+ });
138
+
139
+ test("filters out non-text parts from opencode messages", () => {
140
+ const messages: SavedMessage[] = [
141
+ makeOpenCodeMessage([
142
+ { type: "reasoning", text: "thinking..." },
143
+ { type: "subtask", text: "" },
144
+ ]),
145
+ ];
146
+ expect(renderMessagesToText(messages)).toBe("");
147
+ });
148
+
149
+ test("includes only text parts when opencode message has mixed part types", () => {
150
+ const messages: SavedMessage[] = [
151
+ makeOpenCodeMessage([
152
+ { type: "reasoning", text: "thinking..." },
153
+ { type: "text", text: "The answer is 42" },
154
+ { type: "subtask", text: "" },
155
+ ]),
156
+ ];
157
+ expect(renderMessagesToText(messages)).toBe("The answer is 42");
158
+ });
159
+
160
+ // --- Claude ---
161
+
162
+ test("returns string message from claude assistant with plain string message", () => {
163
+ const messages: SavedMessage[] = [makeClaudeMessage("assistant", "Plain string output")];
164
+ expect(renderMessagesToText(messages)).toBe("Plain string output");
165
+ });
166
+
167
+ test("returns content when claude assistant message is an object with content string", () => {
168
+ const messages: SavedMessage[] = [
169
+ makeClaudeMessage("assistant", { content: "Content field string" }),
170
+ ];
171
+ expect(renderMessagesToText(messages)).toBe("Content field string");
172
+ });
173
+
174
+ test("joins text blocks when claude assistant message has content as text block array", () => {
175
+ const messages: SavedMessage[] = [
176
+ makeClaudeMessage("assistant", {
177
+ content: [
178
+ { type: "text", text: "Block one" },
179
+ { type: "text", text: "Block two" },
180
+ ],
181
+ }),
182
+ ];
183
+ expect(renderMessagesToText(messages)).toBe("Block one\nBlock two");
184
+ });
185
+
186
+ test("skips claude user messages", () => {
187
+ const messages: SavedMessage[] = [makeClaudeMessage("user", "user prompt")];
188
+ expect(renderMessagesToText(messages)).toBe("");
189
+ });
190
+
191
+ test("skips claude system messages", () => {
192
+ const messages: SavedMessage[] = [makeClaudeMessage("system", "system instructions")];
193
+ expect(renderMessagesToText(messages)).toBe("");
194
+ });
195
+
196
+ test("falls back to JSON.stringify for claude assistant with unknown message shape", () => {
197
+ const unknownMsg = { weird: "shape", count: 99 };
198
+ const messages: SavedMessage[] = [makeClaudeMessage("assistant", unknownMsg)];
199
+ expect(renderMessagesToText(messages)).toBe(JSON.stringify(unknownMsg));
200
+ });
201
+
202
+ // --- Mixed providers ---
203
+
204
+ test("joins messages from mixed providers with double newlines", () => {
205
+ const messages: SavedMessage[] = [
206
+ makeCopilotAssistantEvent("Copilot says hello"),
207
+ makeOpenCodeMessage([{ type: "text", text: "OpenCode says hello" }]),
208
+ makeClaudeMessage("assistant", "Claude says hello"),
209
+ ];
210
+ expect(renderMessagesToText(messages)).toBe(
211
+ "Copilot says hello\n\nOpenCode says hello\n\nClaude says hello",
212
+ );
213
+ });
214
+
215
+ test("skips blank entries when building joined output", () => {
216
+ const messages: SavedMessage[] = [
217
+ makeCopilotSessionStartEvent(),
218
+ makeCopilotAssistantEvent("Only one has content"),
219
+ makeOpenCodeMessage([{ type: "reasoning", text: "ignored" }]),
220
+ ];
221
+ expect(renderMessagesToText(messages)).toBe("Only one has content");
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // hasContent type guard
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe("hasContent", () => {
230
+ test("returns true for object with string content property", () => {
231
+ expect(hasContent({ content: "hello" })).toBe(true);
232
+ });
233
+
234
+ test("returns false for empty object", () => {
235
+ expect(hasContent({})).toBe(false);
236
+ });
237
+
238
+ test("returns false for null", () => {
239
+ expect(hasContent(null)).toBe(false);
240
+ });
241
+
242
+ test("returns false when content is a number instead of a string", () => {
243
+ expect(hasContent({ content: 42 })).toBe(false);
244
+ });
245
+
246
+ test("returns false for a plain string value", () => {
247
+ expect(hasContent("hello")).toBe(false);
248
+ });
249
+
250
+ test("returns false for undefined", () => {
251
+ expect(hasContent(undefined)).toBe(false);
252
+ });
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // isTextBlockArray type guard
257
+ // ---------------------------------------------------------------------------
258
+
259
+ describe("isTextBlockArray", () => {
260
+ test("returns true for a valid array of text blocks", () => {
261
+ expect(isTextBlockArray([{ type: "text", text: "hi" }])).toBe(true);
262
+ });
263
+
264
+ test("returns true for an array with multiple text blocks", () => {
265
+ expect(
266
+ isTextBlockArray([
267
+ { type: "text", text: "first" },
268
+ { type: "text", text: "second" },
269
+ ]),
270
+ ).toBe(true);
271
+ });
272
+
273
+ test("returns true for an empty array (vacuously satisfies every element check)", () => {
274
+ // Array.prototype.every returns true on empty arrays — the empty array
275
+ // satisfies the type guard because there are no elements that violate it.
276
+ expect(isTextBlockArray([])).toBe(true);
277
+ });
278
+
279
+ test("returns false for array with wrong block shape (missing text)", () => {
280
+ expect(isTextBlockArray([{ type: "text" }])).toBe(false);
281
+ });
282
+
283
+ test("returns false for array with wrong type value", () => {
284
+ expect(isTextBlockArray([{ type: "tool_use", text: "hi" }])).toBe(false);
285
+ });
286
+
287
+ test("returns false for non-array value", () => {
288
+ expect(isTextBlockArray("not an array")).toBe(false);
289
+ });
290
+
291
+ test("returns false for null", () => {
292
+ expect(isTextBlockArray(null)).toBe(false);
293
+ });
294
+
295
+ test("returns false when array elements are not objects", () => {
296
+ expect(isTextBlockArray(["text"])).toBe(false);
297
+ });
298
+ });
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // escBash — shell escaping for bash double-quoted strings
302
+ // ---------------------------------------------------------------------------
303
+
304
+ describe("escBash", () => {
305
+ test("returns empty string unchanged", () => {
306
+ expect(escBash("")).toBe("");
307
+ });
308
+
309
+ test("passes through plain alphanumeric text", () => {
310
+ expect(escBash("hello world 123")).toBe("hello world 123");
311
+ });
312
+
313
+ test("escapes double quotes", () => {
314
+ expect(escBash('say "hello"')).toBe('say \\"hello\\"');
315
+ });
316
+
317
+ test("escapes backslashes", () => {
318
+ expect(escBash("a\\b")).toBe("a\\\\b");
319
+ });
320
+
321
+ test("escapes dollar signs", () => {
322
+ expect(escBash("$HOME")).toBe("\\$HOME");
323
+ });
324
+
325
+ test("escapes backticks", () => {
326
+ expect(escBash("`whoami`")).toBe("\\`whoami\\`");
327
+ });
328
+
329
+ test("escapes exclamation marks (history expansion)", () => {
330
+ expect(escBash("hello!")).toBe("hello\\!");
331
+ });
332
+
333
+ test("replaces newlines with spaces", () => {
334
+ expect(escBash("line1\nline2\nline3")).toBe("line1 line2 line3");
335
+ });
336
+
337
+ test("replaces carriage returns with spaces", () => {
338
+ expect(escBash("line1\r\nline2")).toBe("line1 line2");
339
+ });
340
+
341
+ test("collapses consecutive newlines into a single space", () => {
342
+ expect(escBash("a\n\n\nb")).toBe("a b");
343
+ });
344
+
345
+ test("strips null bytes", () => {
346
+ expect(escBash("ab\0cd")).toBe("abcd");
347
+ });
348
+
349
+ test("preserves single quotes (literal in double-quoted bash strings)", () => {
350
+ expect(escBash("it's fine")).toBe("it's fine");
351
+ });
352
+
353
+ test("preserves parentheses, braces, and brackets (safe in double quotes)", () => {
354
+ expect(escBash("(a) {b} [c]")).toBe("(a) {b} [c]");
355
+ });
356
+
357
+ test("preserves pipe, ampersand, and semicolon (safe in double quotes)", () => {
358
+ expect(escBash("a | b & c ; d")).toBe("a | b & c ; d");
359
+ });
360
+
361
+ test("handles a string with all special characters combined", () => {
362
+ expect(escBash('$`"\\!\0')).toBe('\\$\\`\\"\\\\\\!');
363
+ });
364
+
365
+ test("handles unicode characters", () => {
366
+ expect(escBash("héllo wörld 日本語")).toBe("héllo wörld 日本語");
367
+ });
368
+
369
+ test("handles very long strings without error", () => {
370
+ const long = "a".repeat(10_000);
371
+ expect(escBash(long)).toBe(long);
372
+ });
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // escPwsh — shell escaping for PowerShell double-quoted strings
377
+ // ---------------------------------------------------------------------------
378
+
379
+ describe("escPwsh", () => {
380
+ test("returns empty string unchanged", () => {
381
+ expect(escPwsh("")).toBe("");
382
+ });
383
+
384
+ test("passes through plain text", () => {
385
+ expect(escPwsh("hello world")).toBe("hello world");
386
+ });
387
+
388
+ test("escapes backticks (PowerShell escape character)", () => {
389
+ expect(escPwsh("a`b")).toBe("a``b");
390
+ });
391
+
392
+ test("escapes double quotes", () => {
393
+ expect(escPwsh('say "hi"')).toBe('say `"hi`"');
394
+ });
395
+
396
+ test("escapes dollar signs", () => {
397
+ expect(escPwsh("$env:HOME")).toBe("`$env:HOME");
398
+ });
399
+
400
+ test("converts newlines to backtick-n", () => {
401
+ expect(escPwsh("line1\nline2")).toBe("line1`nline2");
402
+ });
403
+
404
+ test("converts carriage returns to backtick-r", () => {
405
+ expect(escPwsh("line1\rline2")).toBe("line1`rline2");
406
+ });
407
+
408
+ test("strips null bytes", () => {
409
+ expect(escPwsh("ab\0cd")).toBe("abcd");
410
+ });
411
+
412
+ test("handles combined special characters", () => {
413
+ expect(escPwsh('$`"\0')).toBe('`$```"');
414
+ });
415
+ });