@studio-foundation/cli 0.3.0-beta.1 → 0.3.0-beta.6

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 (88) hide show
  1. package/package.json +9 -5
  2. package/ARCHITECTURE.md +0 -40
  3. package/src/commands/api.ts +0 -126
  4. package/src/commands/config.ts +0 -433
  5. package/src/commands/init.ts +0 -879
  6. package/src/commands/install.ts +0 -23
  7. package/src/commands/integrations.ts +0 -332
  8. package/src/commands/list.ts +0 -197
  9. package/src/commands/logs.ts +0 -119
  10. package/src/commands/ollama.ts +0 -168
  11. package/src/commands/project.ts +0 -167
  12. package/src/commands/registry/audit.ts +0 -87
  13. package/src/commands/registry/index.ts +0 -63
  14. package/src/commands/registry/install.ts +0 -222
  15. package/src/commands/registry/publish.ts +0 -112
  16. package/src/commands/registry/remove.ts +0 -89
  17. package/src/commands/registry/search.ts +0 -93
  18. package/src/commands/registry/sync.ts +0 -24
  19. package/src/commands/registry/update.ts +0 -63
  20. package/src/commands/replay.ts +0 -559
  21. package/src/commands/run.ts +0 -454
  22. package/src/commands/status.ts +0 -163
  23. package/src/commands/template/index.ts +0 -59
  24. package/src/commands/template/validate.ts +0 -175
  25. package/src/commands/templates.ts +0 -99
  26. package/src/commands/tools.ts +0 -227
  27. package/src/commands/users.ts +0 -127
  28. package/src/commands/validate.ts +0 -46
  29. package/src/config.ts +0 -101
  30. package/src/index.ts +0 -201
  31. package/src/models-cache.ts +0 -127
  32. package/src/output/file-changes.ts +0 -97
  33. package/src/output/formatter.ts +0 -85
  34. package/src/output/formatters.ts +0 -303
  35. package/src/output/logger.ts +0 -18
  36. package/src/output/parallel-progress.ts +0 -103
  37. package/src/output/progress.ts +0 -414
  38. package/src/provider-validator.ts +0 -109
  39. package/src/registry/cache.ts +0 -46
  40. package/src/registry/client.ts +0 -84
  41. package/src/registry/lockfile.ts +0 -68
  42. package/src/registry/resolver.ts +0 -113
  43. package/src/registry/types.ts +0 -66
  44. package/src/run-logger.ts +0 -66
  45. package/src/run-store-factory.ts +0 -30
  46. package/src/studio-dir.ts +0 -28
  47. package/src/utils/input-wizard.ts +0 -73
  48. package/src/utils/placeholders.ts +0 -10
  49. package/tests/__stubs__/studio-runner.ts +0 -104
  50. package/tests/commands/api.test.ts +0 -110
  51. package/tests/commands/config.test.ts +0 -221
  52. package/tests/commands/init.test.ts +0 -919
  53. package/tests/commands/install.test.ts +0 -52
  54. package/tests/commands/integrations.test.ts +0 -158
  55. package/tests/commands/ollama.test.ts +0 -139
  56. package/tests/commands/project.test.ts +0 -179
  57. package/tests/commands/registry/audit.test.ts +0 -56
  58. package/tests/commands/registry/install.test.ts +0 -200
  59. package/tests/commands/registry/publish.test.ts +0 -56
  60. package/tests/commands/registry/remove.test.ts +0 -103
  61. package/tests/commands/registry/search.test.ts +0 -44
  62. package/tests/commands/registry/sync.test.ts +0 -37
  63. package/tests/commands/registry/update.test.ts +0 -62
  64. package/tests/commands/replay.test.ts +0 -283
  65. package/tests/commands/template/validate.test.ts +0 -150
  66. package/tests/commands/templates.test.ts +0 -42
  67. package/tests/commands/tools.test.ts +0 -106
  68. package/tests/config.test.ts +0 -142
  69. package/tests/formatter.test.ts +0 -158
  70. package/tests/integration/sigint.test.ts +0 -188
  71. package/tests/models-cache.test.ts +0 -250
  72. package/tests/output/file-changes.test.ts +0 -178
  73. package/tests/output/formatters.test.ts +0 -448
  74. package/tests/output/progress-spinner.test.ts +0 -232
  75. package/tests/output/progress-timer.test.ts +0 -230
  76. package/tests/provider-validator.test.ts +0 -182
  77. package/tests/registry/cache.test.ts +0 -66
  78. package/tests/registry/client.test.ts +0 -70
  79. package/tests/registry/lockfile.test.ts +0 -87
  80. package/tests/registry/resolver.test.ts +0 -122
  81. package/tests/run-logger-events.test.ts +0 -326
  82. package/tests/run-logger.test.ts +0 -64
  83. package/tests/run-store-factory.test.ts +0 -51
  84. package/tests/studio-dir.test.ts +0 -31
  85. package/tests/utils/input-wizard.test.ts +0 -153
  86. package/tests/utils/placeholders.test.ts +0 -36
  87. package/tsconfig.json +0 -24
  88. package/vitest.config.ts +0 -20
@@ -1,414 +0,0 @@
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
- }
@@ -1,109 +0,0 @@
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
- }
@@ -1,46 +0,0 @@
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
- }
@@ -1,84 +0,0 @@
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
- }