@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.
- package/ARCHITECTURE.md +40 -0
- package/LICENSE +663 -0
- package/README.md +165 -0
- package/dist/commands/api.d.ts +16 -0
- package/dist/commands/api.d.ts.map +1 -0
- package/dist/commands/api.js +118 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/config.d.ts +32 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +390 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/init.d.ts +93 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +728 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +22 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.d.ts.map +1 -0
- package/dist/commands/integrations.js +282 -0
- package/dist/commands/integrations.js.map +1 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +161 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +7 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +98 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/ollama.d.ts +6 -0
- package/dist/commands/ollama.d.ts.map +1 -0
- package/dist/commands/ollama.js +161 -0
- package/dist/commands/ollama.js.map +1 -0
- package/dist/commands/project.d.ts +38 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +137 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/registry/audit.d.ts +13 -0
- package/dist/commands/registry/audit.d.ts.map +1 -0
- package/dist/commands/registry/audit.js +65 -0
- package/dist/commands/registry/audit.js.map +1 -0
- package/dist/commands/registry/index.d.ts +3 -0
- package/dist/commands/registry/index.d.ts.map +1 -0
- package/dist/commands/registry/index.js +53 -0
- package/dist/commands/registry/index.js.map +1 -0
- package/dist/commands/registry/install.d.ts +17 -0
- package/dist/commands/registry/install.d.ts.map +1 -0
- package/dist/commands/registry/install.js +153 -0
- package/dist/commands/registry/install.js.map +1 -0
- package/dist/commands/registry/publish.d.ts +8 -0
- package/dist/commands/registry/publish.d.ts.map +1 -0
- package/dist/commands/registry/publish.js +94 -0
- package/dist/commands/registry/publish.js.map +1 -0
- package/dist/commands/registry/remove.d.ts +8 -0
- package/dist/commands/registry/remove.d.ts.map +1 -0
- package/dist/commands/registry/remove.js +82 -0
- package/dist/commands/registry/remove.js.map +1 -0
- package/dist/commands/registry/search.d.ts +9 -0
- package/dist/commands/registry/search.d.ts.map +1 -0
- package/dist/commands/registry/search.js +68 -0
- package/dist/commands/registry/search.js.map +1 -0
- package/dist/commands/registry/sync.d.ts +8 -0
- package/dist/commands/registry/sync.d.ts.map +1 -0
- package/dist/commands/registry/sync.js +19 -0
- package/dist/commands/registry/sync.js.map +1 -0
- package/dist/commands/registry/update.d.ts +15 -0
- package/dist/commands/registry/update.d.ts.map +1 -0
- package/dist/commands/registry/update.js +48 -0
- package/dist/commands/registry/update.js.map +1 -0
- package/dist/commands/replay.d.ts +36 -0
- package/dist/commands/replay.d.ts.map +1 -0
- package/dist/commands/replay.js +453 -0
- package/dist/commands/replay.js.map +1 -0
- package/dist/commands/run.d.ts +18 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +406 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +144 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/template/index.d.ts +2 -0
- package/dist/commands/template/index.d.ts.map +1 -0
- package/dist/commands/template/index.js +57 -0
- package/dist/commands/template/index.js.map +1 -0
- package/dist/commands/template/validate.d.ts +8 -0
- package/dist/commands/template/validate.d.ts.map +1 -0
- package/dist/commands/template/validate.js +135 -0
- package/dist/commands/template/validate.js.map +1 -0
- package/dist/commands/templates.d.ts +14 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +86 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +200 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/users.d.ts +4 -0
- package/dist/commands/users.d.ts.map +1 -0
- package/dist/commands/users.js +123 -0
- package/dist/commands/users.js.map +1 -0
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +39 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -0
- package/dist/models-cache.d.ts +13 -0
- package/dist/models-cache.d.ts.map +1 -0
- package/dist/models-cache.js +106 -0
- package/dist/models-cache.js.map +1 -0
- package/dist/output/file-changes.d.ts +15 -0
- package/dist/output/file-changes.d.ts.map +1 -0
- package/dist/output/file-changes.js +77 -0
- package/dist/output/file-changes.js.map +1 -0
- package/dist/output/formatter.d.ts +6 -0
- package/dist/output/formatter.d.ts.map +1 -0
- package/dist/output/formatter.js +70 -0
- package/dist/output/formatter.js.map +1 -0
- package/dist/output/formatters.d.ts +42 -0
- package/dist/output/formatters.d.ts.map +1 -0
- package/dist/output/formatters.js +280 -0
- package/dist/output/formatters.js.map +1 -0
- package/dist/output/logger.d.ts +3 -0
- package/dist/output/logger.d.ts.map +1 -0
- package/dist/output/logger.js +15 -0
- package/dist/output/logger.js.map +1 -0
- package/dist/output/parallel-progress.d.ts +33 -0
- package/dist/output/parallel-progress.d.ts.map +1 -0
- package/dist/output/parallel-progress.js +85 -0
- package/dist/output/parallel-progress.js.map +1 -0
- package/dist/output/progress.d.ts +32 -0
- package/dist/output/progress.d.ts.map +1 -0
- package/dist/output/progress.js +393 -0
- package/dist/output/progress.js.map +1 -0
- package/dist/provider-validator.d.ts +15 -0
- package/dist/provider-validator.d.ts.map +1 -0
- package/dist/provider-validator.js +85 -0
- package/dist/provider-validator.js.map +1 -0
- package/dist/registry/cache.d.ts +10 -0
- package/dist/registry/cache.d.ts.map +1 -0
- package/dist/registry/cache.js +36 -0
- package/dist/registry/cache.js.map +1 -0
- package/dist/registry/client.d.ts +19 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +63 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/lockfile.d.ts +17 -0
- package/dist/registry/lockfile.d.ts.map +1 -0
- package/dist/registry/lockfile.js +61 -0
- package/dist/registry/lockfile.js.map +1 -0
- package/dist/registry/resolver.d.ts +13 -0
- package/dist/registry/resolver.d.ts.map +1 -0
- package/dist/registry/resolver.js +79 -0
- package/dist/registry/resolver.js.map +1 -0
- package/dist/registry/types.d.ts +59 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +14 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/run-logger.d.ts +8 -0
- package/dist/run-logger.d.ts.map +1 -0
- package/dist/run-logger.js +53 -0
- package/dist/run-logger.js.map +1 -0
- package/dist/run-store-factory.d.ts +9 -0
- package/dist/run-store-factory.d.ts.map +1 -0
- package/dist/run-store-factory.js +26 -0
- package/dist/run-store-factory.js.map +1 -0
- package/dist/studio-dir.d.ts +7 -0
- package/dist/studio-dir.d.ts.map +1 -0
- package/dist/studio-dir.js +27 -0
- package/dist/studio-dir.js.map +1 -0
- package/dist/utils/input-wizard.d.ts +4 -0
- package/dist/utils/input-wizard.d.ts.map +1 -0
- package/dist/utils/input-wizard.js +56 -0
- package/dist/utils/input-wizard.js.map +1 -0
- package/dist/utils/placeholders.d.ts +6 -0
- package/dist/utils/placeholders.d.ts.map +1 -0
- package/dist/utils/placeholders.js +12 -0
- package/dist/utils/placeholders.js.map +1 -0
- package/package.json +43 -0
- package/src/commands/api.ts +126 -0
- package/src/commands/config.ts +433 -0
- package/src/commands/init.ts +879 -0
- package/src/commands/install.ts +23 -0
- package/src/commands/integrations.ts +332 -0
- package/src/commands/list.ts +197 -0
- package/src/commands/logs.ts +119 -0
- package/src/commands/ollama.ts +168 -0
- package/src/commands/project.ts +167 -0
- package/src/commands/registry/audit.ts +87 -0
- package/src/commands/registry/index.ts +63 -0
- package/src/commands/registry/install.ts +222 -0
- package/src/commands/registry/publish.ts +112 -0
- package/src/commands/registry/remove.ts +89 -0
- package/src/commands/registry/search.ts +93 -0
- package/src/commands/registry/sync.ts +24 -0
- package/src/commands/registry/update.ts +63 -0
- package/src/commands/replay.ts +559 -0
- package/src/commands/run.ts +454 -0
- package/src/commands/status.ts +163 -0
- package/src/commands/template/index.ts +59 -0
- package/src/commands/template/validate.ts +175 -0
- package/src/commands/templates.ts +99 -0
- package/src/commands/tools.ts +227 -0
- package/src/commands/users.ts +127 -0
- package/src/commands/validate.ts +46 -0
- package/src/config.ts +101 -0
- package/src/index.ts +201 -0
- package/src/models-cache.ts +127 -0
- package/src/output/file-changes.ts +97 -0
- package/src/output/formatter.ts +85 -0
- package/src/output/formatters.ts +303 -0
- package/src/output/logger.ts +18 -0
- package/src/output/parallel-progress.ts +103 -0
- package/src/output/progress.ts +414 -0
- package/src/provider-validator.ts +109 -0
- package/src/registry/cache.ts +46 -0
- package/src/registry/client.ts +84 -0
- package/src/registry/lockfile.ts +68 -0
- package/src/registry/resolver.ts +113 -0
- package/src/registry/types.ts +66 -0
- package/src/run-logger.ts +66 -0
- package/src/run-store-factory.ts +30 -0
- package/src/studio-dir.ts +28 -0
- package/src/utils/input-wizard.ts +73 -0
- package/src/utils/placeholders.ts +10 -0
- package/templates/.studiorc.yaml +16 -0
- package/templates/projects/blank/metadata.json +7 -0
- package/templates/studio-config.yaml +14 -0
- package/tests/__stubs__/studio-runner.ts +104 -0
- package/tests/commands/api.test.ts +110 -0
- package/tests/commands/config.test.ts +221 -0
- package/tests/commands/init.test.ts +919 -0
- package/tests/commands/install.test.ts +52 -0
- package/tests/commands/integrations.test.ts +158 -0
- package/tests/commands/ollama.test.ts +139 -0
- package/tests/commands/project.test.ts +179 -0
- package/tests/commands/registry/audit.test.ts +56 -0
- package/tests/commands/registry/install.test.ts +200 -0
- package/tests/commands/registry/publish.test.ts +56 -0
- package/tests/commands/registry/remove.test.ts +103 -0
- package/tests/commands/registry/search.test.ts +44 -0
- package/tests/commands/registry/sync.test.ts +37 -0
- package/tests/commands/registry/update.test.ts +62 -0
- package/tests/commands/replay.test.ts +283 -0
- package/tests/commands/template/validate.test.ts +150 -0
- package/tests/commands/templates.test.ts +42 -0
- package/tests/commands/tools.test.ts +106 -0
- package/tests/config.test.ts +142 -0
- package/tests/formatter.test.ts +158 -0
- package/tests/integration/sigint.test.ts +188 -0
- package/tests/models-cache.test.ts +250 -0
- package/tests/output/file-changes.test.ts +178 -0
- package/tests/output/formatters.test.ts +448 -0
- package/tests/output/progress-spinner.test.ts +232 -0
- package/tests/output/progress-timer.test.ts +230 -0
- package/tests/provider-validator.test.ts +182 -0
- package/tests/registry/cache.test.ts +66 -0
- package/tests/registry/client.test.ts +70 -0
- package/tests/registry/lockfile.test.ts +87 -0
- package/tests/registry/resolver.test.ts +122 -0
- package/tests/run-logger-events.test.ts +326 -0
- package/tests/run-logger.test.ts +64 -0
- package/tests/run-store-factory.test.ts +51 -0
- package/tests/studio-dir.test.ts +31 -0
- package/tests/utils/input-wizard.test.ts +153 -0
- package/tests/utils/placeholders.test.ts +36 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@studio-foundation/cli",
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
|
+
"description": "Command-line interface for Studio v7",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"studio": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"studio",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"author": "Ariane Guay",
|
|
16
|
+
"license": "AGPL-3.0-only",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@inquirer/prompts": "^8.2.1",
|
|
19
|
+
"chalk": "^5.6.2",
|
|
20
|
+
"commander": "^14.0.3",
|
|
21
|
+
"dotenv": "^17.3.1",
|
|
22
|
+
"js-yaml": "^4.1.1",
|
|
23
|
+
"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"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/js-yaml": "^4.0.9",
|
|
31
|
+
"@types/node": "^25.2.3",
|
|
32
|
+
"typescript": "^5.3.0",
|
|
33
|
+
"vitest": "^4.0.18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"dev": "tsc --watch",
|
|
38
|
+
"clean": "rm -rf dist",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
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
|
+
}
|