@studio-foundation/cli 0.3.0-beta.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 (277) hide show
  1. package/ARCHITECTURE.md +40 -0
  2. package/LICENSE +663 -0
  3. package/README.md +165 -0
  4. package/dist/commands/api.d.ts +16 -0
  5. package/dist/commands/api.d.ts.map +1 -0
  6. package/dist/commands/api.js +118 -0
  7. package/dist/commands/api.js.map +1 -0
  8. package/dist/commands/config.d.ts +32 -0
  9. package/dist/commands/config.d.ts.map +1 -0
  10. package/dist/commands/config.js +390 -0
  11. package/dist/commands/config.js.map +1 -0
  12. package/dist/commands/init.d.ts +93 -0
  13. package/dist/commands/init.d.ts.map +1 -0
  14. package/dist/commands/init.js +728 -0
  15. package/dist/commands/init.js.map +1 -0
  16. package/dist/commands/install.d.ts +2 -0
  17. package/dist/commands/install.d.ts.map +1 -0
  18. package/dist/commands/install.js +22 -0
  19. package/dist/commands/install.js.map +1 -0
  20. package/dist/commands/integrations.d.ts +14 -0
  21. package/dist/commands/integrations.d.ts.map +1 -0
  22. package/dist/commands/integrations.js +282 -0
  23. package/dist/commands/integrations.js.map +1 -0
  24. package/dist/commands/list.d.ts +9 -0
  25. package/dist/commands/list.d.ts.map +1 -0
  26. package/dist/commands/list.js +161 -0
  27. package/dist/commands/list.js.map +1 -0
  28. package/dist/commands/logs.d.ts +7 -0
  29. package/dist/commands/logs.d.ts.map +1 -0
  30. package/dist/commands/logs.js +98 -0
  31. package/dist/commands/logs.js.map +1 -0
  32. package/dist/commands/ollama.d.ts +6 -0
  33. package/dist/commands/ollama.d.ts.map +1 -0
  34. package/dist/commands/ollama.js +161 -0
  35. package/dist/commands/ollama.js.map +1 -0
  36. package/dist/commands/project.d.ts +38 -0
  37. package/dist/commands/project.d.ts.map +1 -0
  38. package/dist/commands/project.js +137 -0
  39. package/dist/commands/project.js.map +1 -0
  40. package/dist/commands/registry/audit.d.ts +13 -0
  41. package/dist/commands/registry/audit.d.ts.map +1 -0
  42. package/dist/commands/registry/audit.js +65 -0
  43. package/dist/commands/registry/audit.js.map +1 -0
  44. package/dist/commands/registry/index.d.ts +3 -0
  45. package/dist/commands/registry/index.d.ts.map +1 -0
  46. package/dist/commands/registry/index.js +53 -0
  47. package/dist/commands/registry/index.js.map +1 -0
  48. package/dist/commands/registry/install.d.ts +17 -0
  49. package/dist/commands/registry/install.d.ts.map +1 -0
  50. package/dist/commands/registry/install.js +153 -0
  51. package/dist/commands/registry/install.js.map +1 -0
  52. package/dist/commands/registry/publish.d.ts +8 -0
  53. package/dist/commands/registry/publish.d.ts.map +1 -0
  54. package/dist/commands/registry/publish.js +94 -0
  55. package/dist/commands/registry/publish.js.map +1 -0
  56. package/dist/commands/registry/remove.d.ts +8 -0
  57. package/dist/commands/registry/remove.d.ts.map +1 -0
  58. package/dist/commands/registry/remove.js +82 -0
  59. package/dist/commands/registry/remove.js.map +1 -0
  60. package/dist/commands/registry/search.d.ts +9 -0
  61. package/dist/commands/registry/search.d.ts.map +1 -0
  62. package/dist/commands/registry/search.js +68 -0
  63. package/dist/commands/registry/search.js.map +1 -0
  64. package/dist/commands/registry/sync.d.ts +8 -0
  65. package/dist/commands/registry/sync.d.ts.map +1 -0
  66. package/dist/commands/registry/sync.js +19 -0
  67. package/dist/commands/registry/sync.js.map +1 -0
  68. package/dist/commands/registry/update.d.ts +15 -0
  69. package/dist/commands/registry/update.d.ts.map +1 -0
  70. package/dist/commands/registry/update.js +48 -0
  71. package/dist/commands/registry/update.js.map +1 -0
  72. package/dist/commands/replay.d.ts +36 -0
  73. package/dist/commands/replay.d.ts.map +1 -0
  74. package/dist/commands/replay.js +453 -0
  75. package/dist/commands/replay.js.map +1 -0
  76. package/dist/commands/run.d.ts +18 -0
  77. package/dist/commands/run.d.ts.map +1 -0
  78. package/dist/commands/run.js +406 -0
  79. package/dist/commands/run.js.map +1 -0
  80. package/dist/commands/status.d.ts +6 -0
  81. package/dist/commands/status.d.ts.map +1 -0
  82. package/dist/commands/status.js +144 -0
  83. package/dist/commands/status.js.map +1 -0
  84. package/dist/commands/template/index.d.ts +2 -0
  85. package/dist/commands/template/index.d.ts.map +1 -0
  86. package/dist/commands/template/index.js +57 -0
  87. package/dist/commands/template/index.js.map +1 -0
  88. package/dist/commands/template/validate.d.ts +8 -0
  89. package/dist/commands/template/validate.d.ts.map +1 -0
  90. package/dist/commands/template/validate.js +135 -0
  91. package/dist/commands/template/validate.js.map +1 -0
  92. package/dist/commands/templates.d.ts +14 -0
  93. package/dist/commands/templates.d.ts.map +1 -0
  94. package/dist/commands/templates.js +86 -0
  95. package/dist/commands/templates.js.map +1 -0
  96. package/dist/commands/tools.d.ts +12 -0
  97. package/dist/commands/tools.d.ts.map +1 -0
  98. package/dist/commands/tools.js +200 -0
  99. package/dist/commands/tools.js.map +1 -0
  100. package/dist/commands/users.d.ts +4 -0
  101. package/dist/commands/users.d.ts.map +1 -0
  102. package/dist/commands/users.js +123 -0
  103. package/dist/commands/users.js.map +1 -0
  104. package/dist/commands/validate.d.ts +2 -0
  105. package/dist/commands/validate.d.ts.map +1 -0
  106. package/dist/commands/validate.js +39 -0
  107. package/dist/commands/validate.js.map +1 -0
  108. package/dist/config.d.ts +36 -0
  109. package/dist/config.d.ts.map +1 -0
  110. package/dist/config.js +64 -0
  111. package/dist/config.js.map +1 -0
  112. package/dist/index.d.ts +3 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +178 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/models-cache.d.ts +13 -0
  117. package/dist/models-cache.d.ts.map +1 -0
  118. package/dist/models-cache.js +106 -0
  119. package/dist/models-cache.js.map +1 -0
  120. package/dist/output/file-changes.d.ts +15 -0
  121. package/dist/output/file-changes.d.ts.map +1 -0
  122. package/dist/output/file-changes.js +77 -0
  123. package/dist/output/file-changes.js.map +1 -0
  124. package/dist/output/formatter.d.ts +6 -0
  125. package/dist/output/formatter.d.ts.map +1 -0
  126. package/dist/output/formatter.js +70 -0
  127. package/dist/output/formatter.js.map +1 -0
  128. package/dist/output/formatters.d.ts +42 -0
  129. package/dist/output/formatters.d.ts.map +1 -0
  130. package/dist/output/formatters.js +280 -0
  131. package/dist/output/formatters.js.map +1 -0
  132. package/dist/output/logger.d.ts +3 -0
  133. package/dist/output/logger.d.ts.map +1 -0
  134. package/dist/output/logger.js +15 -0
  135. package/dist/output/logger.js.map +1 -0
  136. package/dist/output/parallel-progress.d.ts +33 -0
  137. package/dist/output/parallel-progress.d.ts.map +1 -0
  138. package/dist/output/parallel-progress.js +85 -0
  139. package/dist/output/parallel-progress.js.map +1 -0
  140. package/dist/output/progress.d.ts +32 -0
  141. package/dist/output/progress.d.ts.map +1 -0
  142. package/dist/output/progress.js +393 -0
  143. package/dist/output/progress.js.map +1 -0
  144. package/dist/provider-validator.d.ts +15 -0
  145. package/dist/provider-validator.d.ts.map +1 -0
  146. package/dist/provider-validator.js +85 -0
  147. package/dist/provider-validator.js.map +1 -0
  148. package/dist/registry/cache.d.ts +10 -0
  149. package/dist/registry/cache.d.ts.map +1 -0
  150. package/dist/registry/cache.js +36 -0
  151. package/dist/registry/cache.js.map +1 -0
  152. package/dist/registry/client.d.ts +19 -0
  153. package/dist/registry/client.d.ts.map +1 -0
  154. package/dist/registry/client.js +63 -0
  155. package/dist/registry/client.js.map +1 -0
  156. package/dist/registry/lockfile.d.ts +17 -0
  157. package/dist/registry/lockfile.d.ts.map +1 -0
  158. package/dist/registry/lockfile.js +61 -0
  159. package/dist/registry/lockfile.js.map +1 -0
  160. package/dist/registry/resolver.d.ts +13 -0
  161. package/dist/registry/resolver.d.ts.map +1 -0
  162. package/dist/registry/resolver.js +79 -0
  163. package/dist/registry/resolver.js.map +1 -0
  164. package/dist/registry/types.d.ts +59 -0
  165. package/dist/registry/types.d.ts.map +1 -0
  166. package/dist/registry/types.js +14 -0
  167. package/dist/registry/types.js.map +1 -0
  168. package/dist/run-logger.d.ts +8 -0
  169. package/dist/run-logger.d.ts.map +1 -0
  170. package/dist/run-logger.js +53 -0
  171. package/dist/run-logger.js.map +1 -0
  172. package/dist/run-store-factory.d.ts +9 -0
  173. package/dist/run-store-factory.d.ts.map +1 -0
  174. package/dist/run-store-factory.js +26 -0
  175. package/dist/run-store-factory.js.map +1 -0
  176. package/dist/studio-dir.d.ts +7 -0
  177. package/dist/studio-dir.d.ts.map +1 -0
  178. package/dist/studio-dir.js +27 -0
  179. package/dist/studio-dir.js.map +1 -0
  180. package/dist/utils/input-wizard.d.ts +4 -0
  181. package/dist/utils/input-wizard.d.ts.map +1 -0
  182. package/dist/utils/input-wizard.js +56 -0
  183. package/dist/utils/input-wizard.js.map +1 -0
  184. package/dist/utils/placeholders.d.ts +6 -0
  185. package/dist/utils/placeholders.d.ts.map +1 -0
  186. package/dist/utils/placeholders.js +12 -0
  187. package/dist/utils/placeholders.js.map +1 -0
  188. package/package.json +43 -0
  189. package/src/commands/api.ts +126 -0
  190. package/src/commands/config.ts +433 -0
  191. package/src/commands/init.ts +879 -0
  192. package/src/commands/install.ts +23 -0
  193. package/src/commands/integrations.ts +332 -0
  194. package/src/commands/list.ts +197 -0
  195. package/src/commands/logs.ts +119 -0
  196. package/src/commands/ollama.ts +168 -0
  197. package/src/commands/project.ts +167 -0
  198. package/src/commands/registry/audit.ts +87 -0
  199. package/src/commands/registry/index.ts +63 -0
  200. package/src/commands/registry/install.ts +222 -0
  201. package/src/commands/registry/publish.ts +112 -0
  202. package/src/commands/registry/remove.ts +89 -0
  203. package/src/commands/registry/search.ts +93 -0
  204. package/src/commands/registry/sync.ts +24 -0
  205. package/src/commands/registry/update.ts +63 -0
  206. package/src/commands/replay.ts +559 -0
  207. package/src/commands/run.ts +454 -0
  208. package/src/commands/status.ts +163 -0
  209. package/src/commands/template/index.ts +59 -0
  210. package/src/commands/template/validate.ts +175 -0
  211. package/src/commands/templates.ts +99 -0
  212. package/src/commands/tools.ts +227 -0
  213. package/src/commands/users.ts +127 -0
  214. package/src/commands/validate.ts +46 -0
  215. package/src/config.ts +101 -0
  216. package/src/index.ts +201 -0
  217. package/src/models-cache.ts +127 -0
  218. package/src/output/file-changes.ts +97 -0
  219. package/src/output/formatter.ts +85 -0
  220. package/src/output/formatters.ts +303 -0
  221. package/src/output/logger.ts +18 -0
  222. package/src/output/parallel-progress.ts +103 -0
  223. package/src/output/progress.ts +414 -0
  224. package/src/provider-validator.ts +109 -0
  225. package/src/registry/cache.ts +46 -0
  226. package/src/registry/client.ts +84 -0
  227. package/src/registry/lockfile.ts +68 -0
  228. package/src/registry/resolver.ts +113 -0
  229. package/src/registry/types.ts +66 -0
  230. package/src/run-logger.ts +66 -0
  231. package/src/run-store-factory.ts +30 -0
  232. package/src/studio-dir.ts +28 -0
  233. package/src/utils/input-wizard.ts +73 -0
  234. package/src/utils/placeholders.ts +10 -0
  235. package/templates/.studiorc.yaml +16 -0
  236. package/templates/projects/blank/metadata.json +7 -0
  237. package/templates/studio-config.yaml +14 -0
  238. package/tests/__stubs__/studio-runner.ts +104 -0
  239. package/tests/commands/api.test.ts +110 -0
  240. package/tests/commands/config.test.ts +221 -0
  241. package/tests/commands/init.test.ts +919 -0
  242. package/tests/commands/install.test.ts +52 -0
  243. package/tests/commands/integrations.test.ts +158 -0
  244. package/tests/commands/ollama.test.ts +139 -0
  245. package/tests/commands/project.test.ts +179 -0
  246. package/tests/commands/registry/audit.test.ts +56 -0
  247. package/tests/commands/registry/install.test.ts +200 -0
  248. package/tests/commands/registry/publish.test.ts +56 -0
  249. package/tests/commands/registry/remove.test.ts +103 -0
  250. package/tests/commands/registry/search.test.ts +44 -0
  251. package/tests/commands/registry/sync.test.ts +37 -0
  252. package/tests/commands/registry/update.test.ts +62 -0
  253. package/tests/commands/replay.test.ts +283 -0
  254. package/tests/commands/template/validate.test.ts +150 -0
  255. package/tests/commands/templates.test.ts +42 -0
  256. package/tests/commands/tools.test.ts +106 -0
  257. package/tests/config.test.ts +142 -0
  258. package/tests/formatter.test.ts +158 -0
  259. package/tests/integration/sigint.test.ts +188 -0
  260. package/tests/models-cache.test.ts +250 -0
  261. package/tests/output/file-changes.test.ts +178 -0
  262. package/tests/output/formatters.test.ts +448 -0
  263. package/tests/output/progress-spinner.test.ts +232 -0
  264. package/tests/output/progress-timer.test.ts +230 -0
  265. package/tests/provider-validator.test.ts +182 -0
  266. package/tests/registry/cache.test.ts +66 -0
  267. package/tests/registry/client.test.ts +70 -0
  268. package/tests/registry/lockfile.test.ts +87 -0
  269. package/tests/registry/resolver.test.ts +122 -0
  270. package/tests/run-logger-events.test.ts +326 -0
  271. package/tests/run-logger.test.ts +64 -0
  272. package/tests/run-store-factory.test.ts +51 -0
  273. package/tests/studio-dir.test.ts +31 -0
  274. package/tests/utils/input-wizard.test.ts +153 -0
  275. package/tests/utils/placeholders.test.ts +36 -0
  276. package/tsconfig.json +24 -0
  277. package/vitest.config.ts +20 -0
@@ -0,0 +1,303 @@
1
+ // cli/src/output/formatters.ts
2
+
3
+ // ── Shared helpers ──────────────────────────────────────────────────────────
4
+
5
+ function titleCase(key: string): string {
6
+ return key
7
+ .split(/[-_]/)
8
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
9
+ .join(' ');
10
+ }
11
+
12
+ // ── Token formatting ──────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Formats a token count into a compact human-readable string.
16
+ * 450 → "450", 2100 → "2.1k", 17900 → "17.9k", 1234567 → "1.2M"
17
+ */
18
+ export function formatTokens(count: number): string {
19
+ if (count === 0) return '0';
20
+ if (count < 1000) return String(count);
21
+ if (count < 1_000_000) {
22
+ const k = count / 1000;
23
+ return k % 1 === 0 ? `${k}k` : `${parseFloat(k.toFixed(1))}k`;
24
+ }
25
+ const m = count / 1_000_000;
26
+ return m % 1 === 0 ? `${m}M` : `${parseFloat(m.toFixed(1))}M`;
27
+ }
28
+
29
+ // ── Stage line formatting ─────────────────────────────────────────────────────
30
+
31
+ const STAGE_LINE_WIDTH = 42;
32
+
33
+ /**
34
+ * Formats a stage progress line with dot-filling for alignment.
35
+ * formatStageLine("[1/4]", "brief-analysis", "✓ (12s, 2.1k tokens)")
36
+ * → "[1/4] brief-analysis ............ ✓ (12s, 2.1k tokens)"
37
+ */
38
+ export function formatStageLine(prefix: string, name: string, suffix: string): string {
39
+ const left = `${prefix} ${name} `;
40
+ const dotsNeeded = Math.max(2, STAGE_LINE_WIDTH - left.length);
41
+ const dots = '.'.repeat(dotsNeeded);
42
+ return `${left}${dots} ${suffix}`;
43
+ }
44
+
45
+ // ── Stage name mapping ────────────────────────────────────────────────────────
46
+
47
+ const STAGE_NAME_PATTERNS: Array<[RegExp, string]> = [
48
+ [/^brief[-_]analysis$/i, 'Analyzing brief'],
49
+ [/^implementation[-_]plan$/i, 'Planning implementation'],
50
+ [/^code[-_]gen(?:eration)?$/i, 'Generating code'],
51
+ [/^qa[-_]review$/i, 'Reviewing'],
52
+ [/^analysis$/i, 'Analysis'],
53
+ [/^planning$/i, 'Planning'],
54
+ [/^generation$/i, 'Generating'],
55
+ [/^review$/i, 'Reviewing'],
56
+ ];
57
+
58
+ /**
59
+ * Converts a kebab-case stage name to a human-readable label.
60
+ * Uses a lookup table for known names; falls back to title-casing.
61
+ */
62
+ export function humanReadableStageName(stageName: string): string {
63
+ for (const [pattern, label] of STAGE_NAME_PATTERNS) {
64
+ if (pattern.test(stageName)) return label;
65
+ }
66
+ return titleCase(stageName);
67
+ }
68
+
69
+ // ── Tool call grouping ────────────────────────────────────────────────────────
70
+
71
+ interface ToolGroup {
72
+ singular: string;
73
+ plural: string;
74
+ verb: string; // past tense, e.g. "Read", "Wrote"
75
+ }
76
+
77
+ const TOOL_GROUPS: Record<string, ToolGroup> = {
78
+ read_file: { verb: 'Read', singular: 'file', plural: 'files' },
79
+ write_file: { verb: 'Wrote', singular: 'file', plural: 'files' },
80
+ list_files: { verb: 'Listed', singular: 'directory', plural: 'directories' },
81
+ run_command: { verb: 'Ran', singular: 'command', plural: 'commands' },
82
+ search_codebase: { verb: 'Searched', singular: 'time', plural: 'times' },
83
+ apply_patch: { verb: 'Patched', singular: 'file', plural: 'files' },
84
+ };
85
+
86
+ /** Extracts the action part from a tool name like `repo_manager-read_file` → `read_file`. */
87
+ function toolAction(name: string): string {
88
+ const idx = name.lastIndexOf('-');
89
+ return idx >= 0 ? name.slice(idx + 1) : name;
90
+ }
91
+
92
+ /**
93
+ * Groups tool calls by type and returns a human-readable summary string.
94
+ * E.g. "Read 3 files, wrote 1 file, ran 2 commands"
95
+ */
96
+ export function summarizeToolCalls(toolCalls: { name: string }[]): string {
97
+ if (toolCalls.length === 0) return '';
98
+
99
+ const counts = new Map<string, number>();
100
+ let unknownCount = 0;
101
+
102
+ for (const tc of toolCalls) {
103
+ const action = toolAction(tc.name);
104
+ if (TOOL_GROUPS[action]) {
105
+ counts.set(action, (counts.get(action) ?? 0) + 1);
106
+ } else {
107
+ unknownCount++;
108
+ }
109
+ }
110
+
111
+ const parts: string[] = [];
112
+
113
+ for (const [action, count] of counts) {
114
+ const group = TOOL_GROUPS[action];
115
+ const noun = count === 1 ? group.singular : group.plural;
116
+ // First part uses title-case verb, rest lowercase
117
+ const verb = parts.length === 0 ? group.verb : group.verb.toLowerCase();
118
+ parts.push(`${verb} ${count} ${noun}`);
119
+ }
120
+
121
+ if (unknownCount > 0) {
122
+ const label = `${unknownCount} tool call${unknownCount !== 1 ? 's' : ''}`;
123
+ parts.push(label);
124
+ }
125
+
126
+ return parts.join(', ');
127
+ }
128
+
129
+ /** Counts how many tool calls wrote or patched files. */
130
+ export function countWriteFiles(toolCalls: { name: string }[]): number {
131
+ return toolCalls.filter((tc) => {
132
+ const action = toolAction(tc.name);
133
+ return action === 'write_file' || action === 'apply_patch';
134
+ }).length;
135
+ }
136
+
137
+ // ── Live mode helpers ─────────────────────────────────────────────────────────
138
+
139
+ export function getToolIcon(tool: string): string {
140
+ if (tool.startsWith('repo_manager-read')) return '📖';
141
+ if (tool.startsWith('repo_manager-write')) return '✏️';
142
+ if (tool.startsWith('repo_manager-list')) return '📁';
143
+ if (tool.startsWith('search')) return '🔍';
144
+ if (tool.startsWith('shell')) return '⚙️';
145
+ if (tool.startsWith('git')) return '🔀';
146
+ return '🔧';
147
+ }
148
+
149
+ export function summarizeToolParams(tool: string, params: Record<string, unknown>): string {
150
+ if (tool.includes('read_file') || tool.includes('write_file')) return `(${params.path})`;
151
+ if (tool.includes('list_files')) return params.path ? `(${params.path})` : '';
152
+ if (tool.includes('search')) {
153
+ const term = params.query ?? params.pattern;
154
+ return term ? `("${term}")` : '';
155
+ }
156
+ if (tool.includes('run_command')) return `("${params.command}")`;
157
+ return '';
158
+ }
159
+
160
+ export function summarizeToolResult(result: unknown, error?: string): string {
161
+ if (error) return error;
162
+ if (typeof result === 'string') {
163
+ const lines = result.split('\n').length;
164
+ return lines > 1 ? `${lines} lines` : result.slice(0, 60);
165
+ }
166
+ if (Array.isArray(result)) return `${result.length} items`;
167
+ if (result !== null && typeof result === 'object') {
168
+ const obj = result as Record<string, unknown>;
169
+ if (typeof obj.content === 'string') {
170
+ const lines = obj.content.split('\n').length;
171
+ return lines > 1 ? `${lines} lines` : (obj.content.length > 0 ? obj.content.slice(0, 60) : 'empty');
172
+ }
173
+ if (Array.isArray(obj.files)) return `${obj.files.length} files`;
174
+ if (obj.written === true) return 'written';
175
+ }
176
+ return 'Done';
177
+ }
178
+
179
+ // ── Verbose tool result formatting ───────────────────────────────────────────
180
+
181
+ /**
182
+ * Formats the full result of a tool call for verbose display.
183
+ * Each line is indented with 2 spaces.
184
+ */
185
+ export function formatToolResult(result: unknown, error?: string): string {
186
+ if (error) return ` (error) ${error}`;
187
+ if (result === null || result === undefined) return ' (empty)';
188
+
189
+ if (typeof result === 'string') {
190
+ return result.split('\n').map(line => ` ${line}`).join('\n');
191
+ }
192
+
193
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
194
+ const obj = result as Record<string, unknown>;
195
+ if (typeof obj.content === 'string') {
196
+ if (obj.content.length === 0) return ' (empty content)';
197
+ return obj.content.split('\n').map(line => ` ${line}`).join('\n');
198
+ }
199
+ }
200
+
201
+ // Arrays and other objects: indented JSON
202
+ const json = JSON.stringify(result, null, 2);
203
+ return json.split('\n').map(line => ` ${line}`).join('\n');
204
+ }
205
+
206
+ // ── Stage output formatting ─────────────────────────────────────────────────
207
+
208
+ const CIRCLED_DIGITS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
209
+
210
+ function circledIndex(i: number): string {
211
+ return i < CIRCLED_DIGITS.length ? CIRCLED_DIGITS[i] : `(${i + 1})`;
212
+ }
213
+
214
+ function isPrimitive(value: unknown): value is string | number | boolean | null | undefined {
215
+ return value === null || value === undefined || typeof value !== 'object';
216
+ }
217
+
218
+ function formatValue(value: unknown, indent: number, maxDepth: number): string {
219
+ const pad = ' '.repeat(indent);
220
+
221
+ if (value === null || value === undefined) return '—';
222
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
223
+
224
+ if (typeof value === 'string') {
225
+ if (value.length <= 80) return value;
226
+ return `\n${pad} ${value}`;
227
+ }
228
+
229
+ if (Array.isArray(value)) {
230
+ if (value.length === 0) return '(empty)';
231
+
232
+ // Array of primitives
233
+ if (value.every(isPrimitive)) {
234
+ const inline = value.map(v => String(v ?? '—')).join(', ');
235
+ if (inline.length <= 80) return inline;
236
+ return '\n' + value.map(v => `${pad} • ${String(v ?? '—')}`).join('\n');
237
+ }
238
+
239
+ // Array of objects
240
+ return '\n' + value.map((item, i) => {
241
+ const idx = circledIndex(i);
242
+ if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
243
+ const obj = item as Record<string, unknown>;
244
+ const keys = Object.keys(obj);
245
+ // Single string field: compact rendering
246
+ if (keys.length === 1 && typeof obj[keys[0]] === 'string') {
247
+ return `${pad} ${idx} ${obj[keys[0]]}`;
248
+ }
249
+ // First string field as title, rest as sub-fields
250
+ const firstStrKey = keys.find(k => typeof obj[k] === 'string');
251
+ if (firstStrKey && indent + 1 < maxDepth) {
252
+ const title = obj[firstStrKey] as string;
253
+ const rest = keys.filter(k => k !== firstStrKey);
254
+ if (rest.length === 0) return `${pad} ${idx} ${title}`;
255
+ const subPad = pad + ' ';
256
+ const maxKeyLen = Math.max(...rest.map(k => titleCase(k).length));
257
+ const subFields = rest.map(k => {
258
+ const label = titleCase(k).padEnd(maxKeyLen);
259
+ const val = formatValue(obj[k], indent + 2, maxDepth);
260
+ return `${subPad}${label} : ${val}`;
261
+ }).join('\n');
262
+ return `${pad} ${idx} ${title}\n${subFields}`;
263
+ }
264
+ }
265
+ // Fallback: JSON
266
+ return `${pad} ${idx} ${JSON.stringify(item)}`;
267
+ }).join('\n');
268
+ }
269
+
270
+ // Nested object
271
+ if (typeof value === 'object') {
272
+ if (indent + 1 >= maxDepth) return JSON.stringify(value);
273
+ const obj = value as Record<string, unknown>;
274
+ const formatted = formatObjectFields(obj, indent + 1, maxDepth);
275
+ return formatted ? '\n' + formatted : '(empty)';
276
+ }
277
+
278
+ return String(value);
279
+ }
280
+
281
+ function formatObjectFields(obj: Record<string, unknown>, indent: number, maxDepth: number): string {
282
+ const keys = Object.keys(obj);
283
+ if (keys.length === 0) return '';
284
+
285
+ const pad = ' '.repeat(indent);
286
+ const labels = keys.map(k => titleCase(k));
287
+ const maxKeyLen = Math.max(...labels.map(l => l.length));
288
+
289
+ return keys.map((key, i) => {
290
+ const label = labels[i].padEnd(maxKeyLen);
291
+ const val = formatValue(obj[key], indent, maxDepth);
292
+ return `${pad}${label} : ${val}`;
293
+ }).join('\n');
294
+ }
295
+
296
+ /**
297
+ * Formats a stage output object as a human-readable string.
298
+ * Detects types dynamically — no hardcoded field names.
299
+ * Returns plain text (no ANSI colors).
300
+ */
301
+ export function formatStageOutput(output: Record<string, unknown>, maxDepth = 4): string {
302
+ return formatObjectFields(output, 0, maxDepth);
303
+ }
@@ -0,0 +1,18 @@
1
+ import chalk from 'chalk';
2
+
3
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
4
+
5
+ export function log(level: LogLevel, message: string, data?: unknown): void {
6
+ const timestamp = new Date().toISOString().slice(11, 19);
7
+ const prefix = {
8
+ debug: chalk.gray('[DEBUG]'),
9
+ info: chalk.blue('[INFO]'),
10
+ warn: chalk.yellow('[WARN]'),
11
+ error: chalk.red('[ERROR]'),
12
+ }[level];
13
+
14
+ console.log(`${chalk.gray(timestamp)} ${prefix} ${message}`);
15
+ if (data) {
16
+ console.log(chalk.gray(JSON.stringify(data, null, 2)));
17
+ }
18
+ }
@@ -0,0 +1,103 @@
1
+ // cli/src/output/parallel-progress.ts
2
+ // Multi-line renderer for parallel stage display.
3
+ // Uses ANSI escape codes to update lines in-place without creating concurrent ora spinners.
4
+
5
+ import chalk from 'chalk';
6
+ import { formatStageLine } from './formatters.js';
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
9
+
10
+ interface StageEntry {
11
+ prefix: string;
12
+ stageName: string;
13
+ startTime: number;
14
+ done: boolean;
15
+ finalText?: string;
16
+ }
17
+
18
+ /**
19
+ * Renders N parallel stages as N lines that update in-place.
20
+ * Each line shows a spinner + elapsed time while running, then freezes on completion.
21
+ *
22
+ * Visual output:
23
+ * [5/14] entity-resolution-PERSON ............ ⠇ (12s)
24
+ * [6/14] entity-resolution-PLACE ............. ⠙ (12s)
25
+ * [7/14] entity-resolution-ORG ............... ⠹ (12s)
26
+ */
27
+ export class ParallelRenderer {
28
+ private stages: StageEntry[] = [];
29
+ private frameIdx = 0;
30
+ private timer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ /**
33
+ * Register a new parallel stage and print its initial line.
34
+ * All addStage calls fire synchronously before any stage completes,
35
+ * so lines are built up before the first timer tick.
36
+ */
37
+ addStage(prefix: string, stageName: string): void {
38
+ const entry: StageEntry = {
39
+ prefix,
40
+ stageName,
41
+ startTime: Date.now(),
42
+ done: false,
43
+ };
44
+ this.stages.push(entry);
45
+ process.stdout.write(this.runningText(entry) + '\n');
46
+
47
+ if (!this.timer) {
48
+ this.timer = setInterval(() => this.renderAll(), 100);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Freeze a stage's line with its final status text (✓ or ✗).
54
+ */
55
+ completeStage(stageName: string, finalText: string): void {
56
+ const entry = this.stages.find(s => s.stageName === stageName);
57
+ if (!entry) return;
58
+ entry.done = true;
59
+ entry.finalText = finalText;
60
+ this.renderAll();
61
+
62
+ if (this.stages.every(s => s.done)) {
63
+ this.stop();
64
+ }
65
+ }
66
+
67
+ private runningText(entry: StageEntry): string {
68
+ const elapsed = Math.floor((Date.now() - entry.startTime) / 1000);
69
+ const frame = chalk.cyan(SPINNER_FRAMES[this.frameIdx]);
70
+ return formatStageLine(entry.prefix, entry.stageName, `${frame} (${elapsed}s)`);
71
+ }
72
+
73
+ /**
74
+ * Redraw all lines in one pass, moving the cursor to the top of the block first.
75
+ * Node.js is single-threaded so this is safe — timer and event handlers don't interleave.
76
+ */
77
+ private renderAll(): void {
78
+ this.frameIdx = (this.frameIdx + 1) % SPINNER_FRAMES.length;
79
+ const N = this.stages.length;
80
+ if (N === 0) return;
81
+
82
+ // Move cursor to the top of the rendered block
83
+ process.stdout.write(`\x1B[${N}A`);
84
+
85
+ for (const entry of this.stages) {
86
+ const text = entry.done ? entry.finalText! : this.runningText(entry);
87
+ // Clear line, go to column 0, write text, advance to next line
88
+ process.stdout.write(`\x1B[2K\r${text}\n`);
89
+ }
90
+ // Cursor is now positioned below the last line, ready for more output
91
+ }
92
+
93
+ stop(): void {
94
+ if (this.timer) {
95
+ clearInterval(this.timer);
96
+ this.timer = null;
97
+ }
98
+ }
99
+
100
+ interrupt(): void {
101
+ this.stop();
102
+ }
103
+ }