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

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 (91) hide show
  1. package/package.json +8 -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/templates/.studiorc.yaml +0 -16
  50. package/templates/projects/blank/metadata.json +0 -7
  51. package/templates/studio-config.yaml +0 -14
  52. package/tests/__stubs__/studio-runner.ts +0 -104
  53. package/tests/commands/api.test.ts +0 -110
  54. package/tests/commands/config.test.ts +0 -221
  55. package/tests/commands/init.test.ts +0 -919
  56. package/tests/commands/install.test.ts +0 -52
  57. package/tests/commands/integrations.test.ts +0 -158
  58. package/tests/commands/ollama.test.ts +0 -139
  59. package/tests/commands/project.test.ts +0 -179
  60. package/tests/commands/registry/audit.test.ts +0 -56
  61. package/tests/commands/registry/install.test.ts +0 -200
  62. package/tests/commands/registry/publish.test.ts +0 -56
  63. package/tests/commands/registry/remove.test.ts +0 -103
  64. package/tests/commands/registry/search.test.ts +0 -44
  65. package/tests/commands/registry/sync.test.ts +0 -37
  66. package/tests/commands/registry/update.test.ts +0 -62
  67. package/tests/commands/replay.test.ts +0 -283
  68. package/tests/commands/template/validate.test.ts +0 -150
  69. package/tests/commands/templates.test.ts +0 -42
  70. package/tests/commands/tools.test.ts +0 -106
  71. package/tests/config.test.ts +0 -142
  72. package/tests/formatter.test.ts +0 -158
  73. package/tests/integration/sigint.test.ts +0 -188
  74. package/tests/models-cache.test.ts +0 -250
  75. package/tests/output/file-changes.test.ts +0 -178
  76. package/tests/output/formatters.test.ts +0 -448
  77. package/tests/output/progress-spinner.test.ts +0 -232
  78. package/tests/output/progress-timer.test.ts +0 -230
  79. package/tests/provider-validator.test.ts +0 -182
  80. package/tests/registry/cache.test.ts +0 -66
  81. package/tests/registry/client.test.ts +0 -70
  82. package/tests/registry/lockfile.test.ts +0 -87
  83. package/tests/registry/resolver.test.ts +0 -122
  84. package/tests/run-logger-events.test.ts +0 -326
  85. package/tests/run-logger.test.ts +0 -64
  86. package/tests/run-store-factory.test.ts +0 -51
  87. package/tests/studio-dir.test.ts +0 -31
  88. package/tests/utils/input-wizard.test.ts +0 -153
  89. package/tests/utils/placeholders.test.ts +0 -36
  90. package/tsconfig.json +0 -24
  91. package/vitest.config.ts +0 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studio-foundation/cli",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.5",
4
4
  "description": "Command-line interface for Studio v7",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,6 +8,9 @@
8
8
  "bin": {
9
9
  "studio": "./dist/index.js"
10
10
  },
11
+ "files": [
12
+ "dist"
13
+ ],
11
14
  "keywords": [
12
15
  "studio",
13
16
  "cli"
@@ -21,10 +24,10 @@
21
24
  "dotenv": "^17.3.1",
22
25
  "js-yaml": "^4.1.1",
23
26
  "ora": "^9.3.0",
24
- "@studio-foundation/api": "0.3.0-beta.1",
25
- "@studio-foundation/contracts": "0.3.0-beta.1",
26
- "@studio-foundation/engine": "0.3.0-beta.1",
27
- "@studio-foundation/runner": "0.3.0-beta.1"
27
+ "@studio-foundation/api": "0.3.0-beta.5",
28
+ "@studio-foundation/contracts": "0.3.0-beta.5",
29
+ "@studio-foundation/engine": "0.3.0-beta.5",
30
+ "@studio-foundation/runner": "0.3.0-beta.5"
28
31
  },
29
32
  "devDependencies": {
30
33
  "@types/js-yaml": "^4.0.9",
package/ARCHITECTURE.md DELETED
@@ -1,40 +0,0 @@
1
- # @studio-foundation/cli
2
-
3
- Interface terminal pour Studio. Composition root — câble toutes les dépendances et délègue.
4
-
5
- ## Règles
6
-
7
- - ZERO logique métier — tout est dans engine
8
- - Le CLI est le **composition root** : il instancie `ProviderRegistry`, `ToolRegistry`, `MCPClient`, `PipelineEngine`. Exception documentée au DAG dans INVARIANTS.md.
9
- - Pretty output pour humains (`--live`), JSON pour machines (`--json`)
10
- - `findStudioDir()` remonte l'arbre de dossiers — les tests doivent utiliser `/tmp`, jamais un sous-dossier du repo Studio (qui a lui-même un `.studio/`)
11
- - Dépend de : `@studio-foundation/engine`, `@studio-foundation/runner`, `@studio-foundation/contracts` (+ `@studio-foundation/api` pour `studio api start`)
12
-
13
- ## Fichiers clés
14
-
15
- - `index.ts` — entry point Commander, registre de toutes les commandes
16
- - `commands/run.ts` — `studio run` (commande principale)
17
- - `commands/status.ts`, `logs.ts`, `replay.ts` — inspection des runs
18
- - `commands/list.ts` — `studio list projects|pipelines`
19
- - `commands/init.ts` — `studio init` (wizard interactif + mode direct)
20
- - `commands/config.ts` — `studio config set|list|add-provider`
21
- - `commands/tools.ts` — `studio tools list|add|remove|info`
22
- - `commands/registry/` — `studio registry install|remove|search|publish|audit|sync|update`
23
- - `commands/templates.ts` — `studio templates`
24
- - `commands/template/` — `studio template validate`
25
- - `commands/integrations.ts` — `studio integrations`
26
- - `commands/project.ts` — `studio project`
27
- - `commands/api.ts` — `studio api start`
28
- - `commands/validate.ts` — `studio validate <contract> <output.json>`
29
- - `output/` — formatter, logger, progress/spinner, file-changes renderer
30
- - `utils/input-wizard.ts` — prompts interactifs pour `studio run` sans `--input`
31
- - `registry/` — client HTTP registry, lockfile, resolver, cache
32
-
33
- ## Usage quotidien
34
-
35
- ```bash
36
- studio run <pipeline> --input "..." --live
37
- studio status
38
- studio logs
39
- studio registry install git
40
- ```
@@ -1,126 +0,0 @@
1
- import { writeFile, readFile, unlink, mkdir } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { loadConfig } from '../config.js';
5
-
6
- interface ApiOptions {
7
- port?: string;
8
- config?: string;
9
- }
10
-
11
- function pidFilePath(): string {
12
- return join(homedir(), '.studio', 'api.pid');
13
- }
14
-
15
- export async function writePid(port: number): Promise<void> {
16
- await mkdir(join(homedir(), '.studio'), { recursive: true });
17
- await writeFile(pidFilePath(), `${process.pid}:${port}`, 'utf-8');
18
- }
19
-
20
- export async function readPid(): Promise<{ pid: number; port: number } | null> {
21
- try {
22
- const content = await readFile(pidFilePath(), 'utf-8');
23
- const [pidStr, portStr] = content.trim().split(':');
24
- const pid = parseInt(pidStr ?? '', 10);
25
- const port = parseInt(portStr ?? '', 10);
26
- if (isNaN(pid) || isNaN(port)) return null;
27
- return { pid, port };
28
- } catch {
29
- return null;
30
- }
31
- }
32
-
33
- export async function clearPid(): Promise<void> {
34
- try { await unlink(pidFilePath()); } catch {}
35
- }
36
-
37
- export function isProcessAlive(pid: number): boolean {
38
- try {
39
- process.kill(pid, 0);
40
- return true;
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
- export async function apiStartCommand(options: ApiOptions): Promise<void> {
47
- let apiModule: typeof import('@studio-foundation/api');
48
- try {
49
- apiModule = await import('@studio-foundation/api');
50
- } catch {
51
- console.error('API not installed. Run: studio install api');
52
- process.exit(1);
53
- }
54
-
55
- const { bootstrap, buildServer } = apiModule;
56
- const config = await loadConfig(options.config);
57
- const cwd = config.resolvedStudioDir
58
- ? config.resolvedStudioDir.replace(/\/.studio$/, '')
59
- : process.cwd();
60
-
61
- let result: Awaited<ReturnType<typeof bootstrap>>;
62
- try {
63
- result = await bootstrap(cwd);
64
- } catch (err) {
65
- console.error('Error:', err instanceof Error ? err.message : String(err));
66
- process.exit(1);
67
- }
68
-
69
- const { store, launcher, configsDir, projectName, apiConfig, cleanup, studioVersion, maskedConfig, webhookStore, integrationStore, integrationRuntime } = result;
70
- const port = options.port ? parseInt(options.port, 10) : (apiConfig.port ?? 3700);
71
- const server = buildServer({ store, launcher, configsDir, projectName, apiConfig, studioVersion, maskedConfig, webhookStore, integrationStore, integrationRuntime });
72
-
73
- await writePid(port);
74
-
75
- const shutdown = async () => {
76
- await server.close();
77
- await cleanup();
78
- await clearPid();
79
- process.exit(0);
80
- };
81
- process.on('SIGTERM', () => void shutdown());
82
- process.on('SIGINT', () => void shutdown());
83
-
84
- await server.listen({ port, host: '0.0.0.0' });
85
- console.log(`Studio API running on http://localhost:${port}`);
86
- }
87
-
88
- export async function apiStopCommand(): Promise<void> {
89
- const entry = await readPid();
90
- if (!entry) {
91
- console.log('Studio API is not running.');
92
- return;
93
- }
94
- if (!isProcessAlive(entry.pid)) {
95
- await clearPid();
96
- console.log('Studio API is not running (stale PID file removed).');
97
- return;
98
- }
99
- process.kill(entry.pid, 'SIGTERM');
100
- await clearPid();
101
- console.log('Studio API stopped.');
102
- }
103
-
104
- export async function apiStatusCommand(options: ApiOptions): Promise<void> {
105
- const entry = await readPid();
106
- if (!entry) {
107
- console.log('Studio API: not running');
108
- return;
109
- }
110
- if (!isProcessAlive(entry.pid)) {
111
- await clearPid();
112
- console.log('Studio API: not running (stale PID file removed)');
113
- return;
114
- }
115
- const port = options.port ? parseInt(options.port, 10) : entry.port;
116
- try {
117
- const response = await fetch(`http://localhost:${port}/api/health`);
118
- if (response.ok) {
119
- console.log(`Studio API: running on port ${port} (PID ${entry.pid})`);
120
- } else {
121
- console.log(`Studio API: process alive (PID ${entry.pid}) but not responding on port ${port}`);
122
- }
123
- } catch {
124
- console.log(`Studio API: process alive (PID ${entry.pid}) but port ${port} not responding`);
125
- }
126
- }
@@ -1,433 +0,0 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
- import { resolve, join, dirname } from 'node:path';
3
- import * as yaml from 'js-yaml';
4
- import chalk from 'chalk';
5
- import ora from 'ora';
6
- import { select, password, confirm, input } from '@inquirer/prompts';
7
- import { findStudioDir } from '../studio-dir.js';
8
- import { resolveEnvVars } from '../config.js';
9
- import { validateApiKeyLive } from '../provider-validator.js';
10
- import { getAvailableModels } from '../models-cache.js';
11
-
12
- export const PROVIDERS = [
13
- { id: 'anthropic', label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-4-20250514' },
14
- { id: 'openai', label: 'OpenAI (GPT)', defaultModel: 'gpt-4o' },
15
- { id: 'google', label: 'Google (Gemini)', defaultModel: 'gemini-1.5-pro' },
16
- { id: 'ollama', label: 'Ollama (local)', defaultModel: 'llama3.2' },
17
- ] as const;
18
-
19
- export type ProviderId = (typeof PROVIDERS)[number]['id'];
20
-
21
- export function validateApiKeyForProvider(provider: string, key: string): true | string {
22
- if (provider === 'anthropic') {
23
- if (!key.startsWith('sk-ant-')) return 'Anthropic API keys must start with sk-ant-';
24
- } else if (provider === 'openai') {
25
- if (!key.startsWith('sk-') || key.startsWith('sk-ant-'))
26
- return 'OpenAI API keys must start with sk- (and not be an Anthropic key)';
27
- } else if (provider === 'google') {
28
- if (!key.startsWith('AIza')) return 'Google API keys must start with AIza';
29
- }
30
- // ollama / unknown providers: no format constraint
31
- return true;
32
- }
33
-
34
- export async function addProviderConfig(
35
- configFile: string,
36
- provider: string,
37
- apiKey: string,
38
- setDefault: boolean,
39
- model?: string
40
- ): Promise<void> {
41
- const config = await loadRawConfig(configFile);
42
-
43
- if (!config.providers || typeof config.providers !== 'object') {
44
- config.providers = {};
45
- }
46
- if (provider === 'ollama') {
47
- (config.providers as Record<string, unknown>)[provider] = { baseUrl: apiKey };
48
- } else {
49
- (config.providers as Record<string, unknown>)[provider] = { apiKey };
50
- }
51
-
52
- if (setDefault) {
53
- const meta = PROVIDERS.find((p) => p.id === provider);
54
- config.defaults = {
55
- provider,
56
- model: model ?? meta?.defaultModel ?? 'claude-sonnet-4-20250514',
57
- };
58
- }
59
-
60
- await saveConfig(configFile, config);
61
- }
62
-
63
- export async function isProviderConfigured(configFile: string, provider: string): Promise<boolean> {
64
- const config = await loadRawConfig(configFile);
65
- if (!config.providers || typeof config.providers !== 'object') return false;
66
- return provider in (config.providers as Record<string, unknown>);
67
- }
68
-
69
- export function getConfigValue(config: Record<string, unknown>, path: string): unknown {
70
- const parts = path.split('.');
71
- let current: unknown = config;
72
- for (const part of parts) {
73
- if (current === null || typeof current !== 'object') return undefined;
74
- current = (current as Record<string, unknown>)[part];
75
- }
76
- return current;
77
- }
78
-
79
- export function setConfigValue(config: Record<string, unknown>, path: string, value: string): void {
80
- const parts = path.split('.');
81
- let current = config;
82
- for (let i = 0; i < parts.length - 1; i++) {
83
- const part = parts[i]!;
84
- if (typeof current[part] !== 'object' || current[part] === null) {
85
- current[part] = {};
86
- }
87
- current = current[part] as Record<string, unknown>;
88
- }
89
- current[parts[parts.length - 1]!] = value;
90
- }
91
-
92
- export function maskSecrets(obj: unknown): unknown {
93
- if (Array.isArray(obj)) return obj.map(maskSecrets);
94
- if (obj !== null && typeof obj === 'object') {
95
- return Object.fromEntries(
96
- Object.entries(obj as Record<string, unknown>).map(([k, v]) => {
97
- if (k.toLowerCase() === 'apikey' && typeof v === 'string') {
98
- const prefix = v.slice(0, 3);
99
- return [k, `${prefix}***...`];
100
- }
101
- return [k, maskSecrets(v)];
102
- })
103
- );
104
- }
105
- return obj;
106
- }
107
-
108
- async function resolveConfigFilePath(): Promise<string> {
109
- const studioDir = await findStudioDir(process.cwd());
110
- if (studioDir) return join(studioDir, 'config.yaml');
111
- // Create .studio/ at cwd if nothing found
112
- const newStudioDir = resolve(process.cwd(), '.studio');
113
- await mkdir(newStudioDir, { recursive: true });
114
- return join(newStudioDir, 'config.yaml');
115
- }
116
-
117
- async function loadRawConfig(configFile: string): Promise<Record<string, unknown>> {
118
- try {
119
- const raw = await readFile(configFile, 'utf-8');
120
- const parsed = yaml.load(resolveEnvVars(raw));
121
- return (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, unknown>;
122
- } catch {
123
- return {};
124
- }
125
- }
126
-
127
- async function saveConfig(configFile: string, config: Record<string, unknown>): Promise<void> {
128
- await mkdir(dirname(configFile), { recursive: true });
129
- await writeFile(configFile, yaml.dump(config), 'utf-8');
130
- }
131
-
132
- interface ConfigOptions {
133
- apiKey?: string;
134
- project?: string;
135
- setDefault?: boolean;
136
- }
137
-
138
- async function configAddProviderWizard(configFile: string): Promise<void> {
139
- const config = await loadRawConfig(configFile);
140
- const existingProviders = config.providers
141
- ? Object.keys(config.providers as Record<string, unknown>)
142
- : [];
143
-
144
- // Step 1: Select provider
145
- const providerId = await select<string>({
146
- message: 'Which provider would you like to add?',
147
- choices: PROVIDERS.map((p) => ({ value: p.id, name: p.label })),
148
- });
149
-
150
- // Step 2: Handle already-configured case
151
- if (existingProviders.includes(providerId)) {
152
- const providerLabel = PROVIDERS.find((p) => p.id === providerId)?.label ?? providerId;
153
- const override = await confirm({
154
- message: `${providerLabel} is already configured. Override?`,
155
- default: false,
156
- });
157
- if (!override) {
158
- console.log('Aborted.');
159
- return;
160
- }
161
- }
162
-
163
- // Step 3: Ask for API key (or base URL for ollama)
164
- let apiKey = '';
165
- if (providerId === 'ollama') {
166
- apiKey = await input({
167
- message: 'Ollama base URL:',
168
- default: 'http://localhost:11434',
169
- });
170
- const spinner = ora('Validating connection...').start();
171
- const result = await validateApiKeyLive('ollama', '', { baseUrl: apiKey });
172
- if (result.status === 'valid') spinner.succeed('Connected');
173
- else if (result.status === 'warning') spinner.warn(result.message);
174
- else spinner.fail(result.error);
175
- } else {
176
- const providerLabel = PROVIDERS.find((p) => p.id === providerId)?.label ?? providerId;
177
- while (true) {
178
- apiKey = await password({
179
- message: `${providerLabel} API Key:`,
180
- validate: (value: string) => validateApiKeyForProvider(providerId, value),
181
- });
182
- const spinner = ora('Validating...').start();
183
- const result = await validateApiKeyLive(providerId, apiKey);
184
- spinner.stop();
185
- if (result.status === 'valid') {
186
- console.log(chalk.green(' ✓ Valid'));
187
- break;
188
- } else if (result.status === 'warning') {
189
- console.log(chalk.yellow(` ⚠ ${result.message}`));
190
- break;
191
- } else {
192
- console.log(chalk.red(` ✗ ${result.error}`));
193
- console.log(chalk.gray(' Please try again.'));
194
- }
195
- }
196
- }
197
-
198
- // Step 4: Set as default?
199
- const isFirstProvider = existingProviders.filter((p) => p !== providerId).length === 0;
200
- const setDefault =
201
- isFirstProvider ||
202
- (await confirm({
203
- message: 'Set as default provider?',
204
- default: true,
205
- }));
206
-
207
- // Step 4b: Choose default model (if setting as default and models are available)
208
- let defaultModel: string | undefined;
209
- if (setDefault && providerId !== 'ollama') {
210
- const models = await getAvailableModels(providerId, apiKey);
211
- const meta = PROVIDERS.find((p) => p.id === providerId);
212
- const fallbackModel = meta?.defaultModel ?? 'claude-sonnet-4-20250514';
213
-
214
- if (models.length > 0) {
215
- const choices = [
216
- ...models.map((m) => ({ value: m, name: m })),
217
- { value: '__custom__', name: 'Enter custom model ID' },
218
- ];
219
- const selected = await select<string>({
220
- message: 'Default model:',
221
- choices,
222
- default: models.includes(fallbackModel) ? fallbackModel : models[0],
223
- });
224
- if (selected === '__custom__') {
225
- defaultModel = await input({ message: 'Model ID:', default: fallbackModel });
226
- } else {
227
- defaultModel = selected;
228
- }
229
- } else {
230
- defaultModel = await input({ message: 'Default model:', default: fallbackModel });
231
- }
232
- }
233
-
234
- // Step 5: Write config
235
- await addProviderConfig(configFile, providerId, apiKey, setDefault, defaultModel);
236
-
237
- // Step 6: Confirmation output
238
- const label = PROVIDERS.find((p) => p.id === providerId)?.label ?? providerId;
239
- console.log('');
240
- console.log(chalk.green(`✓ ${label} provider configured`));
241
- if (setDefault) console.log(chalk.green('✓ Set as default'));
242
- console.log('');
243
- console.log('You can now run:');
244
- console.log(` ${chalk.cyan('studio run <pipeline> --input "..."')}`);
245
- console.log('');
246
- }
247
-
248
- export async function configCommand(
249
- action: string,
250
- args: string[],
251
- options: ConfigOptions
252
- ): Promise<void> {
253
- try {
254
- const configFile = await resolveConfigFilePath();
255
-
256
- switch (action) {
257
- case 'list': {
258
- const config = await loadRawConfig(configFile);
259
- const masked = maskSecrets(config);
260
- console.log('');
261
- console.log(chalk.bold('Studio Configuration:'));
262
- console.log(chalk.gray(` File: ${configFile}`));
263
- console.log('');
264
- console.log(yaml.dump(masked));
265
- break;
266
- }
267
-
268
- case 'get': {
269
- const path = args[0];
270
- if (!path) {
271
- console.error('Usage: studio config get <dotted.path>');
272
- process.exit(1);
273
- }
274
- const config = await loadRawConfig(configFile);
275
- const value = getConfigValue(config, path);
276
- if (value === undefined) {
277
- console.log(chalk.yellow(`(not set)`));
278
- } else {
279
- console.log(String(value));
280
- }
281
- break;
282
- }
283
-
284
- case 'set': {
285
- const config = await loadRawConfig(configFile);
286
-
287
- // Convenience: studio config set provider <name> --api-key <key>
288
- if (args[0] === 'provider' && args[1] && options.apiKey) {
289
- const providerName = args[1];
290
- setConfigValue(config, `providers.${providerName}.apiKey`, options.apiKey);
291
- await saveConfig(configFile, config);
292
- console.log(chalk.green(`✓ Set providers.${providerName}.apiKey`));
293
- break;
294
- }
295
-
296
- // Special case: interactive model selection when no value provided
297
- if (args[0] === 'defaults.model' && args[1] === undefined) {
298
- const rawConfig = await loadRawConfig(configFile);
299
- const defaults = rawConfig.defaults as { provider?: string; model?: string } | undefined;
300
- const provider = defaults?.provider;
301
- const providerConfig =
302
- provider && rawConfig.providers
303
- ? (rawConfig.providers as Record<string, { apiKey: string }>)[provider]
304
- : undefined;
305
- const apiKey = providerConfig?.apiKey ?? '';
306
-
307
- if (!provider) {
308
- console.error('Error: no default provider configured. Run studio config add-provider first.');
309
- process.exit(1);
310
- }
311
-
312
- const spinner = ora(`Fetching models for ${provider}...`).start();
313
- const models = await getAvailableModels(provider, apiKey);
314
- spinner.stop();
315
-
316
- if (models.length === 0) {
317
- console.error(
318
- `Error: could not fetch models for provider '${provider}'. ` +
319
- `Provide the model ID directly: studio config set defaults.model <model-id>`
320
- );
321
- process.exit(1);
322
- }
323
-
324
- const selectedModel = await select<string>({
325
- message: 'Default model:',
326
- choices: models.map((m) => ({ value: m, name: m })),
327
- default: defaults?.model && models.includes(defaults.model) ? defaults.model : models[0],
328
- });
329
-
330
- setConfigValue(rawConfig, 'defaults.model', selectedModel);
331
- await saveConfig(configFile, rawConfig);
332
- console.log(chalk.green(`✓ Set defaults.model = ${selectedModel}`));
333
- break;
334
- }
335
-
336
- // Generic: studio config set <dotted.path> <value>
337
- const path = args[0];
338
- const value = args[1];
339
- if (!path || value === undefined) {
340
- console.error('Usage: studio config set <dotted.path> <value>');
341
- console.error(' studio config set provider <name> --api-key <key>');
342
- process.exit(1);
343
- }
344
- setConfigValue(config, path, value);
345
- await saveConfig(configFile, config);
346
- console.log(chalk.green(`✓ Set ${path} = ${value}`));
347
- break;
348
- }
349
-
350
- case 'add-provider': {
351
- const provider = args[0];
352
-
353
- if (!provider) {
354
- // Wizard mode
355
- await configAddProviderWizard(configFile);
356
- break;
357
- }
358
-
359
- // Direct mode
360
- if (provider !== 'ollama' && !options.apiKey) {
361
- console.error(`Error: --api-key is required for provider '${provider}'`);
362
- process.exit(1);
363
- }
364
-
365
- const apiKey = options.apiKey ?? '';
366
-
367
- if (provider !== 'ollama') {
368
- const validation = validateApiKeyForProvider(provider, apiKey);
369
- if (validation !== true) {
370
- console.error(`Error: ${validation}`);
371
- process.exit(1);
372
- }
373
- process.stdout.write('Validating...');
374
- const result = await validateApiKeyLive(provider, apiKey);
375
- if (result.status === 'valid') {
376
- console.log(chalk.green(' ✓ Valid'));
377
- } else if (result.status === 'warning') {
378
- console.log(chalk.yellow(` ⚠ ${result.message}`));
379
- } else {
380
- console.error(chalk.red(` ✗ ${result.error}`));
381
- process.exit(1);
382
- }
383
- } else {
384
- process.stdout.write('Validating connection...');
385
- const result = await validateApiKeyLive('ollama', '', {
386
- baseUrl: apiKey || 'http://localhost:11434',
387
- });
388
- const msg = 'message' in result ? result.message : '';
389
- if (result.status === 'valid') {
390
- console.log(chalk.green(' ✓ Connected'));
391
- } else {
392
- console.log(chalk.yellow(` ⚠ ${msg}`));
393
- }
394
- }
395
-
396
- // Check already configured
397
- const alreadyConfigured = await isProviderConfigured(configFile, provider);
398
- if (alreadyConfigured) {
399
- console.error(
400
- `Error: Provider '${provider}' is already configured. Use 'studio config set' to update it, or run the wizard to override.`
401
- );
402
- process.exit(1);
403
- }
404
-
405
- // Determine setDefault
406
- const config = await loadRawConfig(configFile);
407
- const existingCount = config.providers
408
- ? Object.keys(config.providers as Record<string, unknown>).length
409
- : 0;
410
- const setDefault = options.setDefault ?? existingCount === 0;
411
-
412
- await addProviderConfig(configFile, provider, apiKey, setDefault);
413
-
414
- const label = PROVIDERS.find((p) => p.id === provider)?.label ?? provider;
415
- console.log(chalk.green(`✓ ${label} provider configured`));
416
- if (setDefault) console.log(chalk.green('✓ Set as default'));
417
- console.log('');
418
- break;
419
- }
420
-
421
- default:
422
- console.error(`Unknown config action: ${action}. Available: list, get, set, add-provider`);
423
- process.exit(1);
424
- }
425
- } catch (error) {
426
- if (error instanceof Error && error.name === 'ExitPromptError') {
427
- console.log('\nAborted.');
428
- process.exit(0);
429
- }
430
- console.error('Error:', error instanceof Error ? error.message : error);
431
- process.exit(1);
432
- }
433
- }