@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,414 @@
1
+ import chalk from 'chalk';
2
+ import ora, { type Ora } from 'ora';
3
+ import type { EngineEvents } from '@studio-foundation/engine';
4
+ import { formatDuration } from './formatter.js';
5
+ import { summarizeToolCalls, getToolIcon, summarizeToolParams, summarizeToolResult, formatStageOutput, formatToolResult, formatTokens, formatStageLine, countWriteFiles } from './formatters.js';
6
+ import { ParallelRenderer } from './parallel-progress.js';
7
+
8
+ export class ProgressDisplay {
9
+ private spinner: Ora | null = null;
10
+ private spinnerText = '';
11
+ private toolSpinner: Ora | null = null;
12
+ private thinkingSpinner: Ora | null = null;
13
+ private currentToolText = '';
14
+ private isStreamingTokens = false;
15
+ private stageStartTime = 0;
16
+ private timerInterval: ReturnType<typeof setInterval> | null = null;
17
+
18
+ // Parallel group rendering
19
+ private isInParallelGroup = false;
20
+ private parallelRenderer: ParallelRenderer | null = null;
21
+
22
+ // State tracking for stage progress
23
+ runId = '';
24
+ private currentAttempt = 1;
25
+ private currentStageIndex = 0;
26
+ private currentTotalStages = 0;
27
+ private currentStageName = '';
28
+
29
+ readonly live: boolean;
30
+ readonly verbose: boolean;
31
+
32
+ constructor(
33
+ private jsonMode: boolean,
34
+ mode: 'quiet' | 'verbose' | 'live' | { live: boolean; verbose: boolean }
35
+ ) {
36
+ if (typeof mode === 'string') {
37
+ this.live = mode === 'live';
38
+ this.verbose = mode === 'verbose';
39
+ } else {
40
+ this.live = mode.live;
41
+ this.verbose = mode.verbose;
42
+ }
43
+ }
44
+
45
+ private resetStageTimer(): void {
46
+ this.stageStartTime = Date.now();
47
+ }
48
+
49
+ private startTimer(updateFn: (elapsed: string) => void): void {
50
+ this.timerInterval = setInterval(() => {
51
+ const s = Math.floor((Date.now() - this.stageStartTime) / 1000);
52
+ updateFn(`${s}s`);
53
+ }, 1000);
54
+ }
55
+
56
+ private clearTimer(): void {
57
+ if (this.timerInterval) {
58
+ clearInterval(this.timerInterval);
59
+ this.timerInterval = null;
60
+ }
61
+ }
62
+
63
+ private elapsedSeconds(): number {
64
+ return Math.floor((Date.now() - this.stageStartTime) / 1000);
65
+ }
66
+
67
+ interrupt(): void {
68
+ this.clearTimer();
69
+ this.parallelRenderer?.interrupt();
70
+ this.parallelRenderer = null;
71
+ this.isInParallelGroup = false;
72
+ if (this.isStreamingTokens) {
73
+ process.stdout.write('\n');
74
+ this.isStreamingTokens = false;
75
+ }
76
+ this.toolSpinner?.stop();
77
+ this.toolSpinner = null;
78
+ this.thinkingSpinner?.stop();
79
+ this.thinkingSpinner = null;
80
+ this.spinner?.stop();
81
+ this.spinner = null;
82
+ }
83
+
84
+ getEvents(): EngineEvents {
85
+ return {
86
+ onPipelineStart: (event) => {
87
+ if (this.jsonMode) return;
88
+ this.runId = event.run_id;
89
+ console.log(chalk.blue(`\nRunning pipeline: ${event.pipeline_name}\n`));
90
+ },
91
+
92
+ onStageStart: (event) => {
93
+ if (this.jsonMode) return;
94
+ this.currentStageIndex = event.stage_index;
95
+ this.currentTotalStages = event.total_stages;
96
+ this.currentStageName = event.stage_name;
97
+ this.currentAttempt = 1;
98
+ const prefix = `[${event.stage_index + 1}/${event.total_stages}]`;
99
+
100
+ if (this.isInParallelGroup) {
101
+ // Parallel mode: delegate to multi-line renderer (no ora spinner)
102
+ this.parallelRenderer!.addStage(prefix, event.stage_name);
103
+ return;
104
+ }
105
+
106
+ if (this.live) {
107
+ console.log(chalk.cyan(`${formatStageLine(prefix, event.stage_name, '')}...`));
108
+ this.thinkingSpinner = ora({ text: chalk.dim('Thinking... (0s)'), indent: 2, color: 'gray' }).start();
109
+ this.resetStageTimer();
110
+ this.startTimer((elapsed) => {
111
+ if (this.thinkingSpinner) {
112
+ this.thinkingSpinner.text = chalk.dim(`Thinking... (${elapsed})`);
113
+ }
114
+ });
115
+ } else {
116
+ this.spinnerText = formatStageLine(prefix, event.stage_name, '');
117
+ this.spinner = ora({ text: this.spinnerText, color: 'cyan' }).start();
118
+ this.resetStageTimer();
119
+ this.startTimer((elapsed) => {
120
+ if (this.spinner) {
121
+ this.spinner.text = formatStageLine(prefix, event.stage_name, `(${elapsed})`);
122
+ }
123
+ });
124
+ }
125
+ },
126
+
127
+ onStageComplete: (event) => {
128
+ if (this.jsonMode) return;
129
+
130
+ const duration = formatDuration(event.duration_ms);
131
+ const prefix = `[${event.stage_index + 1}/${event.total_stages}]`;
132
+
133
+ // Build compact info parts: duration, tokens, files
134
+ const infoParts: string[] = [duration];
135
+ if (event.token_usage && event.token_usage.total_tokens > 0) {
136
+ infoParts.push(`${formatTokens(event.token_usage.total_tokens)} tokens`);
137
+ }
138
+ const filesWritten = event.tool_calls ? countWriteFiles(event.tool_calls) : 0;
139
+ if (filesWritten > 0) {
140
+ infoParts.push(`${filesWritten} file${filesWritten !== 1 ? 's' : ''}`);
141
+ }
142
+ const infoStr = infoParts.join(', ');
143
+
144
+ if (this.isInParallelGroup) {
145
+ // Parallel mode: freeze the line with final status
146
+ let finalText: string;
147
+ if (event.status === 'success') {
148
+ finalText = formatStageLine(prefix, event.stage_name, chalk.green('✓') + chalk.gray(` (${infoStr})`));
149
+ } else if (event.status === 'rejected') {
150
+ finalText = formatStageLine(prefix, event.stage_name, chalk.red('✗ rejected') + chalk.gray(` (${duration})`));
151
+ } else {
152
+ finalText = formatStageLine(prefix, event.stage_name, chalk.red('✗ failed') + chalk.gray(` (${infoStr})`));
153
+ }
154
+ this.parallelRenderer?.completeStage(event.stage_name, finalText);
155
+ return;
156
+ }
157
+
158
+ if (this.live) {
159
+ if (this.isStreamingTokens) {
160
+ process.stdout.write('\n');
161
+ this.isStreamingTokens = false;
162
+ }
163
+ this.clearTimer();
164
+ this.thinkingSpinner?.stop();
165
+ this.thinkingSpinner = null;
166
+ if (event.status === 'success') {
167
+ console.log(chalk.green(` ✓`) + chalk.gray(` (${infoStr})`));
168
+ } else if (event.status === 'rejected') {
169
+ console.log(chalk.red(` ✗ rejected`) + chalk.gray(` (${duration})`));
170
+ if (event.rejection_reason) console.log(chalk.red(` ${event.rejection_reason}`));
171
+ if (event.rejection_details?.length) {
172
+ for (const detail of event.rejection_details) {
173
+ console.log(chalk.yellow(` - ${detail}`));
174
+ }
175
+ }
176
+ } else {
177
+ console.log(chalk.red(` ✗ failed`) + chalk.gray(` (${infoStr})`));
178
+ }
179
+ } else if (event.status === 'success') {
180
+ this.spinner?.succeed(
181
+ formatStageLine(prefix, event.stage_name, chalk.green('✓') + chalk.gray(` (${infoStr})`))
182
+ );
183
+ } else if (event.status === 'rejected') {
184
+ this.spinner?.fail(
185
+ formatStageLine(prefix, event.stage_name, chalk.red('✗ rejected') + chalk.gray(` (${duration})`))
186
+ );
187
+ if (event.rejection_reason) {
188
+ console.log(chalk.red(` ${event.rejection_reason}`));
189
+ }
190
+ if (event.rejection_details?.length) {
191
+ for (const detail of event.rejection_details) {
192
+ console.log(chalk.yellow(` - ${detail}`));
193
+ }
194
+ }
195
+ } else {
196
+ this.spinner?.fail(
197
+ formatStageLine(prefix, event.stage_name, chalk.red('✗ failed') + chalk.gray(` (${infoStr})`))
198
+ );
199
+ }
200
+ this.clearTimer();
201
+ this.spinner = null;
202
+
203
+ // Tool call summary: quiet + verbose only (in live mode, each was shown individually)
204
+ if (!this.live && event.tool_calls && event.tool_calls.length > 0) {
205
+ const summary = summarizeToolCalls(event.tool_calls);
206
+ if (summary) console.log(chalk.gray(` ${summary}`));
207
+ }
208
+
209
+ // Formatted output: verbose only
210
+ if (this.verbose && event.status !== 'rejected' && event.output && typeof event.output === 'object') {
211
+ const depth = Infinity;
212
+ const formatted = formatStageOutput(event.output as Record<string, unknown>, depth);
213
+ if (formatted) {
214
+ for (const line of formatted.split('\n')) {
215
+ console.log(chalk.gray(` ${line}`));
216
+ }
217
+ }
218
+ }
219
+
220
+ // Token breakdown: verbose mode (both standalone and live+verbose)
221
+ if (this.verbose && event.token_usage) {
222
+ const u = event.token_usage;
223
+ console.log(chalk.gray(` Tokens: ${u.prompt_tokens} prompt + ${u.completion_tokens} completion = ${u.total_tokens} total`));
224
+ }
225
+ },
226
+
227
+ onTaskRetry: (event) => {
228
+ if (this.jsonMode) return;
229
+
230
+ if (this.isInParallelGroup) {
231
+ // In parallel mode the spinner keeps running; nothing to do here.
232
+ return;
233
+ }
234
+
235
+ this.clearTimer();
236
+ // Stop any active spinners before printing retry info
237
+ if (this.isStreamingTokens) {
238
+ process.stdout.write('\n');
239
+ this.isStreamingTokens = false;
240
+ }
241
+ this.toolSpinner?.stop();
242
+ this.toolSpinner = null;
243
+ this.thinkingSpinner?.stop();
244
+ this.thinkingSpinner = null;
245
+
246
+ this.currentAttempt = event.attempt + 1;
247
+ const prefix = `[${this.currentStageIndex + 1}/${this.currentTotalStages}]`;
248
+ const reason = event.failures.length > 0 ? event.failures[0] : 'validation failed';
249
+
250
+ if (this.live) {
251
+ console.log(chalk.yellow(` ✗ retry (${reason})`));
252
+ } else {
253
+ this.spinner?.fail(
254
+ formatStageLine(prefix, this.currentStageName, chalk.yellow('✗ retry') + chalk.gray(` (${reason})`))
255
+ );
256
+ this.spinner = null;
257
+ }
258
+
259
+ // Verbose extras
260
+ if (this.verbose && event.failures.length > 1) {
261
+ for (const failure of event.failures.slice(1)) {
262
+ console.log(chalk.yellow(` - ${failure}`));
263
+ }
264
+ }
265
+ if (this.verbose && event.agent_output_raw) {
266
+ console.log(chalk.gray(` Raw response: ${event.agent_output_raw.slice(0, 300)}`));
267
+ }
268
+
269
+ // Restart spinner with next attempt counter
270
+ if (!this.live) {
271
+ const suffix = `(attempt ${this.currentAttempt}/${event.max_attempts})`;
272
+ this.spinnerText = formatStageLine(prefix, this.currentStageName, suffix);
273
+ this.spinner = ora({ text: this.spinnerText, color: 'cyan' }).start();
274
+ }
275
+ },
276
+
277
+ onGroupStart: (event) => {
278
+ if (event.parallel) {
279
+ this.isInParallelGroup = true;
280
+ this.parallelRenderer = new ParallelRenderer();
281
+ }
282
+ // Otherwise silent — sequential group is transparent at the pipeline level
283
+ },
284
+
285
+ onGroupIteration: (event) => {
286
+ if (this.jsonMode) return;
287
+ if (event.iteration > 1) {
288
+ console.log(chalk.yellow(`\n ↻ Feedback loop iteration ${event.iteration}/${event.max_iterations}`));
289
+ }
290
+ },
291
+
292
+ onGroupFeedback: (event) => {
293
+ if (this.jsonMode) return;
294
+ console.log(chalk.yellow(` Rejected: ${event.rejection_reason}`));
295
+ if (this.verbose && event.rejection_details.length > 0) {
296
+ for (const detail of event.rejection_details) {
297
+ console.log(chalk.yellow(` - ${detail}`));
298
+ }
299
+ }
300
+ console.log(chalk.yellow(` Re-running with feedback...`));
301
+ },
302
+
303
+ onGroupComplete: (event) => {
304
+ if (this.isInParallelGroup) {
305
+ this.parallelRenderer?.stop();
306
+ this.parallelRenderer = null;
307
+ this.isInParallelGroup = false;
308
+ }
309
+ if (this.jsonMode) return;
310
+ if (event.iterations > 1) {
311
+ if (event.status === 'success') {
312
+ console.log(chalk.green(` ✓ Approved after ${event.iterations} iterations`));
313
+ } else {
314
+ console.log(chalk.red(` ✗ Rejected after ${event.iterations} iterations (max reached)`));
315
+ }
316
+ }
317
+ },
318
+
319
+ onAgentThinking: (event) => {
320
+ if (this.jsonMode || !this.live || this.isInParallelGroup) return;
321
+ for (const line of event.thought.trim().split('\n')) {
322
+ const trimmed = line.trim();
323
+ if (trimmed) console.log(chalk.dim(` 🤔 ${trimmed}`));
324
+ }
325
+ },
326
+
327
+ onAgentProgress: (event) => {
328
+ if (this.jsonMode || !this.live || this.isInParallelGroup) return;
329
+ for (const line of event.message.trim().split('\n')) {
330
+ const trimmed = line.trim();
331
+ if (trimmed) console.log(chalk.dim(` 💭 ${trimmed}`));
332
+ }
333
+ },
334
+
335
+ onAgentToken: (event) => {
336
+ if (this.jsonMode || !this.live || this.isInParallelGroup) return;
337
+ if (this.thinkingSpinner) {
338
+ this.clearTimer();
339
+ this.thinkingSpinner.stop();
340
+ this.thinkingSpinner = null;
341
+ process.stdout.write(' '); // indent to match spinner position
342
+ }
343
+ this.isStreamingTokens = true;
344
+ process.stdout.write(chalk.dim(event.token));
345
+ },
346
+
347
+ onToolCallStart: (event) => {
348
+ if (this.jsonMode || !this.live || this.isInParallelGroup) return;
349
+ // End any in-progress token stream line before starting tool spinner
350
+ if (this.isStreamingTokens) {
351
+ process.stdout.write('\n');
352
+ this.isStreamingTokens = false;
353
+ }
354
+ this.clearTimer();
355
+ this.thinkingSpinner?.stop();
356
+ this.thinkingSpinner = null;
357
+ const icon = getToolIcon(event.tool);
358
+ const params = summarizeToolParams(event.tool, event.params);
359
+ this.currentToolText = `${icon} ${event.tool}${params}`;
360
+ this.toolSpinner = ora({
361
+ text: chalk.white(this.currentToolText),
362
+ indent: 2,
363
+ color: 'cyan',
364
+ }).start();
365
+ },
366
+
367
+ onToolCallComplete: (event) => {
368
+ if (this.jsonMode || !this.live || this.isInParallelGroup) return;
369
+ const summary = summarizeToolResult(event.result, event.error);
370
+ if (event.error) {
371
+ this.toolSpinner?.fail(chalk.red(`${this.currentToolText} — ${event.error}`));
372
+ } else {
373
+ this.toolSpinner?.succeed(chalk.white(this.currentToolText) + chalk.gray(` → ${summary}`));
374
+ }
375
+ this.toolSpinner = null;
376
+
377
+ // Verbose: print full tool result below the spinner line
378
+ if (this.verbose && !event.error) {
379
+ const full = formatToolResult(event.result);
380
+ for (const line of full.split('\n')) {
381
+ console.log(chalk.gray(line));
382
+ }
383
+ }
384
+
385
+ // Restart thinking spinner even on error — LLM still processes the result and may retry
386
+ const fromSec = this.elapsedSeconds();
387
+ this.thinkingSpinner = ora({ text: chalk.dim(`Thinking... (from ${fromSec}s)`), indent: 2, color: 'gray' }).start();
388
+ this.thinkingSpinner.text = chalk.dim(`Thinking... (from ${fromSec}s)`);
389
+ this.startTimer((elapsed) => {
390
+ if (this.thinkingSpinner) {
391
+ this.thinkingSpinner.text = chalk.dim(`Thinking... (from ${elapsed})`);
392
+ }
393
+ });
394
+ },
395
+
396
+ onPipelineComplete: (event) => {
397
+ if (this.jsonMode) return;
398
+
399
+ console.log('');
400
+ const duration = formatDuration(event.duration_ms);
401
+ const tokenStr = event.total_tokens > 0 ? `, ${formatTokens(event.total_tokens)} tokens` : '';
402
+ const toolStr = event.total_tool_calls > 0 ? `, ${event.total_tool_calls} tool calls` : '';
403
+
404
+ if (event.status === 'success') {
405
+ console.log(chalk.green('✓ Pipeline completed') + chalk.gray(` (${duration}${tokenStr}${toolStr})`));
406
+ } else if (event.status === 'rejected') {
407
+ console.log(chalk.red('✗ Pipeline rejected') + chalk.gray(` (${duration}${tokenStr})`));
408
+ } else {
409
+ console.log(chalk.red('✗ Pipeline failed') + chalk.gray(` (${duration}${tokenStr})`));
410
+ }
411
+ },
412
+ };
413
+ }
414
+ }
@@ -0,0 +1,109 @@
1
+ import { parseAndCacheModels } from './models-cache.js';
2
+
3
+ export type ValidationResult =
4
+ | { status: 'valid' }
5
+ | { status: 'invalid'; error: string }
6
+ | { status: 'warning'; message: string };
7
+
8
+ export interface ValidateOptions {
9
+ baseUrl?: string;
10
+ timeoutMs?: number;
11
+ }
12
+
13
+ const DEFAULT_TIMEOUT_MS = 5000;
14
+
15
+ export async function validateApiKeyLive(
16
+ provider: string,
17
+ apiKey: string,
18
+ options: ValidateOptions = {}
19
+ ): Promise<ValidationResult> {
20
+ const abort = new AbortController();
21
+ const timer = setTimeout(
22
+ () => abort.abort(),
23
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS
24
+ );
25
+
26
+ try {
27
+ if (provider === 'anthropic') {
28
+ return await validateAnthropicKey(apiKey, abort.signal);
29
+ } else if (provider === 'openai') {
30
+ return await validateOpenAIKey(apiKey, abort.signal);
31
+ } else if (provider === 'google') {
32
+ return await validateGoogleKey(apiKey, abort.signal);
33
+ } else if (provider === 'ollama') {
34
+ return await validateLocalOllama(
35
+ options.baseUrl ?? 'http://localhost:11434',
36
+ abort.signal
37
+ );
38
+ } else {
39
+ return { status: 'warning', message: `Cannot validate unknown provider '${provider}'` };
40
+ }
41
+ } catch (err) {
42
+ if (err instanceof Error && err.name === 'AbortError') {
43
+ return { status: 'warning', message: 'Validation timed out — proceeding anyway' };
44
+ }
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ return { status: 'warning', message: `Could not validate key: ${message}` };
47
+ } finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+
52
+ async function validateAnthropicKey(
53
+ apiKey: string,
54
+ signal: AbortSignal
55
+ ): Promise<ValidationResult> {
56
+ const res = await fetch('https://api.anthropic.com/v1/models', {
57
+ signal,
58
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
59
+ });
60
+ if (res.status === 200) {
61
+ try { parseAndCacheModels('anthropic', apiKey, await res.json()); } catch { /* ignore */ }
62
+ return { status: 'valid' };
63
+ }
64
+ if (res.status === 401 || res.status === 403) {
65
+ return { status: 'invalid', error: `Invalid key (${res.status} Unauthorized)` };
66
+ }
67
+ return { status: 'warning', message: `Unexpected response ${res.status} — proceeding anyway` };
68
+ }
69
+
70
+ async function validateOpenAIKey(
71
+ apiKey: string,
72
+ signal: AbortSignal
73
+ ): Promise<ValidationResult> {
74
+ const res = await fetch('https://api.openai.com/v1/models', {
75
+ signal,
76
+ headers: { Authorization: `Bearer ${apiKey}` },
77
+ });
78
+ if (res.status === 200) {
79
+ try { parseAndCacheModels('openai', apiKey, await res.json()); } catch { /* ignore */ }
80
+ return { status: 'valid' };
81
+ }
82
+ if (res.status === 401 || res.status === 403) {
83
+ return { status: 'invalid', error: `Invalid key (${res.status} Unauthorized)` };
84
+ }
85
+ return { status: 'warning', message: `Unexpected response ${res.status} — proceeding anyway` };
86
+ }
87
+
88
+ async function validateGoogleKey(
89
+ apiKey: string,
90
+ signal: AbortSignal
91
+ ): Promise<ValidationResult> {
92
+ const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
93
+ const res = await fetch(url, { signal });
94
+ if (res.status === 200) return { status: 'valid' };
95
+ if (res.status === 400 || res.status === 403) {
96
+ return { status: 'invalid', error: `Invalid key (${res.status})` };
97
+ }
98
+ return { status: 'warning', message: `Unexpected response ${res.status} — proceeding anyway` };
99
+ }
100
+
101
+ async function validateLocalOllama(
102
+ baseUrl: string,
103
+ signal: AbortSignal
104
+ ): Promise<ValidationResult> {
105
+ const url = baseUrl.replace(/\/$/, '') + '/api/tags';
106
+ const res = await fetch(url, { signal });
107
+ if (res.status === 200) return { status: 'valid' };
108
+ return { status: 'warning', message: `Ollama returned ${res.status} — proceeding anyway` };
109
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { RegistryIndex } from './types.js';
5
+
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
7
+
8
+ interface CachedIndex extends RegistryIndex {
9
+ _cached_at: string;
10
+ }
11
+
12
+ export class RegistryCache {
13
+ private cacheDir: string;
14
+ private cachePath: string;
15
+
16
+ constructor(cacheDir?: string) {
17
+ this.cacheDir = cacheDir ?? resolve(homedir(), '.cache', 'studio', 'registry');
18
+ this.cachePath = resolve(this.cacheDir, 'index.json');
19
+ }
20
+
21
+ async read(): Promise<RegistryIndex | null> {
22
+ let raw: string;
23
+ try {
24
+ raw = await readFile(this.cachePath, 'utf8');
25
+ } catch {
26
+ return null;
27
+ }
28
+
29
+ const data = JSON.parse(raw) as CachedIndex;
30
+ const age = Date.now() - new Date(data._cached_at).getTime();
31
+ if (age > CACHE_TTL_MS) return null;
32
+
33
+ const { _cached_at: _, ...index } = data;
34
+ return index as RegistryIndex;
35
+ }
36
+
37
+ async write(index: RegistryIndex): Promise<void> {
38
+ await mkdir(this.cacheDir, { recursive: true });
39
+ const data: CachedIndex = { ...index, _cached_at: new Date().toISOString() };
40
+ await writeFile(this.cachePath, JSON.stringify(data, null, 2) + '\n');
41
+ }
42
+
43
+ async isFresh(): Promise<boolean> {
44
+ return (await this.read()) !== null;
45
+ }
46
+ }
@@ -0,0 +1,84 @@
1
+ import { writeFile, mkdir } from 'node:fs/promises';
2
+ import { resolve, dirname } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import type { RegistryIndex, PackageMetadata, PackageType } from './types.js';
5
+ import { REGISTRY_RAW_BASE, REGISTRY_API_BASE } from './types.js';
6
+
7
+ interface GitHubContentItem {
8
+ name: string;
9
+ path: string;
10
+ type: 'file' | 'dir';
11
+ download_url: string | null;
12
+ }
13
+
14
+ export class RegistryClient {
15
+ async fetchIndex(): Promise<RegistryIndex> {
16
+ const res = await fetch(`${REGISTRY_RAW_BASE}/index.json`);
17
+ if (!res.ok) throw new Error(`Failed to fetch registry index: HTTP ${res.status}`);
18
+ return res.json() as Promise<RegistryIndex>;
19
+ }
20
+
21
+ async fetchMetadata(type: PackageType, name: string): Promise<PackageMetadata> {
22
+ const res = await fetch(`${REGISTRY_RAW_BASE}/${type}s/${name}/metadata.json`);
23
+ if (!res.ok) throw new Error(`Package '${name}' not found in registry`);
24
+ return res.json() as Promise<PackageMetadata>;
25
+ }
26
+
27
+ /**
28
+ * Download a single-file package (tool, pipeline, integration, agent, skill).
29
+ * Returns { destPath, sha256 }.
30
+ */
31
+ async downloadFile(
32
+ type: PackageType,
33
+ name: string,
34
+ filename: string,
35
+ destDir: string,
36
+ ): Promise<{ destPath: string; sha256: string }> {
37
+ const url = `${REGISTRY_RAW_BASE}/${type}s/${name}/${filename}`;
38
+ const res = await fetch(url);
39
+ if (!res.ok) throw new Error(`Failed to download ${filename}: HTTP ${res.status}`);
40
+ const content = await res.text();
41
+ const destPath = resolve(destDir, filename);
42
+ await mkdir(destDir, { recursive: true });
43
+ await writeFile(destPath, content);
44
+ const sha256 = createHash('sha256').update(content).digest('hex');
45
+ return { destPath, sha256 };
46
+ }
47
+
48
+ /**
49
+ * Download a directory package (template, plugin) via GitHub API.
50
+ * Returns SHA256 of all file contents concatenated (sorted by path).
51
+ */
52
+ async downloadDirectory(
53
+ type: PackageType,
54
+ name: string,
55
+ remotePath: string,
56
+ localDestDir: string,
57
+ ): Promise<string> {
58
+ const res = await fetch(
59
+ `${REGISTRY_API_BASE}/contents/${type}s/${name}/${remotePath}`,
60
+ { headers: { Accept: 'application/vnd.github+json' } },
61
+ );
62
+ if (!res.ok) throw new Error(`Failed to list directory: HTTP ${res.status}`);
63
+ const items = (await res.json()) as GitHubContentItem[];
64
+
65
+ const hash = createHash('sha256');
66
+ const sortedItems = [...items].sort((a, b) => a.path.localeCompare(b.path));
67
+
68
+ for (const item of sortedItems) {
69
+ const localPath = resolve(localDestDir, item.name);
70
+ if (item.type === 'dir') {
71
+ await this.downloadDirectory(type, name, `${remotePath}/${item.name}`, localPath);
72
+ } else if (item.download_url) {
73
+ const fileRes = await fetch(item.download_url);
74
+ if (!fileRes.ok) throw new Error(`Failed to download ${item.path}`);
75
+ const content = await fileRes.text();
76
+ await mkdir(dirname(localPath), { recursive: true });
77
+ await writeFile(localPath, content);
78
+ hash.update(item.path + content);
79
+ }
80
+ }
81
+
82
+ return hash.digest('hex');
83
+ }
84
+ }