kly 0.1.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/ai/context.mjs +79 -0
- package/dist/ai/context.mjs.map +1 -0
- package/dist/ai/storage.mjs +50 -0
- package/dist/ai/storage.mjs.map +1 -0
- package/dist/bin/kly.d.mts +1 -0
- package/dist/bin/kly.mjs +2888 -0
- package/dist/bin/kly.mjs.map +1 -0
- package/dist/bin/launcher-vTpgdO9n.mjs +3 -0
- package/dist/bin/permissions-2r_7ZqaH.mjs +3 -0
- package/dist/cli.mjs +229 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/define-app.d.mts +33 -0
- package/dist/define-app.d.mts.map +1 -0
- package/dist/define-app.mjs +183 -0
- package/dist/define-app.mjs.map +1 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +15 -0
- package/dist/mcp/index.mjs +4 -0
- package/dist/mcp/schema-converter.d.mts +13 -0
- package/dist/mcp/schema-converter.d.mts.map +1 -0
- package/dist/mcp/schema-converter.mjs +30 -0
- package/dist/mcp/schema-converter.mjs.map +1 -0
- package/dist/mcp/server.d.mts +33 -0
- package/dist/mcp/server.d.mts.map +1 -0
- package/dist/mcp/server.mjs +92 -0
- package/dist/mcp/server.mjs.map +1 -0
- package/dist/permissions/index.mjs +123 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/sandbox/bundled-executor.d.mts +17 -0
- package/dist/sandbox/bundled-executor.d.mts.map +1 -0
- package/dist/sandbox/bundled-executor.mjs +175 -0
- package/dist/sandbox/bundled-executor.mjs.map +1 -0
- package/dist/sandbox/ipc-client.mjs +40 -0
- package/dist/sandbox/ipc-client.mjs.map +1 -0
- package/dist/sandbox/sandboxed-context.mjs +14 -0
- package/dist/sandbox/sandboxed-context.mjs.map +1 -0
- package/dist/shared/constants.mjs +36 -0
- package/dist/shared/constants.mjs.map +1 -0
- package/dist/shared/runtime-mode.mjs +59 -0
- package/dist/shared/runtime-mode.mjs.map +1 -0
- package/dist/tool.d.mts +42 -0
- package/dist/tool.d.mts.map +1 -0
- package/dist/tool.mjs +38 -0
- package/dist/tool.mjs.map +1 -0
- package/dist/types.d.mts +282 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +19 -0
- package/dist/types.mjs.map +1 -0
- package/dist/ui/components/confirm.d.mts +13 -0
- package/dist/ui/components/confirm.d.mts.map +1 -0
- package/dist/ui/components/confirm.mjs +37 -0
- package/dist/ui/components/confirm.mjs.map +1 -0
- package/dist/ui/components/form.d.mts +50 -0
- package/dist/ui/components/form.d.mts.map +1 -0
- package/dist/ui/components/form.mjs +92 -0
- package/dist/ui/components/form.mjs.map +1 -0
- package/dist/ui/components/input.d.mts +29 -0
- package/dist/ui/components/input.d.mts.map +1 -0
- package/dist/ui/components/input.mjs +42 -0
- package/dist/ui/components/input.mjs.map +1 -0
- package/dist/ui/components/select.d.mts +41 -0
- package/dist/ui/components/select.d.mts.map +1 -0
- package/dist/ui/components/select.mjs +50 -0
- package/dist/ui/components/select.mjs.map +1 -0
- package/dist/ui/components/spinner.d.mts +28 -0
- package/dist/ui/components/spinner.d.mts.map +1 -0
- package/dist/ui/components/spinner.mjs +35 -0
- package/dist/ui/components/spinner.mjs.map +1 -0
- package/dist/ui/components/table.d.mts +60 -0
- package/dist/ui/components/table.d.mts.map +1 -0
- package/dist/ui/components/table.mjs +143 -0
- package/dist/ui/components/table.mjs.map +1 -0
- package/dist/ui/index.d.mts +9 -0
- package/dist/ui/utils/colors.d.mts +38 -0
- package/dist/ui/utils/colors.d.mts.map +1 -0
- package/dist/ui/utils/colors.mjs +64 -0
- package/dist/ui/utils/colors.mjs.map +1 -0
- package/dist/ui/utils/output.d.mts +23 -0
- package/dist/ui/utils/output.d.mts.map +1 -0
- package/dist/ui/utils/output.mjs +42 -0
- package/dist/ui/utils/output.mjs.map +1 -0
- package/dist/ui/utils/tty.d.mts +9 -0
- package/dist/ui/utils/tty.d.mts.map +1 -0
- package/dist/ui/utils/tty.mjs +12 -0
- package/dist/ui/utils/tty.mjs.map +1 -0
- package/package.json +81 -0
package/dist/bin/kly.mjs
ADDED
|
@@ -0,0 +1,2888 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import * as clack from "@clack/prompts";
|
|
4
|
+
import pc, { default as pc$1 } from "picocolors";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { exec, spawn } from "node:child_process";
|
|
8
|
+
import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
//#region rolldown:runtime
|
|
13
|
+
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
14
|
+
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/ai/models-dev.ts
|
|
17
|
+
const MODELS_DEV_API = "https://models.dev/api.json";
|
|
18
|
+
const CACHE_FILE = join(homedir(), ".kly", "models-cache.json");
|
|
19
|
+
const CACHE_TTL = 1440 * 60 * 1e3;
|
|
20
|
+
/**
|
|
21
|
+
* Fetch models.dev data with caching
|
|
22
|
+
*/
|
|
23
|
+
async function fetchModelsDevData(forceRefresh = false) {
|
|
24
|
+
if (!forceRefresh && existsSync(CACHE_FILE)) try {
|
|
25
|
+
const cached = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
|
|
26
|
+
if (Date.now() - cached.timestamp < CACHE_TTL) return cached;
|
|
27
|
+
} catch (_error) {}
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(MODELS_DEV_API);
|
|
30
|
+
if (!response.ok) return null;
|
|
31
|
+
const cachedData = {
|
|
32
|
+
providers: await response.json(),
|
|
33
|
+
timestamp: Date.now()
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cachedData, null, 2), "utf-8");
|
|
37
|
+
} catch (_error) {}
|
|
38
|
+
return cachedData;
|
|
39
|
+
} catch (_error) {
|
|
40
|
+
if (existsSync(CACHE_FILE)) try {
|
|
41
|
+
return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
|
|
42
|
+
} catch {}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Map our provider IDs to models.dev IDs
|
|
48
|
+
*/
|
|
49
|
+
const PROVIDER_ID_MAP = {
|
|
50
|
+
openai: "openai",
|
|
51
|
+
anthropic: "anthropic",
|
|
52
|
+
google: "google",
|
|
53
|
+
deepseek: "deepseek",
|
|
54
|
+
groq: "groq",
|
|
55
|
+
mistral: "mistral",
|
|
56
|
+
cohere: "cohere",
|
|
57
|
+
ollama: "ollama"
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Get provider info by our internal provider ID
|
|
61
|
+
*/
|
|
62
|
+
function getProviderInfo(data, provider) {
|
|
63
|
+
const modelsDevId = PROVIDER_ID_MAP[provider];
|
|
64
|
+
if (!modelsDevId) return null;
|
|
65
|
+
return data.providers[modelsDevId] || null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get all models for a provider
|
|
69
|
+
*/
|
|
70
|
+
function getProviderModels(data, provider) {
|
|
71
|
+
const providerInfo = getProviderInfo(data, provider);
|
|
72
|
+
if (!providerInfo) return [];
|
|
73
|
+
return Object.values(providerInfo.models);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get model info by ID
|
|
77
|
+
*/
|
|
78
|
+
function getModelInfo(data, provider, modelId) {
|
|
79
|
+
const providerInfo = getProviderInfo(data, provider);
|
|
80
|
+
if (!providerInfo) return null;
|
|
81
|
+
return providerInfo.models[modelId] || null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Format price for display
|
|
85
|
+
*/
|
|
86
|
+
function formatPrice(pricePerMillion) {
|
|
87
|
+
if (pricePerMillion === void 0) return "N/A";
|
|
88
|
+
if (pricePerMillion === 0) return "Free";
|
|
89
|
+
return pricePerMillion.toFixed(2).replace(/\.?0+$/, "");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Format capabilities for display
|
|
93
|
+
*/
|
|
94
|
+
function formatCapabilities(model) {
|
|
95
|
+
const caps = [];
|
|
96
|
+
if (model.tool_call) caps.push("Tools");
|
|
97
|
+
if (model.reasoning) caps.push("Reasoning");
|
|
98
|
+
if (model.structured_output) caps.push("JSON");
|
|
99
|
+
if (model.attachment) caps.push("Files");
|
|
100
|
+
return caps;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/ai/provider-config.ts
|
|
105
|
+
/**
|
|
106
|
+
* Provider configurations
|
|
107
|
+
* Based on https://models.dev/api.json
|
|
108
|
+
*/
|
|
109
|
+
const PROVIDER_CONFIGS = {
|
|
110
|
+
openai: {
|
|
111
|
+
docURL: "https://platform.openai.com/docs",
|
|
112
|
+
description: "OpenAI's GPT models"
|
|
113
|
+
},
|
|
114
|
+
anthropic: {
|
|
115
|
+
docURL: "https://docs.anthropic.com",
|
|
116
|
+
description: "Anthropic's Claude models"
|
|
117
|
+
},
|
|
118
|
+
google: {
|
|
119
|
+
docURL: "https://ai.google.dev/docs",
|
|
120
|
+
description: "Google's Gemini models"
|
|
121
|
+
},
|
|
122
|
+
deepseek: {
|
|
123
|
+
docURL: "https://platform.deepseek.com/docs",
|
|
124
|
+
description: "DeepSeek's AI models"
|
|
125
|
+
},
|
|
126
|
+
groq: {
|
|
127
|
+
baseURL: "https://api.groq.com/openai/v1",
|
|
128
|
+
docURL: "https://console.groq.com/docs",
|
|
129
|
+
description: "Ultra-fast LLM inference"
|
|
130
|
+
},
|
|
131
|
+
mistral: {
|
|
132
|
+
docURL: "https://docs.mistral.ai",
|
|
133
|
+
description: "Mistral AI models"
|
|
134
|
+
},
|
|
135
|
+
cohere: {
|
|
136
|
+
baseURL: "https://api.cohere.ai/v1",
|
|
137
|
+
docURL: "https://docs.cohere.com",
|
|
138
|
+
description: "Cohere's language models"
|
|
139
|
+
},
|
|
140
|
+
ollama: {
|
|
141
|
+
baseURL: "http://localhost:11434/v1",
|
|
142
|
+
docURL: "https://ollama.ai",
|
|
143
|
+
description: "Local AI models"
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Get default base URL for a provider
|
|
148
|
+
*/
|
|
149
|
+
function getDefaultBaseURL(provider) {
|
|
150
|
+
return PROVIDER_CONFIGS[provider]?.baseURL;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get provider description
|
|
154
|
+
*/
|
|
155
|
+
function getProviderDescription(provider) {
|
|
156
|
+
return PROVIDER_CONFIGS[provider]?.description;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/ai/storage.ts
|
|
161
|
+
const CONFIG_DIR$1 = join(homedir(), ".kly");
|
|
162
|
+
const CONFIG_FILE = join(CONFIG_DIR$1, "config.json");
|
|
163
|
+
/**
|
|
164
|
+
* Ensure config directory exists
|
|
165
|
+
*/
|
|
166
|
+
function ensureConfigDir() {
|
|
167
|
+
if (!existsSync(CONFIG_DIR$1)) mkdirSync(CONFIG_DIR$1, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Load configuration from ~/.kly/config.json
|
|
171
|
+
*/
|
|
172
|
+
function loadConfig() {
|
|
173
|
+
ensureConfigDir();
|
|
174
|
+
if (!existsSync(CONFIG_FILE)) return { models: {} };
|
|
175
|
+
try {
|
|
176
|
+
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
177
|
+
return JSON.parse(content);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error("Failed to parse config file:", error);
|
|
180
|
+
return { models: {} };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Save configuration to ~/.kly/config.json
|
|
185
|
+
*/
|
|
186
|
+
function saveConfig(config) {
|
|
187
|
+
ensureConfigDir();
|
|
188
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get current active model configuration
|
|
192
|
+
*/
|
|
193
|
+
function getCurrentModelConfig() {
|
|
194
|
+
const config = loadConfig();
|
|
195
|
+
if (!config.currentModel) return null;
|
|
196
|
+
return config.models[config.currentModel] || null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Set a model as current
|
|
200
|
+
*/
|
|
201
|
+
function setCurrentModel(modelName) {
|
|
202
|
+
const config = loadConfig();
|
|
203
|
+
if (!config.models[modelName]) throw new Error(`Model '${modelName}' not found in config`);
|
|
204
|
+
config.currentModel = modelName;
|
|
205
|
+
saveConfig(config);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Add or update a model configuration
|
|
209
|
+
*/
|
|
210
|
+
function saveModelConfig(modelName, modelConfig) {
|
|
211
|
+
const config = loadConfig();
|
|
212
|
+
config.models[modelName] = modelConfig;
|
|
213
|
+
if (!config.currentModel) config.currentModel = modelName;
|
|
214
|
+
saveConfig(config);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Remove a model configuration
|
|
218
|
+
*/
|
|
219
|
+
function removeModelConfig(modelName) {
|
|
220
|
+
const config = loadConfig();
|
|
221
|
+
delete config.models[modelName];
|
|
222
|
+
if (config.currentModel === modelName) config.currentModel = void 0;
|
|
223
|
+
saveConfig(config);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* List all configured models
|
|
227
|
+
*/
|
|
228
|
+
function listModels() {
|
|
229
|
+
const config = loadConfig();
|
|
230
|
+
return Object.entries(config.models).map(([name, modelConfig]) => ({
|
|
231
|
+
name,
|
|
232
|
+
config: modelConfig,
|
|
233
|
+
isCurrent: name === config.currentModel
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Get provider display name
|
|
238
|
+
*/
|
|
239
|
+
function getProviderDisplayName(provider) {
|
|
240
|
+
return {
|
|
241
|
+
openai: "OpenAI",
|
|
242
|
+
anthropic: "Anthropic",
|
|
243
|
+
google: "Google",
|
|
244
|
+
deepseek: "DeepSeek",
|
|
245
|
+
ollama: "Ollama",
|
|
246
|
+
groq: "Groq",
|
|
247
|
+
mistral: "Mistral",
|
|
248
|
+
cohere: "Cohere",
|
|
249
|
+
"openai-compatible": "OpenAI Compatible"
|
|
250
|
+
}[provider];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/ai/types.ts
|
|
255
|
+
/**
|
|
256
|
+
* Default models for each provider
|
|
257
|
+
* Based on https://models.dev/ (2025-12)
|
|
258
|
+
*/
|
|
259
|
+
const DEFAULT_MODELS = {
|
|
260
|
+
openai: "gpt-4o-mini",
|
|
261
|
+
anthropic: "claude-3-5-sonnet-20241022",
|
|
262
|
+
google: "gemini-2.5-flash",
|
|
263
|
+
deepseek: "deepseek-v3",
|
|
264
|
+
ollama: "llama3.2",
|
|
265
|
+
groq: "llama-3.3-70b-versatile",
|
|
266
|
+
mistral: "mistral-large-2411",
|
|
267
|
+
cohere: "command-r-plus",
|
|
268
|
+
"openai-compatible": "gpt-4o-mini"
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/ai/models-command.ts
|
|
273
|
+
const PROVIDER_OPTIONS = [
|
|
274
|
+
{
|
|
275
|
+
value: "openai",
|
|
276
|
+
label: "OpenAI",
|
|
277
|
+
hint: getProviderDescription("openai")
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
value: "anthropic",
|
|
281
|
+
label: "Anthropic",
|
|
282
|
+
hint: getProviderDescription("anthropic")
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
value: "google",
|
|
286
|
+
label: "Google",
|
|
287
|
+
hint: getProviderDescription("google")
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
value: "deepseek",
|
|
291
|
+
label: "DeepSeek",
|
|
292
|
+
hint: getProviderDescription("deepseek")
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
value: "groq",
|
|
296
|
+
label: "Groq",
|
|
297
|
+
hint: getProviderDescription("groq")
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
value: "mistral",
|
|
301
|
+
label: "Mistral",
|
|
302
|
+
hint: getProviderDescription("mistral")
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
value: "cohere",
|
|
306
|
+
label: "Cohere",
|
|
307
|
+
hint: getProviderDescription("cohere")
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
value: "ollama",
|
|
311
|
+
label: "Ollama",
|
|
312
|
+
hint: getProviderDescription("ollama")
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
value: "openai-compatible",
|
|
316
|
+
label: "OpenAI Compatible",
|
|
317
|
+
hint: "Custom endpoint"
|
|
318
|
+
}
|
|
319
|
+
];
|
|
320
|
+
/**
|
|
321
|
+
* Main entry point for `kly models` command
|
|
322
|
+
*/
|
|
323
|
+
async function modelsCommand() {
|
|
324
|
+
clack.intro(pc.bgCyan(pc.black(" kly models ")));
|
|
325
|
+
const models = listModels();
|
|
326
|
+
const action = await clack.select({
|
|
327
|
+
message: "What would you like to do?",
|
|
328
|
+
options: [
|
|
329
|
+
{
|
|
330
|
+
value: "list",
|
|
331
|
+
label: "List configured models"
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
value: "add",
|
|
335
|
+
label: "Add a new model"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
value: "switch",
|
|
339
|
+
label: "Switch current model",
|
|
340
|
+
disabled: models.length === 0
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
value: "remove",
|
|
344
|
+
label: "Remove a model",
|
|
345
|
+
disabled: models.length === 0
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
});
|
|
349
|
+
if (clack.isCancel(action)) {
|
|
350
|
+
clack.cancel("Operation cancelled");
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
switch (action) {
|
|
354
|
+
case "list":
|
|
355
|
+
await listAction();
|
|
356
|
+
break;
|
|
357
|
+
case "add":
|
|
358
|
+
await addAction();
|
|
359
|
+
break;
|
|
360
|
+
case "switch":
|
|
361
|
+
await switchAction();
|
|
362
|
+
break;
|
|
363
|
+
case "remove":
|
|
364
|
+
await removeAction();
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
clack.outro(pc.green("Done!"));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* List all configured models
|
|
371
|
+
*/
|
|
372
|
+
async function listAction() {
|
|
373
|
+
const models = listModels();
|
|
374
|
+
if (models.length === 0) {
|
|
375
|
+
clack.note("No models configured yet.\nRun 'kly models' and select 'Add a new model'");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const modelsData = await fetchModelsDevData();
|
|
379
|
+
const lines = [];
|
|
380
|
+
for (const model of models) {
|
|
381
|
+
const current = model.isCurrent ? pc.green("ā ") : " ";
|
|
382
|
+
const provider = getProviderDisplayName(model.config.provider);
|
|
383
|
+
const modelName = model.config.model || DEFAULT_MODELS[model.config.provider];
|
|
384
|
+
let line = `${current}${pc.cyan(model.name)} - ${provider} (${modelName})`;
|
|
385
|
+
if (modelsData) {
|
|
386
|
+
const modelInfo = getModelInfo(modelsData, model.config.provider, modelName);
|
|
387
|
+
if (modelInfo) {
|
|
388
|
+
const metadata = formatModelMetadata(modelInfo);
|
|
389
|
+
if (metadata) line += ` ${pc.dim(metadata)}`;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
lines.push(line);
|
|
393
|
+
}
|
|
394
|
+
clack.note(lines.join("\n"), "Configured models:");
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Format model metadata (pricing and capabilities) for display
|
|
398
|
+
*/
|
|
399
|
+
function formatModelMetadata(modelInfo) {
|
|
400
|
+
const parts = [];
|
|
401
|
+
if (modelInfo.cost?.input !== void 0 && modelInfo.cost?.output !== void 0) parts.push(`[$${formatPrice(modelInfo.cost.input)}/$${formatPrice(modelInfo.cost.output)} per 1M]`);
|
|
402
|
+
const caps = formatCapabilities(modelInfo);
|
|
403
|
+
if (caps.length > 0) parts.push(`[${caps.join(", ")}]`);
|
|
404
|
+
return parts.join(" ");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Add a new model configuration
|
|
408
|
+
*/
|
|
409
|
+
async function addAction() {
|
|
410
|
+
const name = await clack.text({
|
|
411
|
+
message: "Enter a name for this model configuration:",
|
|
412
|
+
placeholder: "my-openai",
|
|
413
|
+
validate: (value) => {
|
|
414
|
+
if (!value) return "Name is required";
|
|
415
|
+
if (listModels().some((m) => m.name === value)) return "A model with this name already exists";
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (clack.isCancel(name)) {
|
|
419
|
+
clack.cancel("Operation cancelled");
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
const provider = await clack.select({
|
|
423
|
+
message: "Select a provider:",
|
|
424
|
+
options: PROVIDER_OPTIONS
|
|
425
|
+
});
|
|
426
|
+
if (clack.isCancel(provider)) {
|
|
427
|
+
clack.cancel("Operation cancelled");
|
|
428
|
+
process.exit(0);
|
|
429
|
+
}
|
|
430
|
+
saveModelConfig(name, await getProviderConfig(provider));
|
|
431
|
+
clack.note(`Model '${pc.cyan(name)}' configured with ${getProviderDisplayName(provider)}`, pc.green("Success!"));
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Switch to a different model
|
|
435
|
+
*/
|
|
436
|
+
async function switchAction() {
|
|
437
|
+
const models = listModels();
|
|
438
|
+
if (models.length === 0) {
|
|
439
|
+
clack.note("No models configured");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const modelName = await clack.select({
|
|
443
|
+
message: "Select a model:",
|
|
444
|
+
options: models.map((m) => ({
|
|
445
|
+
value: m.name,
|
|
446
|
+
label: m.name,
|
|
447
|
+
hint: `${getProviderDisplayName(m.config.provider)} - ${m.config.model || DEFAULT_MODELS[m.config.provider]}`
|
|
448
|
+
}))
|
|
449
|
+
});
|
|
450
|
+
if (clack.isCancel(modelName)) {
|
|
451
|
+
clack.cancel("Operation cancelled");
|
|
452
|
+
process.exit(0);
|
|
453
|
+
}
|
|
454
|
+
setCurrentModel(modelName);
|
|
455
|
+
clack.note(`Switched to '${pc.cyan(modelName)}'`, pc.green("Success!"));
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Remove a model configuration
|
|
459
|
+
*/
|
|
460
|
+
async function removeAction() {
|
|
461
|
+
const models = listModels();
|
|
462
|
+
if (models.length === 0) {
|
|
463
|
+
clack.note("No models configured");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const modelName = await clack.select({
|
|
467
|
+
message: "Select a model to remove:",
|
|
468
|
+
options: models.map((m) => ({
|
|
469
|
+
value: m.name,
|
|
470
|
+
label: m.name,
|
|
471
|
+
hint: `${getProviderDisplayName(m.config.provider)}`
|
|
472
|
+
}))
|
|
473
|
+
});
|
|
474
|
+
if (clack.isCancel(modelName)) {
|
|
475
|
+
clack.cancel("Operation cancelled");
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}
|
|
478
|
+
const confirm$1 = await clack.confirm({ message: `Are you sure you want to remove '${modelName}'?` });
|
|
479
|
+
if (clack.isCancel(confirm$1) || !confirm$1) {
|
|
480
|
+
clack.cancel("Operation cancelled");
|
|
481
|
+
process.exit(0);
|
|
482
|
+
}
|
|
483
|
+
removeModelConfig(modelName);
|
|
484
|
+
clack.note(`Removed '${pc.cyan(modelName)}'`, pc.green("Success!"));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Get provider-specific configuration
|
|
488
|
+
*/
|
|
489
|
+
async function getProviderConfig(provider) {
|
|
490
|
+
const defaultModel = DEFAULT_MODELS[provider];
|
|
491
|
+
if (provider === "ollama") {
|
|
492
|
+
const baseURL$1 = await clack.text({
|
|
493
|
+
message: "Ollama base URL:",
|
|
494
|
+
placeholder: "http://localhost:11434",
|
|
495
|
+
defaultValue: "http://localhost:11434"
|
|
496
|
+
});
|
|
497
|
+
if (clack.isCancel(baseURL$1)) {
|
|
498
|
+
clack.cancel("Operation cancelled");
|
|
499
|
+
process.exit(0);
|
|
500
|
+
}
|
|
501
|
+
const model$1 = await clack.text({
|
|
502
|
+
message: "Model name:",
|
|
503
|
+
placeholder: defaultModel,
|
|
504
|
+
defaultValue: defaultModel
|
|
505
|
+
});
|
|
506
|
+
if (clack.isCancel(model$1)) {
|
|
507
|
+
clack.cancel("Operation cancelled");
|
|
508
|
+
process.exit(0);
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
provider,
|
|
512
|
+
baseURL: baseURL$1 || "http://localhost:11434",
|
|
513
|
+
model: model$1 || defaultModel
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const apiKey = await clack.password({
|
|
517
|
+
message: `Enter your ${getProviderDisplayName(provider)} API key:`,
|
|
518
|
+
validate: (value) => {
|
|
519
|
+
if (!value) return "API key is required";
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
if (clack.isCancel(apiKey)) {
|
|
523
|
+
clack.cancel("Operation cancelled");
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
const customBaseURL = await clack.confirm({
|
|
527
|
+
message: "Do you want to specify a custom base URL?",
|
|
528
|
+
initialValue: false
|
|
529
|
+
});
|
|
530
|
+
if (clack.isCancel(customBaseURL)) {
|
|
531
|
+
clack.cancel("Operation cancelled");
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
let baseURL;
|
|
535
|
+
if (customBaseURL) {
|
|
536
|
+
const defaultURL = getDefaultBaseURL(provider);
|
|
537
|
+
const baseURLInput = await clack.text({
|
|
538
|
+
message: "Base URL:",
|
|
539
|
+
placeholder: defaultURL || ""
|
|
540
|
+
});
|
|
541
|
+
if (clack.isCancel(baseURLInput)) {
|
|
542
|
+
clack.cancel("Operation cancelled");
|
|
543
|
+
process.exit(0);
|
|
544
|
+
}
|
|
545
|
+
baseURL = baseURLInput || void 0;
|
|
546
|
+
}
|
|
547
|
+
const model = await selectModelForProvider(provider, defaultModel);
|
|
548
|
+
return {
|
|
549
|
+
provider,
|
|
550
|
+
apiKey,
|
|
551
|
+
baseURL,
|
|
552
|
+
model
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Let user select a model for a provider
|
|
557
|
+
*/
|
|
558
|
+
async function selectModelForProvider(provider, defaultModel) {
|
|
559
|
+
const modelsData = await fetchModelsDevData();
|
|
560
|
+
if (modelsData) {
|
|
561
|
+
const availableModels = getProviderModels(modelsData, provider);
|
|
562
|
+
if (availableModels.length > 0) return await selectFromModelList(availableModels, defaultModel);
|
|
563
|
+
}
|
|
564
|
+
return await selectModelWithInput(defaultModel);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Show model selection list with pricing and capabilities
|
|
568
|
+
*/
|
|
569
|
+
async function selectFromModelList(availableModels, defaultModel) {
|
|
570
|
+
const modelOptions = availableModels.slice(0, 10).map((m) => {
|
|
571
|
+
const parts = [];
|
|
572
|
+
if (m.cost?.input !== void 0 && m.cost?.output !== void 0) parts.push(`$${formatPrice(m.cost.input)}/$${formatPrice(m.cost.output)} per 1M`);
|
|
573
|
+
const caps = formatCapabilities(m);
|
|
574
|
+
if (caps.length > 0) parts.push(caps.join(", "));
|
|
575
|
+
return {
|
|
576
|
+
value: m.id,
|
|
577
|
+
label: m.name || m.id,
|
|
578
|
+
hint: parts.length > 0 ? parts.join(" ⢠") : "No info available"
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
modelOptions.push({
|
|
582
|
+
value: "__default__",
|
|
583
|
+
label: `Use default (${defaultModel})`,
|
|
584
|
+
hint: "Recommended"
|
|
585
|
+
});
|
|
586
|
+
modelOptions.push({
|
|
587
|
+
value: "__custom__",
|
|
588
|
+
label: "Enter custom model name",
|
|
589
|
+
hint: "Advanced"
|
|
590
|
+
});
|
|
591
|
+
const selectedModel = await clack.select({
|
|
592
|
+
message: "Select a model:",
|
|
593
|
+
options: modelOptions
|
|
594
|
+
});
|
|
595
|
+
if (clack.isCancel(selectedModel)) {
|
|
596
|
+
clack.cancel("Operation cancelled");
|
|
597
|
+
process.exit(0);
|
|
598
|
+
}
|
|
599
|
+
if (selectedModel === "__default__") return;
|
|
600
|
+
if (selectedModel === "__custom__") return await promptForModelName(defaultModel);
|
|
601
|
+
return selectedModel;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Simple model selection via confirm + input
|
|
605
|
+
*/
|
|
606
|
+
async function selectModelWithInput(defaultModel) {
|
|
607
|
+
const useDefault = await clack.confirm({
|
|
608
|
+
message: `Use default model (${defaultModel})?`,
|
|
609
|
+
initialValue: true
|
|
610
|
+
});
|
|
611
|
+
if (clack.isCancel(useDefault)) {
|
|
612
|
+
clack.cancel("Operation cancelled");
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
if (useDefault) return;
|
|
616
|
+
return await promptForModelName(defaultModel);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Prompt user to enter a custom model name
|
|
620
|
+
*/
|
|
621
|
+
async function promptForModelName(defaultModel) {
|
|
622
|
+
const modelInput = await clack.text({
|
|
623
|
+
message: "Model name:",
|
|
624
|
+
placeholder: defaultModel,
|
|
625
|
+
defaultValue: defaultModel
|
|
626
|
+
});
|
|
627
|
+
if (clack.isCancel(modelInput)) {
|
|
628
|
+
clack.cancel("Operation cancelled");
|
|
629
|
+
process.exit(0);
|
|
630
|
+
}
|
|
631
|
+
return modelInput || defaultModel;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/shared/ipc-protocol.ts
|
|
636
|
+
/**
|
|
637
|
+
* Type guard for IPC messages
|
|
638
|
+
*/
|
|
639
|
+
function isIPCRequest(msg) {
|
|
640
|
+
return typeof msg === "object" && msg !== null && "type" in msg && "id" in msg && typeof msg.id === "string";
|
|
641
|
+
}
|
|
642
|
+
function isExecutionCompleteMessage(msg) {
|
|
643
|
+
return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "complete";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/host/resource-provider.ts
|
|
648
|
+
/**
|
|
649
|
+
* Resource Provider - Host-side IPC server
|
|
650
|
+
* Handles resource requests from the sandboxed child process
|
|
651
|
+
* Enforces permissions and provides controlled access to sensitive resources
|
|
652
|
+
*/
|
|
653
|
+
var ResourceProvider = class {
|
|
654
|
+
constructor(options) {
|
|
655
|
+
this.options = options;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Handle an IPC request from sandbox
|
|
659
|
+
*/
|
|
660
|
+
async handle(request) {
|
|
661
|
+
try {
|
|
662
|
+
switch (request.type) {
|
|
663
|
+
case "listModels": return this.handleListModels(request.id);
|
|
664
|
+
case "getModelConfig": return this.handleGetModelConfig(request.id, request.payload.name);
|
|
665
|
+
case "log": return this.handleLog(request.id, request.payload.level, request.payload.message);
|
|
666
|
+
case "prompt:input": return this.handlePromptInput(request.id, request.payload);
|
|
667
|
+
case "prompt:select": return this.handlePromptSelect(request.id, request.payload);
|
|
668
|
+
case "prompt:confirm": return this.handlePromptConfirm(request.id, request.payload);
|
|
669
|
+
case "prompt:multiselect": return this.handlePromptMultiselect(request.id, request.payload);
|
|
670
|
+
case "prompt:form": return this.handlePromptForm(request.id, request.payload);
|
|
671
|
+
default: {
|
|
672
|
+
const unknownRequest = request;
|
|
673
|
+
return {
|
|
674
|
+
type: "response",
|
|
675
|
+
id: unknownRequest.id,
|
|
676
|
+
success: false,
|
|
677
|
+
error: `Unknown request type: ${unknownRequest.type}`
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return {
|
|
683
|
+
type: "response",
|
|
684
|
+
id: request.id,
|
|
685
|
+
success: false,
|
|
686
|
+
error: error instanceof Error ? error.message : String(error)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Handle: List available models (no permission required)
|
|
692
|
+
*/
|
|
693
|
+
handleListModels(requestId) {
|
|
694
|
+
return {
|
|
695
|
+
type: "response",
|
|
696
|
+
id: requestId,
|
|
697
|
+
success: true,
|
|
698
|
+
data: listModels().map((m) => ({
|
|
699
|
+
name: m.name,
|
|
700
|
+
provider: m.config.provider,
|
|
701
|
+
model: m.config.model,
|
|
702
|
+
isCurrent: m.isCurrent
|
|
703
|
+
}))
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Handle: Get model config with API key (requires permission)
|
|
708
|
+
*/
|
|
709
|
+
handleGetModelConfig(requestId, name) {
|
|
710
|
+
if (!this.options.allowApiKey) return {
|
|
711
|
+
type: "response",
|
|
712
|
+
id: requestId,
|
|
713
|
+
success: false,
|
|
714
|
+
error: "Permission denied: API key access not allowed for this app"
|
|
715
|
+
};
|
|
716
|
+
let modelConfig = null;
|
|
717
|
+
if (name) {
|
|
718
|
+
const found = listModels().find((m) => m.name === name);
|
|
719
|
+
if (found) modelConfig = {
|
|
720
|
+
provider: found.config.provider,
|
|
721
|
+
model: found.config.model,
|
|
722
|
+
apiKey: found.config.apiKey,
|
|
723
|
+
baseURL: found.config.baseURL
|
|
724
|
+
};
|
|
725
|
+
} else {
|
|
726
|
+
const current = getCurrentModelConfig();
|
|
727
|
+
if (current) modelConfig = {
|
|
728
|
+
provider: current.provider,
|
|
729
|
+
model: current.model,
|
|
730
|
+
apiKey: current.apiKey,
|
|
731
|
+
baseURL: current.baseURL
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
type: "response",
|
|
736
|
+
id: requestId,
|
|
737
|
+
success: true,
|
|
738
|
+
data: modelConfig
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Handle: Log message (for debugging)
|
|
743
|
+
*/
|
|
744
|
+
handleLog(requestId, level, message) {
|
|
745
|
+
const prefix = `[Sandbox:${this.options.appId}]`;
|
|
746
|
+
switch (level) {
|
|
747
|
+
case "info":
|
|
748
|
+
console.log(`${prefix} ${message}`);
|
|
749
|
+
break;
|
|
750
|
+
case "warn":
|
|
751
|
+
console.warn(`${prefix} ${message}`);
|
|
752
|
+
break;
|
|
753
|
+
case "error":
|
|
754
|
+
console.error(`${prefix} ${message}`);
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
type: "response",
|
|
759
|
+
id: requestId,
|
|
760
|
+
success: true,
|
|
761
|
+
data: void 0
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Handle: Interactive input prompt
|
|
766
|
+
*/
|
|
767
|
+
async handlePromptInput(requestId, payload) {
|
|
768
|
+
const result = await clack.text({
|
|
769
|
+
message: payload.prompt,
|
|
770
|
+
defaultValue: payload.defaultValue,
|
|
771
|
+
placeholder: payload.placeholder,
|
|
772
|
+
validate: payload.maxLength ? (value) => {
|
|
773
|
+
if (value && value.length > payload.maxLength) return `Input must be ${payload.maxLength} characters or less`;
|
|
774
|
+
} : void 0
|
|
775
|
+
});
|
|
776
|
+
if (clack.isCancel(result)) return {
|
|
777
|
+
type: "response",
|
|
778
|
+
id: requestId,
|
|
779
|
+
success: false,
|
|
780
|
+
error: "Operation cancelled by user"
|
|
781
|
+
};
|
|
782
|
+
return {
|
|
783
|
+
type: "response",
|
|
784
|
+
id: requestId,
|
|
785
|
+
success: true,
|
|
786
|
+
data: result
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Handle: Interactive select prompt
|
|
791
|
+
*/
|
|
792
|
+
async handlePromptSelect(requestId, payload) {
|
|
793
|
+
const mappedOptions = payload.options.map((opt) => ({
|
|
794
|
+
label: opt.name,
|
|
795
|
+
value: opt.value,
|
|
796
|
+
...opt.description && { hint: opt.description }
|
|
797
|
+
}));
|
|
798
|
+
const result = await clack.select({
|
|
799
|
+
message: payload.prompt,
|
|
800
|
+
options: mappedOptions
|
|
801
|
+
});
|
|
802
|
+
if (clack.isCancel(result)) return {
|
|
803
|
+
type: "response",
|
|
804
|
+
id: requestId,
|
|
805
|
+
success: false,
|
|
806
|
+
error: "Operation cancelled by user"
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
type: "response",
|
|
810
|
+
id: requestId,
|
|
811
|
+
success: true,
|
|
812
|
+
data: result
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Handle: Interactive confirm prompt
|
|
817
|
+
*/
|
|
818
|
+
async handlePromptConfirm(requestId, payload) {
|
|
819
|
+
const result = await clack.confirm({
|
|
820
|
+
message: payload.message,
|
|
821
|
+
initialValue: payload.defaultValue
|
|
822
|
+
});
|
|
823
|
+
if (clack.isCancel(result)) return {
|
|
824
|
+
type: "response",
|
|
825
|
+
id: requestId,
|
|
826
|
+
success: false,
|
|
827
|
+
error: "Operation cancelled by user"
|
|
828
|
+
};
|
|
829
|
+
return {
|
|
830
|
+
type: "response",
|
|
831
|
+
id: requestId,
|
|
832
|
+
success: true,
|
|
833
|
+
data: result
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Handle: Interactive multiselect prompt
|
|
838
|
+
*/
|
|
839
|
+
async handlePromptMultiselect(requestId, payload) {
|
|
840
|
+
const mappedOptions = payload.options.map((opt) => ({
|
|
841
|
+
label: opt.name,
|
|
842
|
+
value: opt.value,
|
|
843
|
+
...opt.description && { hint: opt.description }
|
|
844
|
+
}));
|
|
845
|
+
const result = await clack.multiselect({
|
|
846
|
+
message: payload.prompt,
|
|
847
|
+
options: mappedOptions,
|
|
848
|
+
required: payload.required
|
|
849
|
+
});
|
|
850
|
+
if (clack.isCancel(result)) return {
|
|
851
|
+
type: "response",
|
|
852
|
+
id: requestId,
|
|
853
|
+
success: false,
|
|
854
|
+
error: "Operation cancelled by user"
|
|
855
|
+
};
|
|
856
|
+
return {
|
|
857
|
+
type: "response",
|
|
858
|
+
id: requestId,
|
|
859
|
+
success: true,
|
|
860
|
+
data: result
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Handle: Interactive form prompt
|
|
865
|
+
*/
|
|
866
|
+
async handlePromptForm(requestId, payload) {
|
|
867
|
+
const result = {};
|
|
868
|
+
if (payload.title) console.log(`\n${pc.bold(payload.title)}\n`);
|
|
869
|
+
for (const field of payload.fields) {
|
|
870
|
+
const label = field.description ? `${field.label} (${field.description})` : field.label;
|
|
871
|
+
if (field.type === "boolean") {
|
|
872
|
+
const value = await clack.confirm({
|
|
873
|
+
message: label,
|
|
874
|
+
initialValue: field.defaultValue
|
|
875
|
+
});
|
|
876
|
+
if (clack.isCancel(value)) return {
|
|
877
|
+
type: "response",
|
|
878
|
+
id: requestId,
|
|
879
|
+
success: false,
|
|
880
|
+
error: "Operation cancelled by user"
|
|
881
|
+
};
|
|
882
|
+
result[field.name] = value;
|
|
883
|
+
} else if (field.type === "enum" && field.enumValues?.length) {
|
|
884
|
+
const value = await clack.select({
|
|
885
|
+
message: label,
|
|
886
|
+
options: field.enumValues.map((v) => ({
|
|
887
|
+
label: v,
|
|
888
|
+
value: v
|
|
889
|
+
}))
|
|
890
|
+
});
|
|
891
|
+
if (clack.isCancel(value)) return {
|
|
892
|
+
type: "response",
|
|
893
|
+
id: requestId,
|
|
894
|
+
success: false,
|
|
895
|
+
error: "Operation cancelled by user"
|
|
896
|
+
};
|
|
897
|
+
result[field.name] = value;
|
|
898
|
+
} else if (field.type === "number") {
|
|
899
|
+
const strValue = await clack.text({
|
|
900
|
+
message: label,
|
|
901
|
+
defaultValue: field.defaultValue?.toString(),
|
|
902
|
+
validate: (value) => {
|
|
903
|
+
if (value && Number.isNaN(Number.parseFloat(value))) return "Please enter a valid number";
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
if (clack.isCancel(strValue)) return {
|
|
907
|
+
type: "response",
|
|
908
|
+
id: requestId,
|
|
909
|
+
success: false,
|
|
910
|
+
error: "Operation cancelled by user"
|
|
911
|
+
};
|
|
912
|
+
result[field.name] = Number.parseFloat(strValue);
|
|
913
|
+
} else {
|
|
914
|
+
const value = await clack.text({
|
|
915
|
+
message: label,
|
|
916
|
+
defaultValue: field.defaultValue
|
|
917
|
+
});
|
|
918
|
+
if (clack.isCancel(value)) return {
|
|
919
|
+
type: "response",
|
|
920
|
+
id: requestId,
|
|
921
|
+
success: false,
|
|
922
|
+
error: "Operation cancelled by user"
|
|
923
|
+
};
|
|
924
|
+
result[field.name] = value;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
type: "response",
|
|
929
|
+
id: requestId,
|
|
930
|
+
success: true,
|
|
931
|
+
data: result
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
/**
|
|
936
|
+
* Factory function to create a resource provider
|
|
937
|
+
*/
|
|
938
|
+
function createResourceProvider(options) {
|
|
939
|
+
return new ResourceProvider(options);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
//#endregion
|
|
943
|
+
//#region src/host/launcher.ts
|
|
944
|
+
/**
|
|
945
|
+
* Launch a user script in a sandboxed child process
|
|
946
|
+
* This is the Host-side launcher that:
|
|
947
|
+
* 1. Spawns a child process with IPC
|
|
948
|
+
* 2. Applies OS-level sandboxing
|
|
949
|
+
* 3. Handles IPC communication for resource access
|
|
950
|
+
* 4. Returns execution result
|
|
951
|
+
*/
|
|
952
|
+
async function launchSandbox(options) {
|
|
953
|
+
const { scriptPath, args: args$1, appId, sandboxConfig, allowApiKey } = options;
|
|
954
|
+
await SandboxManager.initialize(sandboxConfig);
|
|
955
|
+
const absoluteScriptPath = resolve(process.cwd(), scriptPath);
|
|
956
|
+
const executorPath = resolve(__dirname, "../sandbox/bundled-executor.mjs");
|
|
957
|
+
if (!SandboxManager.isSandboxingEnabled()) {
|
|
958
|
+
console.warn("ā ļø Sandboxing is not supported on this platform.");
|
|
959
|
+
console.warn(" Running without OS-level isolation.");
|
|
960
|
+
} else {
|
|
961
|
+
console.log("š Sandbox Configuration:");
|
|
962
|
+
console.log(` Read denied: ${sandboxConfig.filesystem.denyRead.length} paths`);
|
|
963
|
+
console.log(` Write allowed: ${sandboxConfig.filesystem.allowWrite.length} paths`);
|
|
964
|
+
console.log(` Network: ${sandboxConfig.network.allowedDomains.join(", ") || "none"}`);
|
|
965
|
+
console.log("");
|
|
966
|
+
}
|
|
967
|
+
const command$1 = `bun run ${executorPath}`;
|
|
968
|
+
const child = spawn(await SandboxManager.wrapWithSandbox(command$1), {
|
|
969
|
+
shell: true,
|
|
970
|
+
stdio: [
|
|
971
|
+
"inherit",
|
|
972
|
+
"inherit",
|
|
973
|
+
"inherit",
|
|
974
|
+
"ipc"
|
|
975
|
+
],
|
|
976
|
+
env: {
|
|
977
|
+
...process.env,
|
|
978
|
+
KLY_SANDBOX_MODE: "true"
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
const resourceProvider = createResourceProvider({
|
|
982
|
+
appId,
|
|
983
|
+
allowApiKey,
|
|
984
|
+
sandboxConfig
|
|
985
|
+
});
|
|
986
|
+
child.on("message", async (message) => {
|
|
987
|
+
if (isIPCRequest(message)) {
|
|
988
|
+
const response = await resourceProvider.handle(message);
|
|
989
|
+
child.send(response);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
const initMessage = {
|
|
993
|
+
type: "init",
|
|
994
|
+
scriptPath: absoluteScriptPath,
|
|
995
|
+
args: args$1,
|
|
996
|
+
appId,
|
|
997
|
+
permissions: {
|
|
998
|
+
allowApiKey,
|
|
999
|
+
sandboxConfig
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
child.send(initMessage);
|
|
1003
|
+
return new Promise((resolve$1, reject) => {
|
|
1004
|
+
let executionResult = null;
|
|
1005
|
+
child.on("message", (message) => {
|
|
1006
|
+
if (isExecutionCompleteMessage(message)) executionResult = message;
|
|
1007
|
+
});
|
|
1008
|
+
child.on("error", (error) => {
|
|
1009
|
+
reject(/* @__PURE__ */ new Error(`Sandbox process error: ${error.message}`));
|
|
1010
|
+
});
|
|
1011
|
+
child.on("exit", (code) => {
|
|
1012
|
+
if (executionResult) resolve$1({
|
|
1013
|
+
exitCode: code ?? 0,
|
|
1014
|
+
result: executionResult.result,
|
|
1015
|
+
error: executionResult.error
|
|
1016
|
+
});
|
|
1017
|
+
else resolve$1({
|
|
1018
|
+
exitCode: code ?? 1,
|
|
1019
|
+
error: code !== 0 ? `Process exited with code ${code}` : void 0
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/shared/constants.ts
|
|
1027
|
+
/**
|
|
1028
|
+
* Centralized constants for the KLY project
|
|
1029
|
+
* Prevents magic strings and improves maintainability
|
|
1030
|
+
*/
|
|
1031
|
+
/**
|
|
1032
|
+
* Environment variable names used throughout the application
|
|
1033
|
+
*/
|
|
1034
|
+
const ENV_VARS = {
|
|
1035
|
+
SANDBOX_MODE: "KLY_SANDBOX_MODE",
|
|
1036
|
+
MCP_MODE: "KLY_MCP_MODE",
|
|
1037
|
+
PROGRAMMATIC: "KLY_PROGRAMMATIC",
|
|
1038
|
+
TRUST_ALL: "KLY_TRUST_ALL",
|
|
1039
|
+
LOCAL_REF: "KLY_LOCAL_REF",
|
|
1040
|
+
REMOTE_REF: "KLY_REMOTE_REF"
|
|
1041
|
+
};
|
|
1042
|
+
/**
|
|
1043
|
+
* File and directory paths used for configuration and caching
|
|
1044
|
+
*/
|
|
1045
|
+
const PATHS = {
|
|
1046
|
+
CONFIG_DIR: ".kly",
|
|
1047
|
+
META_FILE: ".kly-meta.json",
|
|
1048
|
+
PERMISSIONS_FILE: "permissions.json",
|
|
1049
|
+
CONFIG_FILE: "config.json"
|
|
1050
|
+
};
|
|
1051
|
+
/**
|
|
1052
|
+
* Timeout values in milliseconds
|
|
1053
|
+
*/
|
|
1054
|
+
const TIMEOUTS = {
|
|
1055
|
+
IPC_REQUEST: 3e4,
|
|
1056
|
+
IPC_LONG_REQUEST: 6e4
|
|
1057
|
+
};
|
|
1058
|
+
/**
|
|
1059
|
+
* LLM API domains for network permission configuration
|
|
1060
|
+
*/
|
|
1061
|
+
const LLM_API_DOMAINS = [
|
|
1062
|
+
"api.openai.com",
|
|
1063
|
+
"*.anthropic.com",
|
|
1064
|
+
"generativelanguage.googleapis.com",
|
|
1065
|
+
"api.deepseek.com"
|
|
1066
|
+
];
|
|
1067
|
+
|
|
1068
|
+
//#endregion
|
|
1069
|
+
//#region src/shared/runtime-mode.ts
|
|
1070
|
+
/**
|
|
1071
|
+
* Check if running in sandbox mode
|
|
1072
|
+
* Sandbox mode: Isolated child process with restricted permissions
|
|
1073
|
+
*/
|
|
1074
|
+
function isSandbox() {
|
|
1075
|
+
return process.env[ENV_VARS.SANDBOX_MODE] === "true";
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Check if running in MCP (Model Context Protocol) mode
|
|
1079
|
+
* MCP mode: Running as an MCP server for Claude Desktop integration
|
|
1080
|
+
*/
|
|
1081
|
+
function isMCP() {
|
|
1082
|
+
return process.env[ENV_VARS.MCP_MODE] === "true";
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Check if running with trust all flag
|
|
1086
|
+
* Trust all: Skip permission prompts (for testing/automation)
|
|
1087
|
+
*/
|
|
1088
|
+
function isTrustAll() {
|
|
1089
|
+
return process.env[ENV_VARS.TRUST_ALL] === "true";
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Get local reference environment variable
|
|
1093
|
+
*/
|
|
1094
|
+
function getLocalRef() {
|
|
1095
|
+
return process.env[ENV_VARS.LOCAL_REF];
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Get remote reference environment variable
|
|
1099
|
+
*/
|
|
1100
|
+
function getRemoteRef() {
|
|
1101
|
+
return process.env[ENV_VARS.REMOTE_REF];
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/ui/utils/tty.ts
|
|
1106
|
+
/**
|
|
1107
|
+
* Check if we're in a TTY environment
|
|
1108
|
+
* Returns false in CI or non-interactive environments
|
|
1109
|
+
*/
|
|
1110
|
+
function isTTY() {
|
|
1111
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY && !process.env.CI);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
//#endregion
|
|
1115
|
+
//#region src/sandbox/ipc-client.ts
|
|
1116
|
+
/**
|
|
1117
|
+
* Send an IPC request to the host and wait for response
|
|
1118
|
+
* Used by UI components and other sandbox code to communicate with the host process
|
|
1119
|
+
*/
|
|
1120
|
+
async function sendIPCRequest(type, payload) {
|
|
1121
|
+
if (!process.send) throw new Error("IPC not available - not running in sandbox mode");
|
|
1122
|
+
return new Promise((resolve$1, reject) => {
|
|
1123
|
+
const requestId = `${type}-${Date.now()}-${Math.random()}`;
|
|
1124
|
+
const request = {
|
|
1125
|
+
type,
|
|
1126
|
+
id: requestId,
|
|
1127
|
+
payload
|
|
1128
|
+
};
|
|
1129
|
+
const responseHandler = (message) => {
|
|
1130
|
+
if (typeof message === "object" && message !== null && "type" in message && message.type === "response" && "id" in message && message.id === requestId) {
|
|
1131
|
+
process.off("message", responseHandler);
|
|
1132
|
+
const response = message;
|
|
1133
|
+
if (response.success) resolve$1(response.data);
|
|
1134
|
+
else reject(new Error(response.error));
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
process.on("message", responseHandler);
|
|
1138
|
+
if (!process.send(request)) {
|
|
1139
|
+
process.off("message", responseHandler);
|
|
1140
|
+
reject(/* @__PURE__ */ new Error("Failed to send IPC message"));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
setTimeout(() => {
|
|
1144
|
+
process.off("message", responseHandler);
|
|
1145
|
+
reject(/* @__PURE__ */ new Error(`IPC request timeout: ${type}`));
|
|
1146
|
+
}, TIMEOUTS.IPC_LONG_REQUEST);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/ui/components/confirm.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* Simplified confirm function
|
|
1154
|
+
*
|
|
1155
|
+
* @example
|
|
1156
|
+
* ```typescript
|
|
1157
|
+
* const proceed = await confirm("Continue?", true);
|
|
1158
|
+
* ```
|
|
1159
|
+
*/
|
|
1160
|
+
async function confirm(message, defaultValue = false) {
|
|
1161
|
+
if (isSandbox()) return sendIPCRequest("prompt:confirm", {
|
|
1162
|
+
message,
|
|
1163
|
+
defaultValue
|
|
1164
|
+
});
|
|
1165
|
+
if (!isTTY()) {
|
|
1166
|
+
if (isMCP()) console.warn(`[MCP Warning] Interactive confirmation not available. Using default value (${defaultValue}) for: ${message}`);
|
|
1167
|
+
return defaultValue;
|
|
1168
|
+
}
|
|
1169
|
+
const result = await clack.confirm({
|
|
1170
|
+
message,
|
|
1171
|
+
initialValue: defaultValue
|
|
1172
|
+
});
|
|
1173
|
+
if (clack.isCancel(result)) {
|
|
1174
|
+
clack.cancel("Operation cancelled");
|
|
1175
|
+
process.exit(0);
|
|
1176
|
+
}
|
|
1177
|
+
return result;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/ui/components/select.ts
|
|
1182
|
+
/**
|
|
1183
|
+
* Show a selection menu and wait for user choice
|
|
1184
|
+
*
|
|
1185
|
+
* @example
|
|
1186
|
+
* ```typescript
|
|
1187
|
+
* const color = await select({
|
|
1188
|
+
* options: [
|
|
1189
|
+
* { name: "Red", value: "red" },
|
|
1190
|
+
* { name: "Blue", value: "blue", description: "Ocean color" },
|
|
1191
|
+
* ],
|
|
1192
|
+
* prompt: "Pick a color"
|
|
1193
|
+
* });
|
|
1194
|
+
* ```
|
|
1195
|
+
*/
|
|
1196
|
+
async function select(config) {
|
|
1197
|
+
if (isSandbox()) return sendIPCRequest("prompt:select", {
|
|
1198
|
+
prompt: config.prompt ?? "Select an option",
|
|
1199
|
+
options: config.options
|
|
1200
|
+
});
|
|
1201
|
+
if (!isTTY()) {
|
|
1202
|
+
if (isMCP()) throw new Error(`Interactive selection not available in MCP mode. All parameters must be defined in the tool's inputSchema. Selection prompt: ${config.prompt}`);
|
|
1203
|
+
const firstOption = config.options[0];
|
|
1204
|
+
if (!firstOption) throw new Error("No options provided");
|
|
1205
|
+
return firstOption.value;
|
|
1206
|
+
}
|
|
1207
|
+
const mappedOptions = config.options.map((opt) => ({
|
|
1208
|
+
label: opt.name,
|
|
1209
|
+
value: opt.value,
|
|
1210
|
+
...opt.description && { hint: opt.description }
|
|
1211
|
+
}));
|
|
1212
|
+
const result = await clack.select({
|
|
1213
|
+
message: config.prompt ?? "Select an option",
|
|
1214
|
+
options: mappedOptions
|
|
1215
|
+
});
|
|
1216
|
+
if (clack.isCancel(result)) {
|
|
1217
|
+
clack.cancel("Operation cancelled");
|
|
1218
|
+
process.exit(0);
|
|
1219
|
+
}
|
|
1220
|
+
return result;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/ui/utils/colors.ts
|
|
1225
|
+
/**
|
|
1226
|
+
* Format text with picocolors
|
|
1227
|
+
*/
|
|
1228
|
+
function formatText(text, options) {
|
|
1229
|
+
let result = text;
|
|
1230
|
+
if (options?.color) switch (options.color) {
|
|
1231
|
+
case "red":
|
|
1232
|
+
result = pc$1.red(result);
|
|
1233
|
+
break;
|
|
1234
|
+
case "green":
|
|
1235
|
+
result = pc$1.green(result);
|
|
1236
|
+
break;
|
|
1237
|
+
case "yellow":
|
|
1238
|
+
result = pc$1.yellow(result);
|
|
1239
|
+
break;
|
|
1240
|
+
case "blue":
|
|
1241
|
+
result = pc$1.blue(result);
|
|
1242
|
+
break;
|
|
1243
|
+
case "magenta":
|
|
1244
|
+
result = pc$1.magenta(result);
|
|
1245
|
+
break;
|
|
1246
|
+
case "cyan":
|
|
1247
|
+
result = pc$1.cyan(result);
|
|
1248
|
+
break;
|
|
1249
|
+
case "white":
|
|
1250
|
+
result = pc$1.white(result);
|
|
1251
|
+
break;
|
|
1252
|
+
case "gray":
|
|
1253
|
+
result = pc$1.gray(result);
|
|
1254
|
+
break;
|
|
1255
|
+
}
|
|
1256
|
+
if (options?.bold) result = pc$1.bold(result);
|
|
1257
|
+
if (options?.dim) result = pc$1.dim(result);
|
|
1258
|
+
if (options?.italic) result = pc$1.italic(result);
|
|
1259
|
+
if (options?.underline) result = pc$1.underline(result);
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
//#endregion
|
|
1264
|
+
//#region src/ui/components/table.ts
|
|
1265
|
+
/**
|
|
1266
|
+
* Align text within a given width
|
|
1267
|
+
*/
|
|
1268
|
+
function alignText(text, width, align) {
|
|
1269
|
+
const textLength = stripAnsi(text).length;
|
|
1270
|
+
const padding = Math.max(0, width - textLength);
|
|
1271
|
+
switch (align) {
|
|
1272
|
+
case "right": return " ".repeat(padding) + text;
|
|
1273
|
+
case "center": {
|
|
1274
|
+
const leftPad = Math.floor(padding / 2);
|
|
1275
|
+
const rightPad = padding - leftPad;
|
|
1276
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
|
|
1277
|
+
}
|
|
1278
|
+
default: return text + " ".repeat(padding);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Strip ANSI escape codes from string for length calculation
|
|
1283
|
+
*/
|
|
1284
|
+
function stripAnsi(str) {
|
|
1285
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Calculate column widths based on content
|
|
1289
|
+
*/
|
|
1290
|
+
function calculateColumnWidths(columns, rows, showHeader) {
|
|
1291
|
+
return columns.map((col) => {
|
|
1292
|
+
if (col.width !== void 0) return col.width;
|
|
1293
|
+
let maxWidth = showHeader ? col.header.length : 0;
|
|
1294
|
+
for (const row of rows) {
|
|
1295
|
+
const value = row[col.key];
|
|
1296
|
+
const length = stripAnsi(col.formatter ? col.formatter(value, row) : String(value ?? "")).length;
|
|
1297
|
+
maxWidth = Math.max(maxWidth, length);
|
|
1298
|
+
}
|
|
1299
|
+
return maxWidth;
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Format a single cell value
|
|
1304
|
+
*/
|
|
1305
|
+
function formatCell(value, row, column) {
|
|
1306
|
+
if (column.formatter) return column.formatter(value, row);
|
|
1307
|
+
if (value === null || value === void 0) return pc$1.dim("-");
|
|
1308
|
+
return String(value);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Render table in TTY mode with borders and styling
|
|
1312
|
+
*/
|
|
1313
|
+
function renderTTY(config) {
|
|
1314
|
+
const { columns, rows, showHeader = true, showBorders = true, title } = config;
|
|
1315
|
+
const lines = [];
|
|
1316
|
+
const widths = calculateColumnWidths(columns, rows, showHeader);
|
|
1317
|
+
if (title) {
|
|
1318
|
+
lines.push("");
|
|
1319
|
+
lines.push(formatText(`${title}`, { bold: true }));
|
|
1320
|
+
lines.push("");
|
|
1321
|
+
}
|
|
1322
|
+
if (showHeader) {
|
|
1323
|
+
const headerCells = columns.map((col, i) => {
|
|
1324
|
+
return alignText(formatText(col.header, {
|
|
1325
|
+
bold: true,
|
|
1326
|
+
color: "cyan"
|
|
1327
|
+
}), widths[i], col.align ?? "left");
|
|
1328
|
+
});
|
|
1329
|
+
lines.push(headerCells.join(" "));
|
|
1330
|
+
if (showBorders) {
|
|
1331
|
+
const separatorParts = widths.map((w) => "ā".repeat(w));
|
|
1332
|
+
lines.push(pc$1.gray(separatorParts.join("ā")));
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
for (const row of rows) {
|
|
1336
|
+
const cells = columns.map((col, i) => {
|
|
1337
|
+
const value = row[col.key];
|
|
1338
|
+
return alignText(formatCell(value, row, col), widths[i], col.align ?? "left");
|
|
1339
|
+
});
|
|
1340
|
+
lines.push(cells.join(" "));
|
|
1341
|
+
}
|
|
1342
|
+
if (showBorders && rows.length > 0) {
|
|
1343
|
+
const separatorParts = widths.map((w) => "ā".repeat(w));
|
|
1344
|
+
lines.push(pc$1.gray(separatorParts.join("ā")));
|
|
1345
|
+
}
|
|
1346
|
+
return lines.join("\n");
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Render table in non-TTY mode (plain text)
|
|
1350
|
+
*/
|
|
1351
|
+
function renderPlain(config) {
|
|
1352
|
+
const { columns, rows, showHeader = true, title } = config;
|
|
1353
|
+
const lines = [];
|
|
1354
|
+
const widths = calculateColumnWidths(columns, rows, showHeader);
|
|
1355
|
+
if (title) {
|
|
1356
|
+
lines.push("");
|
|
1357
|
+
lines.push(title);
|
|
1358
|
+
lines.push("");
|
|
1359
|
+
}
|
|
1360
|
+
if (showHeader) {
|
|
1361
|
+
const headerCells = columns.map((col, i) => alignText(col.header, widths[i], col.align ?? "left"));
|
|
1362
|
+
lines.push(headerCells.join(" "));
|
|
1363
|
+
const separator = columns.map((_, i) => "-".repeat(widths[i])).join(" ");
|
|
1364
|
+
lines.push(separator);
|
|
1365
|
+
}
|
|
1366
|
+
for (const row of rows) {
|
|
1367
|
+
const cells = columns.map((col, i) => {
|
|
1368
|
+
const value = row[col.key];
|
|
1369
|
+
return alignText(stripAnsi(formatCell(value, row, col)), widths[i], col.align ?? "left");
|
|
1370
|
+
});
|
|
1371
|
+
lines.push(cells.join(" "));
|
|
1372
|
+
}
|
|
1373
|
+
return lines.join("\n");
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Display a table with columns and rows
|
|
1377
|
+
*
|
|
1378
|
+
* @example
|
|
1379
|
+
* ```typescript
|
|
1380
|
+
* table({
|
|
1381
|
+
* title: "Users",
|
|
1382
|
+
* columns: [
|
|
1383
|
+
* { key: "name", header: "Name" },
|
|
1384
|
+
* { key: "age", header: "Age", align: "right" },
|
|
1385
|
+
* { key: "status", header: "Status", formatter: (val) =>
|
|
1386
|
+
* val === "active" ? pc.green("ā Active") : pc.red("ā Inactive")
|
|
1387
|
+
* },
|
|
1388
|
+
* ],
|
|
1389
|
+
* rows: [
|
|
1390
|
+
* { name: "Alice", age: 25, status: "active" },
|
|
1391
|
+
* { name: "Bob", age: 30, status: "inactive" },
|
|
1392
|
+
* ],
|
|
1393
|
+
* });
|
|
1394
|
+
* ```
|
|
1395
|
+
*/
|
|
1396
|
+
function table(config) {
|
|
1397
|
+
const output$1 = isTTY() ? renderTTY(config) : renderPlain(config);
|
|
1398
|
+
console.log(output$1);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
//#endregion
|
|
1402
|
+
//#region src/ui/utils/output.ts
|
|
1403
|
+
/**
|
|
1404
|
+
* Output a result to the console
|
|
1405
|
+
*
|
|
1406
|
+
* @param result - The result to display (string, object, etc.)
|
|
1407
|
+
*/
|
|
1408
|
+
function output(result) {
|
|
1409
|
+
if (result === void 0 || result === null) return;
|
|
1410
|
+
if (typeof result === "string") console.log(result);
|
|
1411
|
+
else console.log(JSON.stringify(result, null, 2));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
//#endregion
|
|
1415
|
+
//#region src/permissions/index.ts
|
|
1416
|
+
const CONFIG_DIR = join(homedir(), PATHS.CONFIG_DIR);
|
|
1417
|
+
const PERMISSIONS_FILE = join(CONFIG_DIR, PATHS.PERMISSIONS_FILE);
|
|
1418
|
+
/**
|
|
1419
|
+
* Get app identifier from script path
|
|
1420
|
+
*/
|
|
1421
|
+
function getAppIdentifier() {
|
|
1422
|
+
const localRef = getLocalRef();
|
|
1423
|
+
if (localRef) return localRef;
|
|
1424
|
+
const remoteRef = getRemoteRef();
|
|
1425
|
+
if (remoteRef) return remoteRef;
|
|
1426
|
+
const scriptPath = process.argv[1] ?? "";
|
|
1427
|
+
if (scriptPath.startsWith("/") || scriptPath.startsWith("C:\\")) return `local:${scriptPath}`;
|
|
1428
|
+
return scriptPath || "unknown";
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Get friendly app name for display
|
|
1432
|
+
*/
|
|
1433
|
+
function getAppName(appId) {
|
|
1434
|
+
if (appId.startsWith("local:")) {
|
|
1435
|
+
const path = appId.slice(6);
|
|
1436
|
+
const parts = path.split("/");
|
|
1437
|
+
return parts[parts.length - 1] || path;
|
|
1438
|
+
}
|
|
1439
|
+
if (appId.startsWith("github.com/")) return appId.split("/").slice(1, 3).join("/");
|
|
1440
|
+
return appId;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Ensure permissions config directory exists
|
|
1444
|
+
*/
|
|
1445
|
+
function ensurePermissionsDir() {
|
|
1446
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Load permissions configuration
|
|
1450
|
+
*/
|
|
1451
|
+
function loadPermissions() {
|
|
1452
|
+
ensurePermissionsDir();
|
|
1453
|
+
if (!existsSync(PERMISSIONS_FILE)) return { trustedApps: {} };
|
|
1454
|
+
try {
|
|
1455
|
+
const content = readFileSync(PERMISSIONS_FILE, "utf-8");
|
|
1456
|
+
return JSON.parse(content);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
console.error("Failed to parse permissions file:", error);
|
|
1459
|
+
return { trustedApps: {} };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Save permissions configuration
|
|
1464
|
+
*/
|
|
1465
|
+
function savePermissions(config) {
|
|
1466
|
+
ensurePermissionsDir();
|
|
1467
|
+
writeFileSync(PERMISSIONS_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Request permission from user with interactive prompt
|
|
1471
|
+
*/
|
|
1472
|
+
async function requestPermission(appId, appName) {
|
|
1473
|
+
if (!isTTY()) {
|
|
1474
|
+
console.error(`\nPermission required: App "${appName}" (${appId}) wants to access your API keys.`);
|
|
1475
|
+
console.error("Set KLY_TRUST_ALL=true environment variable to grant access in non-interactive mode.");
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
console.log("");
|
|
1479
|
+
console.log(`App "${appName}" is requesting access to your API keys.`);
|
|
1480
|
+
console.log(`Source: ${appId}`);
|
|
1481
|
+
console.log("");
|
|
1482
|
+
console.log("This will allow the app to use your configured LLM models.");
|
|
1483
|
+
console.log("");
|
|
1484
|
+
const choice = await select({
|
|
1485
|
+
prompt: "Do you want to allow this?",
|
|
1486
|
+
options: [
|
|
1487
|
+
{
|
|
1488
|
+
name: "Allow once",
|
|
1489
|
+
value: "once",
|
|
1490
|
+
description: "Allow for this session only"
|
|
1491
|
+
},
|
|
1492
|
+
{
|
|
1493
|
+
name: "Always allow",
|
|
1494
|
+
value: "always",
|
|
1495
|
+
description: "Remember this choice for future runs"
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
name: "Cancel",
|
|
1499
|
+
value: "cancel",
|
|
1500
|
+
description: "Cancel and exit"
|
|
1501
|
+
}
|
|
1502
|
+
]
|
|
1503
|
+
});
|
|
1504
|
+
if (choice === "cancel") return false;
|
|
1505
|
+
if (choice === "always") {
|
|
1506
|
+
const config = loadPermissions();
|
|
1507
|
+
config.trustedApps[appId] = {
|
|
1508
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1509
|
+
choice: "always"
|
|
1510
|
+
};
|
|
1511
|
+
savePermissions(config);
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Check if an app has permission to access API keys
|
|
1518
|
+
* If not, prompt user for permission (in interactive mode)
|
|
1519
|
+
*/
|
|
1520
|
+
async function checkApiKeyPermission(appId) {
|
|
1521
|
+
if (isTrustAll()) return true;
|
|
1522
|
+
const record = loadPermissions().trustedApps[appId];
|
|
1523
|
+
if (record && record.choice === "always") return true;
|
|
1524
|
+
return await requestPermission(appId, getAppName(appId));
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Revoke permission for an app
|
|
1528
|
+
*/
|
|
1529
|
+
function revokePermission(appId) {
|
|
1530
|
+
const config = loadPermissions();
|
|
1531
|
+
delete config.trustedApps[appId];
|
|
1532
|
+
savePermissions(config);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* List all granted permissions
|
|
1536
|
+
* Only "always allow" permissions are stored
|
|
1537
|
+
*/
|
|
1538
|
+
function listPermissions() {
|
|
1539
|
+
const config = loadPermissions();
|
|
1540
|
+
return Object.entries(config.trustedApps).map(([appId, record]) => ({
|
|
1541
|
+
appId,
|
|
1542
|
+
appName: getAppName(appId),
|
|
1543
|
+
timestamp: record.timestamp,
|
|
1544
|
+
choice: record.choice
|
|
1545
|
+
}));
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Request sandbox configuration from user interactively
|
|
1549
|
+
* Returns SandboxRuntimeConfig directly (no conversion needed)
|
|
1550
|
+
*/
|
|
1551
|
+
async function requestSandboxConfig(appId, appName) {
|
|
1552
|
+
if (!isTTY()) {
|
|
1553
|
+
console.error(`\nSandbox permission required for: "${appName}" (${appId})`);
|
|
1554
|
+
console.error("Set KLY_TRUST_ALL=true environment variable to run without sandboxing in non-interactive mode.");
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
const homeDir = homedir();
|
|
1558
|
+
const currentDir = process.cwd();
|
|
1559
|
+
console.log("");
|
|
1560
|
+
console.log(`š Sandbox Permission Request from: ${appName}`);
|
|
1561
|
+
console.log("");
|
|
1562
|
+
console.log("š Filesystem Read Access:");
|
|
1563
|
+
const fsReadChoice = await select({
|
|
1564
|
+
prompt: "Which files should be denied for reading?",
|
|
1565
|
+
options: [
|
|
1566
|
+
{
|
|
1567
|
+
name: "Sensitive only",
|
|
1568
|
+
value: "sensitive",
|
|
1569
|
+
description: "Deny access to ~/.kly, ~/.ssh, ~/.aws, etc."
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
name: "All home directory",
|
|
1573
|
+
value: "all-home",
|
|
1574
|
+
description: "Deny access to entire home directory"
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
name: "None (allow all)",
|
|
1578
|
+
value: "none",
|
|
1579
|
+
description: "No read restrictions (except ~/.kly)"
|
|
1580
|
+
}
|
|
1581
|
+
]
|
|
1582
|
+
});
|
|
1583
|
+
let denyRead = [join(homeDir, ".kly")];
|
|
1584
|
+
if (fsReadChoice === "sensitive") denyRead = [
|
|
1585
|
+
join(homeDir, ".kly"),
|
|
1586
|
+
join(homeDir, ".ssh"),
|
|
1587
|
+
join(homeDir, ".aws"),
|
|
1588
|
+
join(homeDir, ".gnupg")
|
|
1589
|
+
];
|
|
1590
|
+
else if (fsReadChoice === "all-home") denyRead = [homeDir];
|
|
1591
|
+
console.log("");
|
|
1592
|
+
console.log("š Filesystem Write Access:");
|
|
1593
|
+
const fsWriteChoice = await select({
|
|
1594
|
+
prompt: "Which directories should be allowed for writing?",
|
|
1595
|
+
options: [
|
|
1596
|
+
{
|
|
1597
|
+
name: "None",
|
|
1598
|
+
value: "none",
|
|
1599
|
+
description: "No write access"
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: "Current directory only",
|
|
1603
|
+
value: "current",
|
|
1604
|
+
description: `Allow write to ${currentDir}`
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
name: "Temporary directory",
|
|
1608
|
+
value: "temp",
|
|
1609
|
+
description: "Allow write to system temp directory"
|
|
1610
|
+
}
|
|
1611
|
+
]
|
|
1612
|
+
});
|
|
1613
|
+
let allowWrite = [];
|
|
1614
|
+
if (fsWriteChoice === "current") allowWrite = [currentDir];
|
|
1615
|
+
else if (fsWriteChoice === "temp") allowWrite = [process.env.TMPDIR || process.env.TEMP || "/tmp"];
|
|
1616
|
+
const denyWrite = [
|
|
1617
|
+
join(homeDir, ".kly"),
|
|
1618
|
+
join(homeDir, ".ssh"),
|
|
1619
|
+
join(homeDir, ".aws"),
|
|
1620
|
+
join(homeDir, ".gnupg")
|
|
1621
|
+
];
|
|
1622
|
+
console.log("");
|
|
1623
|
+
console.log("š Network Access:");
|
|
1624
|
+
const networkChoice = await select({
|
|
1625
|
+
prompt: "Which network access should be allowed?",
|
|
1626
|
+
options: [
|
|
1627
|
+
{
|
|
1628
|
+
name: "None",
|
|
1629
|
+
value: "none",
|
|
1630
|
+
description: "No network access"
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
name: "LLM APIs only",
|
|
1634
|
+
value: "llm-apis",
|
|
1635
|
+
description: "OpenAI, Anthropic, Google AI"
|
|
1636
|
+
},
|
|
1637
|
+
{
|
|
1638
|
+
name: "Common APIs",
|
|
1639
|
+
value: "common",
|
|
1640
|
+
description: "LLM + GitHub, npm, etc."
|
|
1641
|
+
},
|
|
1642
|
+
{
|
|
1643
|
+
name: "All domains",
|
|
1644
|
+
value: "all",
|
|
1645
|
+
description: "Allow all network access"
|
|
1646
|
+
}
|
|
1647
|
+
]
|
|
1648
|
+
});
|
|
1649
|
+
let allowedDomains = [];
|
|
1650
|
+
if (networkChoice === "llm-apis") allowedDomains = [
|
|
1651
|
+
"api.openai.com",
|
|
1652
|
+
"*.anthropic.com",
|
|
1653
|
+
"generativelanguage.googleapis.com"
|
|
1654
|
+
];
|
|
1655
|
+
else if (networkChoice === "common") allowedDomains = [
|
|
1656
|
+
"api.openai.com",
|
|
1657
|
+
"*.anthropic.com",
|
|
1658
|
+
"generativelanguage.googleapis.com",
|
|
1659
|
+
"*.github.com",
|
|
1660
|
+
"registry.npmjs.org"
|
|
1661
|
+
];
|
|
1662
|
+
else if (networkChoice === "all") allowedDomains = ["*"];
|
|
1663
|
+
console.log("");
|
|
1664
|
+
const duration = await select({
|
|
1665
|
+
prompt: "How long should these permissions last?",
|
|
1666
|
+
options: [
|
|
1667
|
+
{
|
|
1668
|
+
name: "One time only",
|
|
1669
|
+
value: "once",
|
|
1670
|
+
description: "Ask again next time"
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
name: "Always allow",
|
|
1674
|
+
value: "always",
|
|
1675
|
+
description: "Remember for this app"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
name: "Cancel",
|
|
1679
|
+
value: "cancel",
|
|
1680
|
+
description: "Cancel and exit"
|
|
1681
|
+
}
|
|
1682
|
+
]
|
|
1683
|
+
});
|
|
1684
|
+
if (duration === "cancel") return null;
|
|
1685
|
+
const sandboxConfig = {
|
|
1686
|
+
network: {
|
|
1687
|
+
allowedDomains,
|
|
1688
|
+
deniedDomains: []
|
|
1689
|
+
},
|
|
1690
|
+
filesystem: {
|
|
1691
|
+
denyRead,
|
|
1692
|
+
allowWrite,
|
|
1693
|
+
denyWrite
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
if (duration === "always") {
|
|
1697
|
+
const config = loadPermissions();
|
|
1698
|
+
config.trustedApps[appId] = {
|
|
1699
|
+
sandboxConfig,
|
|
1700
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1701
|
+
choice: "always"
|
|
1702
|
+
};
|
|
1703
|
+
savePermissions(config);
|
|
1704
|
+
}
|
|
1705
|
+
console.log("");
|
|
1706
|
+
console.log("ā
Sandbox permissions granted!");
|
|
1707
|
+
return sandboxConfig;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Get sandbox configuration for an app
|
|
1711
|
+
* Returns SandboxRuntimeConfig directly (no conversion needed)
|
|
1712
|
+
*
|
|
1713
|
+
* @param appId - App identifier
|
|
1714
|
+
* @returns SandboxRuntimeConfig or null if denied
|
|
1715
|
+
*/
|
|
1716
|
+
async function getAppSandboxConfig(appId) {
|
|
1717
|
+
const homeDir = homedir();
|
|
1718
|
+
if (isTrustAll()) return {
|
|
1719
|
+
network: {
|
|
1720
|
+
allowedDomains: ["*"],
|
|
1721
|
+
deniedDomains: []
|
|
1722
|
+
},
|
|
1723
|
+
filesystem: {
|
|
1724
|
+
denyRead: [join(homeDir, ".kly")],
|
|
1725
|
+
allowWrite: ["*"],
|
|
1726
|
+
denyWrite: [
|
|
1727
|
+
join(homeDir, ".kly"),
|
|
1728
|
+
join(homeDir, ".ssh"),
|
|
1729
|
+
join(homeDir, ".aws"),
|
|
1730
|
+
join(homeDir, ".gnupg")
|
|
1731
|
+
]
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
const record = loadPermissions().trustedApps[appId];
|
|
1735
|
+
if (record?.choice === "always" && record.sandboxConfig) return record.sandboxConfig;
|
|
1736
|
+
return await requestSandboxConfig(appId, getAppName(appId));
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Clear all permissions
|
|
1740
|
+
*/
|
|
1741
|
+
function clearAllPermissions() {
|
|
1742
|
+
savePermissions({ trustedApps: {} });
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
//#endregion
|
|
1746
|
+
//#region src/permissions/cli.ts
|
|
1747
|
+
/**
|
|
1748
|
+
* Permissions management CLI
|
|
1749
|
+
*/
|
|
1750
|
+
async function permissionsCommand() {
|
|
1751
|
+
switch (await select({
|
|
1752
|
+
prompt: "Permissions Management",
|
|
1753
|
+
options: [
|
|
1754
|
+
{
|
|
1755
|
+
name: "List permissions",
|
|
1756
|
+
value: "list",
|
|
1757
|
+
description: "View all granted permissions"
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
name: "Revoke permission",
|
|
1761
|
+
value: "revoke",
|
|
1762
|
+
description: "Remove permission for a specific app"
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
name: "Clear all",
|
|
1766
|
+
value: "clear",
|
|
1767
|
+
description: "Remove all permissions"
|
|
1768
|
+
}
|
|
1769
|
+
]
|
|
1770
|
+
})) {
|
|
1771
|
+
case "list":
|
|
1772
|
+
await listPermissionsAction();
|
|
1773
|
+
break;
|
|
1774
|
+
case "revoke":
|
|
1775
|
+
await revokePermissionAction();
|
|
1776
|
+
break;
|
|
1777
|
+
case "clear":
|
|
1778
|
+
await clearAllPermissionsAction();
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* List all permissions
|
|
1784
|
+
*/
|
|
1785
|
+
async function listPermissionsAction() {
|
|
1786
|
+
const permissions = listPermissions();
|
|
1787
|
+
if (permissions.length === 0) {
|
|
1788
|
+
output("\nNo permissions granted yet.\n");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
output("\nš Granted Permissions:\n");
|
|
1792
|
+
table({
|
|
1793
|
+
columns: [{
|
|
1794
|
+
key: "app",
|
|
1795
|
+
header: "App"
|
|
1796
|
+
}, {
|
|
1797
|
+
key: "grantedAt",
|
|
1798
|
+
header: "Granted At"
|
|
1799
|
+
}],
|
|
1800
|
+
rows: permissions.map((p) => ({
|
|
1801
|
+
app: p.appName,
|
|
1802
|
+
grantedAt: new Date(p.timestamp).toLocaleString()
|
|
1803
|
+
}))
|
|
1804
|
+
});
|
|
1805
|
+
output("");
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Revoke permission for a specific app
|
|
1809
|
+
*/
|
|
1810
|
+
async function revokePermissionAction() {
|
|
1811
|
+
const permissions = listPermissions();
|
|
1812
|
+
if (permissions.length === 0) {
|
|
1813
|
+
output("\nNo permissions to revoke.\n");
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
const appId = await select({
|
|
1817
|
+
prompt: "Select app to revoke permission:",
|
|
1818
|
+
options: permissions.map((p) => ({
|
|
1819
|
+
name: p.appName,
|
|
1820
|
+
value: p.appId,
|
|
1821
|
+
description: "Always allowed"
|
|
1822
|
+
}))
|
|
1823
|
+
});
|
|
1824
|
+
if (await confirm("Are you sure you want to revoke this permission?")) {
|
|
1825
|
+
revokePermission(appId);
|
|
1826
|
+
output("\nā
Permission revoked.\n");
|
|
1827
|
+
} else output("\nā Cancelled.\n");
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Clear all permissions
|
|
1831
|
+
*/
|
|
1832
|
+
async function clearAllPermissionsAction() {
|
|
1833
|
+
if (await confirm("Are you sure you want to clear ALL permissions?")) {
|
|
1834
|
+
clearAllPermissions();
|
|
1835
|
+
output("\nā
All permissions cleared.\n");
|
|
1836
|
+
} else output("\nā Cancelled.\n");
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/permissions/config-builder.ts
|
|
1841
|
+
/**
|
|
1842
|
+
* Always protected paths (never allow write, some deny read)
|
|
1843
|
+
*/
|
|
1844
|
+
const PROTECTED_PATHS = {
|
|
1845
|
+
alwaysDenyWrite: [
|
|
1846
|
+
join(homedir(), ".kly"),
|
|
1847
|
+
join(homedir(), ".ssh"),
|
|
1848
|
+
join(homedir(), ".aws"),
|
|
1849
|
+
join(homedir(), ".gnupg")
|
|
1850
|
+
],
|
|
1851
|
+
alwaysDenyRead: [join(homedir(), ".kly")]
|
|
1852
|
+
};
|
|
1853
|
+
/**
|
|
1854
|
+
* Build a complete SandboxRuntimeConfig from declared app permissions
|
|
1855
|
+
*
|
|
1856
|
+
* This merges:
|
|
1857
|
+
* 1. Default safe configuration
|
|
1858
|
+
* 2. Automatic LLM domains (if apiKeys: true)
|
|
1859
|
+
* 3. User-declared sandbox config
|
|
1860
|
+
* 4. Mandatory protections (always applied)
|
|
1861
|
+
*
|
|
1862
|
+
* @param permissions - Declared app permissions
|
|
1863
|
+
* @returns Complete sandbox configuration ready for SandboxManager
|
|
1864
|
+
*/
|
|
1865
|
+
function buildSandboxConfig(permissions) {
|
|
1866
|
+
const currentDir = process.cwd();
|
|
1867
|
+
let allowedDomains = [];
|
|
1868
|
+
let allowWrite = [currentDir];
|
|
1869
|
+
let denyRead = [...PROTECTED_PATHS.alwaysDenyRead];
|
|
1870
|
+
if (permissions?.apiKeys) allowedDomains = [...LLM_API_DOMAINS];
|
|
1871
|
+
if (permissions?.sandbox) {
|
|
1872
|
+
const userSandbox = permissions.sandbox;
|
|
1873
|
+
if (userSandbox.network?.allowedDomains) allowedDomains = [...allowedDomains, ...userSandbox.network.allowedDomains];
|
|
1874
|
+
if (userSandbox.filesystem) {
|
|
1875
|
+
if (userSandbox.filesystem.allowWrite) allowWrite = userSandbox.filesystem.allowWrite;
|
|
1876
|
+
if (userSandbox.filesystem.denyRead) denyRead = [...denyRead, ...userSandbox.filesystem.denyRead];
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return {
|
|
1880
|
+
network: {
|
|
1881
|
+
allowedDomains,
|
|
1882
|
+
deniedDomains: []
|
|
1883
|
+
},
|
|
1884
|
+
filesystem: {
|
|
1885
|
+
denyRead,
|
|
1886
|
+
allowWrite,
|
|
1887
|
+
denyWrite: PROTECTED_PATHS.alwaysDenyWrite
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Get a human-readable summary of permissions for display
|
|
1893
|
+
* Only shows special/non-default permissions
|
|
1894
|
+
*/
|
|
1895
|
+
function formatPermissionsSummary(permissions) {
|
|
1896
|
+
const summary = [];
|
|
1897
|
+
if (permissions?.apiKeys) summary.push("⢠API Keys access (to call LLM APIs)");
|
|
1898
|
+
const config = buildSandboxConfig(permissions);
|
|
1899
|
+
const currentDir = process.cwd();
|
|
1900
|
+
if (config.network.allowedDomains.length > 0) if (config.network.allowedDomains.includes("*")) summary.push("⢠Network: All domains");
|
|
1901
|
+
else {
|
|
1902
|
+
const domains = config.network.allowedDomains.slice(0, 3).join(", ");
|
|
1903
|
+
const more = config.network.allowedDomains.length > 3 ? ` +${config.network.allowedDomains.length - 3} more` : "";
|
|
1904
|
+
summary.push(`⢠Network: ${domains}${more}`);
|
|
1905
|
+
}
|
|
1906
|
+
if (config.filesystem.allowWrite.length > 1 || config.filesystem.allowWrite.length === 1 && config.filesystem.allowWrite[0] !== currentDir) {
|
|
1907
|
+
const dirs = config.filesystem.allowWrite.map((p) => p === currentDir ? "current directory" : p).slice(0, 2).join(", ");
|
|
1908
|
+
const more = config.filesystem.allowWrite.length > 2 ? ` +${config.filesystem.allowWrite.length - 2} more` : "";
|
|
1909
|
+
summary.push(`⢠Filesystem write: ${dirs}${more}`);
|
|
1910
|
+
}
|
|
1911
|
+
if (permissions?.sandbox?.filesystem?.denyRead) summary.push(`⢠Filesystem read denied: ${permissions.sandbox.filesystem.denyRead.length} path(s)`);
|
|
1912
|
+
return summary;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
//#endregion
|
|
1916
|
+
//#region src/permissions/extract.ts
|
|
1917
|
+
/**
|
|
1918
|
+
* Extract permissions from a user's app script
|
|
1919
|
+
* This runs in the host process BEFORE launching the sandbox
|
|
1920
|
+
*
|
|
1921
|
+
* @param scriptPath - Absolute path to the user's script
|
|
1922
|
+
* @returns Declared permissions or undefined if not specified
|
|
1923
|
+
*/
|
|
1924
|
+
async function extractAppPermissions(scriptPath) {
|
|
1925
|
+
try {
|
|
1926
|
+
const prevMode = process.env[ENV_VARS.PROGRAMMATIC];
|
|
1927
|
+
process.env[ENV_VARS.PROGRAMMATIC] = "true";
|
|
1928
|
+
const module = await import(scriptPath);
|
|
1929
|
+
if (prevMode === void 0) delete process.env[ENV_VARS.PROGRAMMATIC];
|
|
1930
|
+
else process.env[ENV_VARS.PROGRAMMATIC] = prevMode;
|
|
1931
|
+
const app = module.default;
|
|
1932
|
+
if (!app || !app.definition) return;
|
|
1933
|
+
return app.definition.permissions;
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
console.warn(`Warning: Could not extract permissions from ${scriptPath}:`, error instanceof Error ? error.message : String(error));
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
//#endregion
|
|
1941
|
+
//#region src/permissions/unified-prompt.ts
|
|
1942
|
+
/**
|
|
1943
|
+
* Check if permissions require user prompt
|
|
1944
|
+
* Only prompt for special permissions (API keys, custom network/filesystem)
|
|
1945
|
+
* Default permissions (current directory write, no network) are auto-granted
|
|
1946
|
+
*/
|
|
1947
|
+
function needsPermissionPrompt(permissions, sandboxConfig) {
|
|
1948
|
+
if (permissions?.apiKeys) return true;
|
|
1949
|
+
if (sandboxConfig.network.allowedDomains.length > 0) return true;
|
|
1950
|
+
if (permissions?.sandbox?.filesystem) return true;
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Request permission with a single unified prompt
|
|
1955
|
+
* Shows all requested permissions at once
|
|
1956
|
+
*
|
|
1957
|
+
* @param appId - App identifier
|
|
1958
|
+
* @param appPermissions - Declared permissions from app
|
|
1959
|
+
* @param sandboxConfig - Generated sandbox configuration
|
|
1960
|
+
* @returns true if allowed, false if cancelled
|
|
1961
|
+
*/
|
|
1962
|
+
async function requestUnifiedPermission(appId, appPermissions, sandboxConfig) {
|
|
1963
|
+
if (isTrustAll()) return true;
|
|
1964
|
+
const config = loadPermissions();
|
|
1965
|
+
const record = config.trustedApps[appId];
|
|
1966
|
+
if (record && record.choice === "always") return true;
|
|
1967
|
+
if (!needsPermissionPrompt(appPermissions, sandboxConfig)) return true;
|
|
1968
|
+
if (!isTTY()) {
|
|
1969
|
+
const appName$1 = getAppName(appId);
|
|
1970
|
+
console.error(`\nPermission required: App "${appName$1}" (${appId}) requests permissions.`);
|
|
1971
|
+
console.error("Set KLY_TRUST_ALL=true environment variable to grant access in non-interactive mode.");
|
|
1972
|
+
return false;
|
|
1973
|
+
}
|
|
1974
|
+
const appName = getAppName(appId);
|
|
1975
|
+
console.log("");
|
|
1976
|
+
console.log(`App "${appName}" requests the following permissions:`);
|
|
1977
|
+
console.log("");
|
|
1978
|
+
const summary = formatPermissionsSummary(appPermissions);
|
|
1979
|
+
for (const line of summary) console.log(` ${line}`);
|
|
1980
|
+
console.log("");
|
|
1981
|
+
console.log(`Source: ${appId}`);
|
|
1982
|
+
console.log("");
|
|
1983
|
+
const choice = await select({
|
|
1984
|
+
prompt: "Do you want to allow this?",
|
|
1985
|
+
options: [
|
|
1986
|
+
{
|
|
1987
|
+
name: "Allow once",
|
|
1988
|
+
value: "once",
|
|
1989
|
+
description: "Allow for this session only"
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
name: "Always allow",
|
|
1993
|
+
value: "always",
|
|
1994
|
+
description: "Remember this choice for future runs"
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
name: "Cancel",
|
|
1998
|
+
value: "cancel",
|
|
1999
|
+
description: "Cancel and exit"
|
|
2000
|
+
}
|
|
2001
|
+
]
|
|
2002
|
+
});
|
|
2003
|
+
if (choice === "cancel") return false;
|
|
2004
|
+
if (choice === "always") {
|
|
2005
|
+
config.trustedApps[appId] = {
|
|
2006
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2007
|
+
choice: "always",
|
|
2008
|
+
sandboxConfig
|
|
2009
|
+
};
|
|
2010
|
+
savePermissions(config);
|
|
2011
|
+
}
|
|
2012
|
+
return true;
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Check if app needs permission check
|
|
2016
|
+
* Returns stored sandbox config if "always" was granted
|
|
2017
|
+
*/
|
|
2018
|
+
function checkStoredPermission(appId) {
|
|
2019
|
+
if (isTrustAll()) return null;
|
|
2020
|
+
const record = loadPermissions().trustedApps[appId];
|
|
2021
|
+
if (record?.choice === "always" && record.sandboxConfig) return record.sandboxConfig;
|
|
2022
|
+
return null;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/remote/parser.ts
|
|
2027
|
+
/**
|
|
2028
|
+
* Parse various remote formats into RepoRef
|
|
2029
|
+
*
|
|
2030
|
+
* Supported formats:
|
|
2031
|
+
* - user/repo
|
|
2032
|
+
* - user/repo@v1.0.0
|
|
2033
|
+
* - user/repo@branch
|
|
2034
|
+
* - github.com/user/repo
|
|
2035
|
+
* - github.com/user/repo@ref
|
|
2036
|
+
* - https://github.com/user/repo
|
|
2037
|
+
*/
|
|
2038
|
+
function parseRemoteRef(input) {
|
|
2039
|
+
let normalized = input.trim();
|
|
2040
|
+
normalized = normalized.replace(/^https?:\/\//, "");
|
|
2041
|
+
normalized = normalized.replace(/^github\.com\//, "");
|
|
2042
|
+
let ref = "main";
|
|
2043
|
+
const atIndex = normalized.indexOf("@");
|
|
2044
|
+
if (atIndex !== -1) {
|
|
2045
|
+
ref = normalized.slice(atIndex + 1);
|
|
2046
|
+
normalized = normalized.slice(0, atIndex);
|
|
2047
|
+
}
|
|
2048
|
+
normalized = normalized.replace(/\.git$/, "");
|
|
2049
|
+
const parts = normalized.split("/");
|
|
2050
|
+
if (parts.length !== 2) return null;
|
|
2051
|
+
const [owner, repo] = parts;
|
|
2052
|
+
if (!owner || !repo || !isValidGitHubName(owner) || !isValidGitHubName(repo)) return null;
|
|
2053
|
+
return {
|
|
2054
|
+
owner,
|
|
2055
|
+
repo,
|
|
2056
|
+
ref
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Check if a string is a valid GitHub username or repo name
|
|
2061
|
+
*/
|
|
2062
|
+
function isValidGitHubName(name) {
|
|
2063
|
+
return /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(name) || /^[a-zA-Z0-9]$/.test(name);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Get the kly cache directory
|
|
2067
|
+
*/
|
|
2068
|
+
function getCacheDir() {
|
|
2069
|
+
return join(homedir(), ".kly", "cache");
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Get the cache path for a specific repo ref
|
|
2073
|
+
*/
|
|
2074
|
+
function getRepoCachePath(ref) {
|
|
2075
|
+
return join(getCacheDir(), "github.com", ref.owner, ref.repo, ref.ref);
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Check if an input looks like a remote reference (vs local file path)
|
|
2079
|
+
*/
|
|
2080
|
+
function isRemoteRef(input) {
|
|
2081
|
+
if (input.startsWith("./") || input.startsWith("../") || input.startsWith("/") || input.includes("\\")) return false;
|
|
2082
|
+
return parseRemoteRef(input) !== null;
|
|
2083
|
+
}
|
|
2084
|
+
var init_parser = __esmMin((() => {}));
|
|
2085
|
+
|
|
2086
|
+
//#endregion
|
|
2087
|
+
//#region src/remote/cache.ts
|
|
2088
|
+
init_parser();
|
|
2089
|
+
const META_FILENAME = ".kly-meta.json";
|
|
2090
|
+
/**
|
|
2091
|
+
* Check if cache exists and is valid
|
|
2092
|
+
*/
|
|
2093
|
+
function checkCache(ref) {
|
|
2094
|
+
const cachePath = getRepoCachePath(ref);
|
|
2095
|
+
const metaPath = join(cachePath, META_FILENAME);
|
|
2096
|
+
if (!existsSync(cachePath)) return {
|
|
2097
|
+
exists: false,
|
|
2098
|
+
valid: false,
|
|
2099
|
+
reason: "Cache directory does not exist"
|
|
2100
|
+
};
|
|
2101
|
+
if (!existsSync(metaPath)) return {
|
|
2102
|
+
exists: true,
|
|
2103
|
+
valid: false,
|
|
2104
|
+
reason: "Cache metadata missing"
|
|
2105
|
+
};
|
|
2106
|
+
try {
|
|
2107
|
+
const metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
2108
|
+
if (!existsSync(join(cachePath, metadata.entryPoint))) return {
|
|
2109
|
+
exists: true,
|
|
2110
|
+
valid: false,
|
|
2111
|
+
metadata,
|
|
2112
|
+
reason: "Entry point file missing"
|
|
2113
|
+
};
|
|
2114
|
+
if (!metadata.dependenciesInstalled) return {
|
|
2115
|
+
exists: true,
|
|
2116
|
+
valid: false,
|
|
2117
|
+
metadata,
|
|
2118
|
+
reason: "Dependencies not installed"
|
|
2119
|
+
};
|
|
2120
|
+
return {
|
|
2121
|
+
exists: true,
|
|
2122
|
+
valid: true,
|
|
2123
|
+
metadata
|
|
2124
|
+
};
|
|
2125
|
+
} catch {
|
|
2126
|
+
return {
|
|
2127
|
+
exists: true,
|
|
2128
|
+
valid: false,
|
|
2129
|
+
reason: "Invalid cache metadata"
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Write cache metadata
|
|
2135
|
+
*/
|
|
2136
|
+
function writeMetadata(ref, metadata) {
|
|
2137
|
+
const metaPath = join(getRepoCachePath(ref), META_FILENAME);
|
|
2138
|
+
mkdirSync(dirname(metaPath), { recursive: true });
|
|
2139
|
+
writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Remove cached repository
|
|
2143
|
+
*/
|
|
2144
|
+
function invalidateCache(ref) {
|
|
2145
|
+
const cachePath = getRepoCachePath(ref);
|
|
2146
|
+
if (existsSync(cachePath)) rmSync(cachePath, {
|
|
2147
|
+
recursive: true,
|
|
2148
|
+
force: true
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/remote/fetcher.ts
|
|
2154
|
+
init_parser();
|
|
2155
|
+
const execAsync = promisify(exec);
|
|
2156
|
+
/**
|
|
2157
|
+
* Clone a repository to cache
|
|
2158
|
+
*/
|
|
2159
|
+
async function cloneRepo(ref) {
|
|
2160
|
+
const repoUrl = `https://github.com/${ref.owner}/${ref.repo}.git`;
|
|
2161
|
+
const targetPath = getRepoCachePath(ref);
|
|
2162
|
+
if (existsSync(targetPath)) rmSync(targetPath, {
|
|
2163
|
+
recursive: true,
|
|
2164
|
+
force: true
|
|
2165
|
+
});
|
|
2166
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
2167
|
+
try {
|
|
2168
|
+
await execAsync(`git clone --depth 1 --branch ${ref.ref} ${repoUrl} "${targetPath}"`, { timeout: 6e4 });
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
if (ref.ref === "main") try {
|
|
2171
|
+
await execAsync(`git clone --depth 1 ${repoUrl} "${targetPath}"`, { timeout: 6e4 });
|
|
2172
|
+
return;
|
|
2173
|
+
} catch {}
|
|
2174
|
+
throw new Error(`Failed to clone ${ref.owner}/${ref.repo}@${ref.ref}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Install dependencies using bun
|
|
2179
|
+
*/
|
|
2180
|
+
async function installDependencies(repoPath) {
|
|
2181
|
+
if (!existsSync(`${repoPath}/package.json`)) return;
|
|
2182
|
+
try {
|
|
2183
|
+
await execAsync("bun install", {
|
|
2184
|
+
cwd: repoPath,
|
|
2185
|
+
timeout: 12e4
|
|
2186
|
+
});
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
throw new Error(`Failed to install dependencies: ${error instanceof Error ? error.message : String(error)}`);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Get the commit SHA of a cloned repo
|
|
2193
|
+
*/
|
|
2194
|
+
async function getCommitSha(repoPath) {
|
|
2195
|
+
try {
|
|
2196
|
+
const { stdout } = await execAsync("git rev-parse HEAD", {
|
|
2197
|
+
cwd: repoPath,
|
|
2198
|
+
timeout: 5e3
|
|
2199
|
+
});
|
|
2200
|
+
return stdout.trim();
|
|
2201
|
+
} catch {
|
|
2202
|
+
return "unknown";
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
//#endregion
|
|
2207
|
+
//#region src/remote/integrity.ts
|
|
2208
|
+
/**
|
|
2209
|
+
* Directories and files to ignore when calculating repository hash
|
|
2210
|
+
*/
|
|
2211
|
+
const IGNORE_PATTERNS = [
|
|
2212
|
+
".git",
|
|
2213
|
+
"node_modules",
|
|
2214
|
+
"dist",
|
|
2215
|
+
"build",
|
|
2216
|
+
"coverage",
|
|
2217
|
+
".kly",
|
|
2218
|
+
".kly-meta.json",
|
|
2219
|
+
".DS_Store",
|
|
2220
|
+
"*.log"
|
|
2221
|
+
];
|
|
2222
|
+
/**
|
|
2223
|
+
* File extensions to include in hash calculation
|
|
2224
|
+
*/
|
|
2225
|
+
const SOURCE_EXTENSIONS = [
|
|
2226
|
+
".ts",
|
|
2227
|
+
".js",
|
|
2228
|
+
".tsx",
|
|
2229
|
+
".jsx",
|
|
2230
|
+
".json",
|
|
2231
|
+
".md",
|
|
2232
|
+
".txt"
|
|
2233
|
+
];
|
|
2234
|
+
/**
|
|
2235
|
+
* Calculate integrity hash for a cloned repository
|
|
2236
|
+
* Uses SHA-384 (consistent with browser Subresource Integrity)
|
|
2237
|
+
*
|
|
2238
|
+
* Hash includes:
|
|
2239
|
+
* - All source code files (sorted by path)
|
|
2240
|
+
* - File paths (for structure verification)
|
|
2241
|
+
* - Lock file (bun.lockb) if present
|
|
2242
|
+
*
|
|
2243
|
+
* @param repoPath - Absolute path to the repository
|
|
2244
|
+
* @param algorithm - Hash algorithm (default: sha384)
|
|
2245
|
+
* @returns Hash in format "sha384-base64..."
|
|
2246
|
+
*/
|
|
2247
|
+
function calculateRepoHash(repoPath, algorithm = "sha384") {
|
|
2248
|
+
const hash = createHash(algorithm);
|
|
2249
|
+
const files = collectSourceFiles(repoPath);
|
|
2250
|
+
files.sort();
|
|
2251
|
+
for (const file of files) {
|
|
2252
|
+
const relativePath = relative(repoPath, file);
|
|
2253
|
+
const content = readFileSync(file);
|
|
2254
|
+
hash.update(`FILE:${relativePath}\n`);
|
|
2255
|
+
hash.update(content);
|
|
2256
|
+
hash.update("\n");
|
|
2257
|
+
}
|
|
2258
|
+
const lockFile = join(repoPath, "bun.lockb");
|
|
2259
|
+
if (existsSync(lockFile)) {
|
|
2260
|
+
hash.update("LOCK:bun.lockb\n");
|
|
2261
|
+
hash.update(readFileSync(lockFile));
|
|
2262
|
+
hash.update("\n");
|
|
2263
|
+
}
|
|
2264
|
+
return `${algorithm}-${hash.digest("base64")}`;
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Recursively collect all source files in a directory
|
|
2268
|
+
*
|
|
2269
|
+
* @param dir - Directory to scan
|
|
2270
|
+
* @param results - Accumulator for file paths
|
|
2271
|
+
* @returns Array of absolute file paths
|
|
2272
|
+
*/
|
|
2273
|
+
function collectSourceFiles(dir, results = []) {
|
|
2274
|
+
if (!existsSync(dir)) return results;
|
|
2275
|
+
try {
|
|
2276
|
+
const entries = readdirSync(dir);
|
|
2277
|
+
for (const entry of entries) {
|
|
2278
|
+
if (shouldIgnore(entry)) continue;
|
|
2279
|
+
const fullPath = join(dir, entry);
|
|
2280
|
+
let stat;
|
|
2281
|
+
try {
|
|
2282
|
+
stat = statSync(fullPath);
|
|
2283
|
+
} catch {
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
if (stat.isDirectory()) collectSourceFiles(fullPath, results);
|
|
2287
|
+
else if (stat.isFile() && shouldIncludeFile(entry)) results.push(fullPath);
|
|
2288
|
+
}
|
|
2289
|
+
} catch {}
|
|
2290
|
+
return results;
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Check if a file/directory should be ignored
|
|
2294
|
+
*/
|
|
2295
|
+
function shouldIgnore(name) {
|
|
2296
|
+
for (const pattern of IGNORE_PATTERNS) if (pattern.startsWith("*")) {
|
|
2297
|
+
const ext = pattern.slice(1);
|
|
2298
|
+
if (name.endsWith(ext)) return true;
|
|
2299
|
+
} else if (name === pattern) return true;
|
|
2300
|
+
return false;
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Check if a file should be included in hash calculation
|
|
2304
|
+
*/
|
|
2305
|
+
function shouldIncludeFile(name) {
|
|
2306
|
+
return SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext));
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
//#endregion
|
|
2310
|
+
//#region src/remote/resolver.ts
|
|
2311
|
+
/**
|
|
2312
|
+
* Entry point candidates to search for (in order)
|
|
2313
|
+
*/
|
|
2314
|
+
const ENTRY_CANDIDATES = [
|
|
2315
|
+
"index.ts",
|
|
2316
|
+
"main.ts",
|
|
2317
|
+
"src/index.ts",
|
|
2318
|
+
"src/main.ts",
|
|
2319
|
+
"app.ts"
|
|
2320
|
+
];
|
|
2321
|
+
/**
|
|
2322
|
+
* Resolve entry point for a kly app
|
|
2323
|
+
* Priority: main field > convention candidates
|
|
2324
|
+
*/
|
|
2325
|
+
function resolveEntryPoint(repoPath) {
|
|
2326
|
+
const pkgPath = join(repoPath, "package.json");
|
|
2327
|
+
if (existsSync(pkgPath)) try {
|
|
2328
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
2329
|
+
if (pkg.main && (pkg.main.endsWith(".ts") || pkg.main.endsWith(".js"))) {
|
|
2330
|
+
if (existsSync(join(repoPath, pkg.main))) return pkg.main;
|
|
2331
|
+
}
|
|
2332
|
+
} catch {}
|
|
2333
|
+
for (const candidate of ENTRY_CANDIDATES) if (existsSync(join(repoPath, candidate))) return candidate;
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Read kly configuration from package.json
|
|
2338
|
+
*/
|
|
2339
|
+
function readKlyConfig(repoPath) {
|
|
2340
|
+
const pkgPath = join(repoPath, "package.json");
|
|
2341
|
+
if (!existsSync(pkgPath)) return null;
|
|
2342
|
+
try {
|
|
2343
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8")).kly ?? null;
|
|
2344
|
+
} catch {
|
|
2345
|
+
return null;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Check if current kly version satisfies the required version
|
|
2350
|
+
* Simple semver check (supports >=x.y.z format)
|
|
2351
|
+
*/
|
|
2352
|
+
function validateVersion(required, current) {
|
|
2353
|
+
const reqMatch = required.match(/^>=?\s*(\d+)\.(\d+)\.(\d+)/);
|
|
2354
|
+
if (!reqMatch) return true;
|
|
2355
|
+
const curMatch = current.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
2356
|
+
if (!curMatch) return true;
|
|
2357
|
+
const reqMajor = Number(reqMatch[1]);
|
|
2358
|
+
const reqMinor = Number(reqMatch[2]);
|
|
2359
|
+
const reqPatch = Number(reqMatch[3]);
|
|
2360
|
+
const curMajor = Number(curMatch[1]);
|
|
2361
|
+
const curMinor = Number(curMatch[2]);
|
|
2362
|
+
const curPatch = Number(curMatch[3]);
|
|
2363
|
+
if (curMajor > reqMajor) return true;
|
|
2364
|
+
if (curMajor < reqMajor) return false;
|
|
2365
|
+
if (curMinor > reqMinor) return true;
|
|
2366
|
+
if (curMinor < reqMinor) return false;
|
|
2367
|
+
return curPatch >= reqPatch;
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Check required environment variables
|
|
2371
|
+
* Returns list of missing variables
|
|
2372
|
+
*/
|
|
2373
|
+
function checkEnvVars(required) {
|
|
2374
|
+
return required.filter((name) => !process.env[name]);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
//#endregion
|
|
2378
|
+
//#region src/remote/sumfile.ts
|
|
2379
|
+
/**
|
|
2380
|
+
* Get the path to kly.sum file
|
|
2381
|
+
*/
|
|
2382
|
+
function getSumFilePath() {
|
|
2383
|
+
return join(homedir(), ".kly", "kly.sum");
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Manager for kly.sum file (integrity verification database)
|
|
2387
|
+
*
|
|
2388
|
+
* File format (one entry per line):
|
|
2389
|
+
* github.com/owner/repo@ref sha384-hash timestamp trusted|untrusted
|
|
2390
|
+
*
|
|
2391
|
+
* Example:
|
|
2392
|
+
* github.com/jack/weather@v1.0.0 sha384-oqVuAfXRKap7fdgc... 1704067200 trusted
|
|
2393
|
+
*/
|
|
2394
|
+
var SumFileManager = class {
|
|
2395
|
+
entries = /* @__PURE__ */ new Map();
|
|
2396
|
+
sumFilePath;
|
|
2397
|
+
dirty = false;
|
|
2398
|
+
constructor(sumFilePath) {
|
|
2399
|
+
this.sumFilePath = sumFilePath || getSumFilePath();
|
|
2400
|
+
this.load();
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Load entries from kly.sum file
|
|
2404
|
+
*/
|
|
2405
|
+
load() {
|
|
2406
|
+
if (!existsSync(this.sumFilePath)) return;
|
|
2407
|
+
try {
|
|
2408
|
+
const lines = readFileSync(this.sumFilePath, "utf-8").split("\n");
|
|
2409
|
+
for (const line of lines) {
|
|
2410
|
+
const trimmed = line.trim();
|
|
2411
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
2412
|
+
const entry = this.parseLine(trimmed);
|
|
2413
|
+
if (entry) this.entries.set(entry.url, entry);
|
|
2414
|
+
}
|
|
2415
|
+
} catch (error) {
|
|
2416
|
+
console.warn(`Warning: Failed to load kly.sum: ${error}`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Parse a single line from kly.sum
|
|
2421
|
+
*/
|
|
2422
|
+
parseLine(line) {
|
|
2423
|
+
const parts = line.split(/\s+/);
|
|
2424
|
+
if (parts.length < 4) return null;
|
|
2425
|
+
const [url, hash, timestampStr, trustedStr] = parts;
|
|
2426
|
+
if (!url || !hash || !timestampStr || !trustedStr) return null;
|
|
2427
|
+
const timestamp = Number(timestampStr);
|
|
2428
|
+
if (Number.isNaN(timestamp)) return null;
|
|
2429
|
+
return {
|
|
2430
|
+
url,
|
|
2431
|
+
hash,
|
|
2432
|
+
timestamp,
|
|
2433
|
+
trusted: trustedStr === "trusted"
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Format an entry for writing to file
|
|
2438
|
+
*/
|
|
2439
|
+
formatEntry(entry) {
|
|
2440
|
+
return `${entry.url} ${entry.hash} ${entry.timestamp} ${entry.trusted ? "trusted" : "untrusted"}`;
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Save entries to kly.sum file
|
|
2444
|
+
*/
|
|
2445
|
+
save() {
|
|
2446
|
+
if (!this.dirty) return;
|
|
2447
|
+
try {
|
|
2448
|
+
const dir = dirname(this.sumFilePath);
|
|
2449
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2450
|
+
const lines = [
|
|
2451
|
+
"# kly.sum - Integrity verification database",
|
|
2452
|
+
"# Format: url hash timestamp trusted|untrusted",
|
|
2453
|
+
"# DO NOT EDIT THIS FILE MANUALLY",
|
|
2454
|
+
"",
|
|
2455
|
+
...Array.from(this.entries.values()).sort((a, b) => a.url.localeCompare(b.url)).map((entry) => this.formatEntry(entry))
|
|
2456
|
+
];
|
|
2457
|
+
writeFileSync(this.sumFilePath, `${lines.join("\n")}\n`, "utf-8");
|
|
2458
|
+
this.dirty = false;
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
console.error(`Error: Failed to save kly.sum: ${error}`);
|
|
2461
|
+
throw error;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Verify a repository's integrity hash
|
|
2466
|
+
*
|
|
2467
|
+
* @param url - Full URL (e.g., "github.com/owner/repo@ref")
|
|
2468
|
+
* @param hash - Calculated hash to verify
|
|
2469
|
+
* @returns "ok" if matches, "mismatch" if different, "new" if first time
|
|
2470
|
+
*/
|
|
2471
|
+
verify(url, hash) {
|
|
2472
|
+
const entry = this.entries.get(url);
|
|
2473
|
+
if (!entry) return "new";
|
|
2474
|
+
if (entry.hash === hash) return "ok";
|
|
2475
|
+
return "mismatch";
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Add or update an entry in kly.sum
|
|
2479
|
+
*
|
|
2480
|
+
* @param url - Full URL
|
|
2481
|
+
* @param hash - Integrity hash
|
|
2482
|
+
* @param trusted - Whether user explicitly trusted this code
|
|
2483
|
+
*/
|
|
2484
|
+
add(url, hash, trusted = false) {
|
|
2485
|
+
this.entries.set(url, {
|
|
2486
|
+
url,
|
|
2487
|
+
hash,
|
|
2488
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
2489
|
+
trusted
|
|
2490
|
+
});
|
|
2491
|
+
this.dirty = true;
|
|
2492
|
+
this.save();
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Update an existing entry's hash (e.g., user approved new version)
|
|
2496
|
+
*
|
|
2497
|
+
* @param url - Full URL
|
|
2498
|
+
* @param hash - New hash
|
|
2499
|
+
* @param trusted - Whether user explicitly trusted this update
|
|
2500
|
+
*/
|
|
2501
|
+
update(url, hash, trusted = false) {
|
|
2502
|
+
const existing = this.entries.get(url);
|
|
2503
|
+
this.entries.set(url, {
|
|
2504
|
+
url,
|
|
2505
|
+
hash,
|
|
2506
|
+
timestamp: existing?.timestamp ?? Math.floor(Date.now() / 1e3),
|
|
2507
|
+
trusted
|
|
2508
|
+
});
|
|
2509
|
+
this.dirty = true;
|
|
2510
|
+
this.save();
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Remove an entry from kly.sum
|
|
2514
|
+
*
|
|
2515
|
+
* @param url - Full URL to remove
|
|
2516
|
+
* @returns true if removed, false if not found
|
|
2517
|
+
*/
|
|
2518
|
+
remove(url) {
|
|
2519
|
+
const existed = this.entries.delete(url);
|
|
2520
|
+
if (existed) {
|
|
2521
|
+
this.dirty = true;
|
|
2522
|
+
this.save();
|
|
2523
|
+
}
|
|
2524
|
+
return existed;
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Get an entry by URL
|
|
2528
|
+
*
|
|
2529
|
+
* @param url - Full URL
|
|
2530
|
+
* @returns Entry if found, undefined otherwise
|
|
2531
|
+
*/
|
|
2532
|
+
get(url) {
|
|
2533
|
+
return this.entries.get(url);
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* Get all entries
|
|
2537
|
+
*
|
|
2538
|
+
* @returns Array of all sum entries
|
|
2539
|
+
*/
|
|
2540
|
+
getAll() {
|
|
2541
|
+
return Array.from(this.entries.values());
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Clear all entries (for testing or reset)
|
|
2545
|
+
*/
|
|
2546
|
+
clear() {
|
|
2547
|
+
this.entries.clear();
|
|
2548
|
+
this.dirty = true;
|
|
2549
|
+
this.save();
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Get statistics about the sum file
|
|
2553
|
+
*/
|
|
2554
|
+
getStats() {
|
|
2555
|
+
const entries = Array.from(this.entries.values());
|
|
2556
|
+
return {
|
|
2557
|
+
total: entries.length,
|
|
2558
|
+
trusted: entries.filter((e) => e.trusted).length,
|
|
2559
|
+
untrusted: entries.filter((e) => !e.trusted).length
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
|
|
2564
|
+
//#endregion
|
|
2565
|
+
//#region src/remote/index.ts
|
|
2566
|
+
init_parser();
|
|
2567
|
+
/** Current kly CLI version */
|
|
2568
|
+
const KLY_VERSION = "0.1.0";
|
|
2569
|
+
/**
|
|
2570
|
+
* Run a remote GitHub repository as a kly app
|
|
2571
|
+
*/
|
|
2572
|
+
async function runRemote(input, options = {}) {
|
|
2573
|
+
const ref = parseRemoteRef(input);
|
|
2574
|
+
if (!ref) throw new Error(`Invalid remote reference: ${input}`);
|
|
2575
|
+
const repoPath = getRepoCachePath(ref);
|
|
2576
|
+
const cacheResult = checkCache(ref);
|
|
2577
|
+
if (!cacheResult.valid || options.force) {
|
|
2578
|
+
if (options.force && cacheResult.exists) {
|
|
2579
|
+
console.log(`Refreshing ${ref.owner}/${ref.repo}@${ref.ref}...`);
|
|
2580
|
+
invalidateCache(ref);
|
|
2581
|
+
} else console.log(`Fetching ${ref.owner}/${ref.repo}@${ref.ref}...`);
|
|
2582
|
+
await cloneRepo(ref);
|
|
2583
|
+
const entryPoint = resolveEntryPoint(repoPath);
|
|
2584
|
+
if (!entryPoint) throw new Error(`No entry point found in ${ref.owner}/${ref.repo}. Set "main" in package.json or create index.ts`);
|
|
2585
|
+
if (!options.skipInstall) {
|
|
2586
|
+
console.log("Installing dependencies...");
|
|
2587
|
+
await installDependencies(repoPath);
|
|
2588
|
+
}
|
|
2589
|
+
writeMetadata(ref, {
|
|
2590
|
+
commitSha: await getCommitSha(repoPath),
|
|
2591
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2592
|
+
entryPoint,
|
|
2593
|
+
dependenciesInstalled: !options.skipInstall
|
|
2594
|
+
});
|
|
2595
|
+
console.log("Ready!\n");
|
|
2596
|
+
}
|
|
2597
|
+
if (!options.skipIntegrityCheck) {
|
|
2598
|
+
if (!(await verifyIntegrity(ref, repoPath)).proceedWithExecution) {
|
|
2599
|
+
console.error("\nā Execution cancelled due to integrity verification failure");
|
|
2600
|
+
process.exit(1);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
await executeApp(ref, repoPath, options.args ?? [], options.mcp ?? false);
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Verify repository integrity using kly.sum
|
|
2607
|
+
*
|
|
2608
|
+
* @param ref - Repository reference
|
|
2609
|
+
* @param repoPath - Local path to repository
|
|
2610
|
+
* @returns Object with integrity check result and whether to proceed with execution
|
|
2611
|
+
*/
|
|
2612
|
+
async function verifyIntegrity(ref, repoPath) {
|
|
2613
|
+
const url = `github.com/${ref.owner}/${ref.repo}@${ref.ref}`;
|
|
2614
|
+
console.log("\nš Verifying code integrity...");
|
|
2615
|
+
const hash = calculateRepoHash(repoPath);
|
|
2616
|
+
console.log(` Hash: ${hash.slice(0, 20)}...`);
|
|
2617
|
+
const sumManager = new SumFileManager();
|
|
2618
|
+
const verifyResult = sumManager.verify(url, hash);
|
|
2619
|
+
const result = {
|
|
2620
|
+
status: verifyResult,
|
|
2621
|
+
hash,
|
|
2622
|
+
requiresTrust: verifyResult !== "ok"
|
|
2623
|
+
};
|
|
2624
|
+
switch (verifyResult) {
|
|
2625
|
+
case "ok":
|
|
2626
|
+
console.log(" ā Integrity verified\n");
|
|
2627
|
+
return {
|
|
2628
|
+
proceedWithExecution: true,
|
|
2629
|
+
result
|
|
2630
|
+
};
|
|
2631
|
+
case "new":
|
|
2632
|
+
console.log("\nā ļø SECURITY NOTICE: First time running this tool\n");
|
|
2633
|
+
console.log(" This code has not been verified before.");
|
|
2634
|
+
console.log(" Please review the source code before proceeding:");
|
|
2635
|
+
console.log(` https://github.com/${ref.owner}/${ref.repo}/tree/${ref.ref}\n`);
|
|
2636
|
+
if (await confirm("Do you trust this code and want to proceed?")) {
|
|
2637
|
+
sumManager.add(url, hash, true);
|
|
2638
|
+
console.log(" ā Code trusted and added to kly.sum\n");
|
|
2639
|
+
return {
|
|
2640
|
+
proceedWithExecution: true,
|
|
2641
|
+
result
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
console.log("\n User declined to trust the code");
|
|
2645
|
+
return {
|
|
2646
|
+
proceedWithExecution: false,
|
|
2647
|
+
result
|
|
2648
|
+
};
|
|
2649
|
+
case "mismatch":
|
|
2650
|
+
result.expectedHash = sumManager.get(url)?.hash;
|
|
2651
|
+
console.log("\nšØ SECURITY WARNING: Code has been modified!\n");
|
|
2652
|
+
console.log(" The code for this tool has changed since you last ran it.");
|
|
2653
|
+
console.log(" This could indicate:");
|
|
2654
|
+
console.log(" - A supply chain attack (code tampering)");
|
|
2655
|
+
console.log(" - Maintainer account compromise");
|
|
2656
|
+
console.log(" - Git history rewrite\n");
|
|
2657
|
+
console.log(" Expected hash:", `${result.expectedHash?.slice(0, 40)}...`);
|
|
2658
|
+
console.log(" Current hash: ", `${hash.slice(0, 40)}...\n`);
|
|
2659
|
+
console.log(" Recommended actions:");
|
|
2660
|
+
console.log(" 1. Check GitHub for official announcements");
|
|
2661
|
+
console.log(" 2. Contact the maintainer");
|
|
2662
|
+
console.log(" 3. Review code changes carefully");
|
|
2663
|
+
console.log(` 4. Visit: https://github.com/${ref.owner}/${ref.repo}/commits/${ref.ref}\n`);
|
|
2664
|
+
if (await confirm("ā ļø Proceed anyway? (NOT RECOMMENDED)", false)) {
|
|
2665
|
+
if (await confirm("Update kly.sum with new hash?", false)) {
|
|
2666
|
+
sumManager.update(url, hash, true);
|
|
2667
|
+
console.log(" ā kly.sum updated with new hash\n");
|
|
2668
|
+
}
|
|
2669
|
+
return {
|
|
2670
|
+
proceedWithExecution: true,
|
|
2671
|
+
result
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
console.log("\n Execution cancelled for safety");
|
|
2675
|
+
return {
|
|
2676
|
+
proceedWithExecution: false,
|
|
2677
|
+
result
|
|
2678
|
+
};
|
|
2679
|
+
default:
|
|
2680
|
+
console.error("Unknown verification result");
|
|
2681
|
+
return {
|
|
2682
|
+
proceedWithExecution: false,
|
|
2683
|
+
result
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Execute the kly app
|
|
2689
|
+
*/
|
|
2690
|
+
async function executeApp(ref, repoPath, args$1, mcp) {
|
|
2691
|
+
const config = readKlyConfig(repoPath);
|
|
2692
|
+
if (config?.version) {
|
|
2693
|
+
if (!validateVersion(config.version, KLY_VERSION)) throw new Error(`This app requires kly ${config.version}, but you have ${KLY_VERSION}`);
|
|
2694
|
+
}
|
|
2695
|
+
if (config?.env && config.env.length > 0) {
|
|
2696
|
+
const missing = checkEnvVars(config.env);
|
|
2697
|
+
if (missing.length > 0) console.warn(`Warning: Required environment variables not set: ${missing.join(", ")}`);
|
|
2698
|
+
}
|
|
2699
|
+
const entryPoint = resolveEntryPoint(repoPath);
|
|
2700
|
+
if (!entryPoint) throw new Error(`Cannot resolve entry point for ${ref.owner}/${ref.repo}`);
|
|
2701
|
+
const absoluteEntryPath = join(repoPath, entryPoint);
|
|
2702
|
+
const remoteRef = `github.com/${ref.owner}/${ref.repo}`;
|
|
2703
|
+
const prevRemoteRef = process.env[ENV_VARS.REMOTE_REF];
|
|
2704
|
+
process.env[ENV_VARS.REMOTE_REF] = remoteRef;
|
|
2705
|
+
try {
|
|
2706
|
+
const { getAppIdentifier: getAppIdentifier$1, checkApiKeyPermission: checkApiKeyPermission$1, getAppSandboxConfig: getAppSandboxConfig$1 } = await import("./permissions-2r_7ZqaH.mjs");
|
|
2707
|
+
const { launchSandbox: launchSandbox$1 } = await import("./launcher-vTpgdO9n.mjs");
|
|
2708
|
+
const appId = getAppIdentifier$1();
|
|
2709
|
+
console.log("š Checking permissions...");
|
|
2710
|
+
const allowApiKey = await checkApiKeyPermission$1(appId);
|
|
2711
|
+
if (!allowApiKey) {
|
|
2712
|
+
console.error("ā Permission denied: API key access rejected");
|
|
2713
|
+
process.exit(1);
|
|
2714
|
+
}
|
|
2715
|
+
const sandboxConfig = await getAppSandboxConfig$1(appId);
|
|
2716
|
+
if (!sandboxConfig) {
|
|
2717
|
+
console.error("ā Permission denied: Sandbox configuration rejected");
|
|
2718
|
+
process.exit(1);
|
|
2719
|
+
}
|
|
2720
|
+
if (mcp) {
|
|
2721
|
+
console.warn("ā ļø MCP mode with remote repos not yet fully supported in new architecture");
|
|
2722
|
+
process.env[ENV_VARS.MCP_MODE] = "true";
|
|
2723
|
+
process.argv = ["bun", absoluteEntryPath];
|
|
2724
|
+
await import(absoluteEntryPath);
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
const result = await launchSandbox$1({
|
|
2728
|
+
scriptPath: absoluteEntryPath,
|
|
2729
|
+
args: args$1,
|
|
2730
|
+
appId,
|
|
2731
|
+
sandboxConfig,
|
|
2732
|
+
allowApiKey
|
|
2733
|
+
});
|
|
2734
|
+
if (result.error) console.error(`\nā Error: ${result.error}`);
|
|
2735
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2736
|
+
} finally {
|
|
2737
|
+
if (prevRemoteRef === void 0) delete process.env[ENV_VARS.REMOTE_REF];
|
|
2738
|
+
else process.env[ENV_VARS.REMOTE_REF] = prevRemoteRef;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region bin/kly.ts
|
|
2744
|
+
const args = process.argv.slice(2);
|
|
2745
|
+
const command = args[0];
|
|
2746
|
+
async function main() {
|
|
2747
|
+
if (!command || command === "--help" || command === "-h") {
|
|
2748
|
+
showHelp();
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
if (command === "--version" || command === "-v") {
|
|
2752
|
+
showVersion();
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
if (command === "models") {
|
|
2756
|
+
await modelsCommand();
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
if (command === "permissions") {
|
|
2760
|
+
await permissionsCommand();
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
if (command === "run") {
|
|
2764
|
+
const target = args[1];
|
|
2765
|
+
if (!target) {
|
|
2766
|
+
console.error("Error: Missing file path or remote reference");
|
|
2767
|
+
console.error("Usage: kly run <file|user/repo[@ref]>");
|
|
2768
|
+
process.exit(1);
|
|
2769
|
+
}
|
|
2770
|
+
const force = args.indexOf("--force") !== -1;
|
|
2771
|
+
const dashDashIndex = args.indexOf("--");
|
|
2772
|
+
const appArgs = dashDashIndex !== -1 ? args.slice(dashDashIndex + 1) : args.slice(2).filter((arg) => arg !== "--force");
|
|
2773
|
+
if (isRemoteRef(target)) await runRemote(target, {
|
|
2774
|
+
args: appArgs,
|
|
2775
|
+
force
|
|
2776
|
+
});
|
|
2777
|
+
else await runFile(target, appArgs);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (command === "mcp") {
|
|
2781
|
+
const target = args[1];
|
|
2782
|
+
if (!target) {
|
|
2783
|
+
console.error("Error: Missing file path or remote reference");
|
|
2784
|
+
console.error("Usage: kly mcp <file|user/repo[@ref]>");
|
|
2785
|
+
process.exit(1);
|
|
2786
|
+
}
|
|
2787
|
+
const force = args.indexOf("--force") !== -1;
|
|
2788
|
+
if (isRemoteRef(target)) await runRemote(target, {
|
|
2789
|
+
args: [],
|
|
2790
|
+
force,
|
|
2791
|
+
mcp: true
|
|
2792
|
+
});
|
|
2793
|
+
else await runFileAsMcp(target);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
console.error(`Unknown command: ${command}`);
|
|
2797
|
+
console.error("Run \"kly --help\" for usage");
|
|
2798
|
+
process.exit(1);
|
|
2799
|
+
}
|
|
2800
|
+
async function runFile(filePath, appArgs) {
|
|
2801
|
+
const absolutePath = resolve(process.cwd(), filePath);
|
|
2802
|
+
const prevLocalRef = process.env.KLY_LOCAL_REF;
|
|
2803
|
+
process.env.KLY_LOCAL_REF = `local:${absolutePath}`;
|
|
2804
|
+
try {
|
|
2805
|
+
const appId = getAppIdentifier();
|
|
2806
|
+
const storedConfig = checkStoredPermission(appId);
|
|
2807
|
+
let sandboxConfig;
|
|
2808
|
+
let allowApiKey = false;
|
|
2809
|
+
if (!storedConfig) {
|
|
2810
|
+
const appPermissions = await extractAppPermissions(absolutePath);
|
|
2811
|
+
sandboxConfig = buildSandboxConfig(appPermissions);
|
|
2812
|
+
console.log("š Checking permissions...");
|
|
2813
|
+
if (!await requestUnifiedPermission(appId, appPermissions, sandboxConfig)) {
|
|
2814
|
+
console.error("ā Permission denied");
|
|
2815
|
+
process.exit(1);
|
|
2816
|
+
}
|
|
2817
|
+
allowApiKey = appPermissions?.apiKeys ?? false;
|
|
2818
|
+
} else {
|
|
2819
|
+
sandboxConfig = storedConfig;
|
|
2820
|
+
allowApiKey = (await extractAppPermissions(absolutePath))?.apiKeys ?? false;
|
|
2821
|
+
}
|
|
2822
|
+
const result = await launchSandbox({
|
|
2823
|
+
scriptPath: absolutePath,
|
|
2824
|
+
args: appArgs,
|
|
2825
|
+
appId,
|
|
2826
|
+
sandboxConfig,
|
|
2827
|
+
allowApiKey
|
|
2828
|
+
});
|
|
2829
|
+
if (result.error) console.error(`\nā Error: ${result.error}`);
|
|
2830
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
2831
|
+
} finally {
|
|
2832
|
+
if (prevLocalRef === void 0) delete process.env.KLY_LOCAL_REF;
|
|
2833
|
+
else process.env.KLY_LOCAL_REF = prevLocalRef;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
async function runFileAsMcp(filePath) {
|
|
2837
|
+
const absolutePath = resolve(process.cwd(), filePath);
|
|
2838
|
+
process.env.KLY_MCP_MODE = "true";
|
|
2839
|
+
process.argv = ["bun", absolutePath];
|
|
2840
|
+
await import(absolutePath);
|
|
2841
|
+
}
|
|
2842
|
+
function showHelp() {
|
|
2843
|
+
console.log(`
|
|
2844
|
+
kly - Command Line AI
|
|
2845
|
+
|
|
2846
|
+
Usage:
|
|
2847
|
+
kly <command> [options]
|
|
2848
|
+
|
|
2849
|
+
Commands:
|
|
2850
|
+
models Manage LLM model configurations
|
|
2851
|
+
permissions Manage app permissions
|
|
2852
|
+
run <target> Run a Kly app
|
|
2853
|
+
mcp <target> Start an MCP server for a Kly app
|
|
2854
|
+
|
|
2855
|
+
Target can be:
|
|
2856
|
+
./file.ts Local file
|
|
2857
|
+
user/repo GitHub repo (main branch)
|
|
2858
|
+
user/repo@v1.0.0 GitHub repo at specific tag
|
|
2859
|
+
user/repo@branch GitHub repo at specific branch
|
|
2860
|
+
|
|
2861
|
+
Options:
|
|
2862
|
+
--force Force re-fetch remote repo (ignore cache)
|
|
2863
|
+
--help, -h Show help
|
|
2864
|
+
--version, -v Show version
|
|
2865
|
+
|
|
2866
|
+
Examples:
|
|
2867
|
+
kly models
|
|
2868
|
+
kly permissions
|
|
2869
|
+
kly run ./my-tool.ts
|
|
2870
|
+
kly run ./my-tool.ts --name=World
|
|
2871
|
+
kly run user/weather-app
|
|
2872
|
+
kly run user/weather-app@v1.0.0
|
|
2873
|
+
kly run user/weather-app -- --city=Beijing
|
|
2874
|
+
kly mcp ./my-tool.ts
|
|
2875
|
+
kly mcp user/weather-app
|
|
2876
|
+
`);
|
|
2877
|
+
}
|
|
2878
|
+
function showVersion() {
|
|
2879
|
+
console.log("0.1.0");
|
|
2880
|
+
}
|
|
2881
|
+
main().catch((err) => {
|
|
2882
|
+
console.error(err.message || err);
|
|
2883
|
+
process.exit(1);
|
|
2884
|
+
});
|
|
2885
|
+
|
|
2886
|
+
//#endregion
|
|
2887
|
+
export { getAppSandboxConfig as a, revokePermission as c, getAppName as i, savePermissions as l, clearAllPermissions as n, listPermissions as o, getAppIdentifier as r, loadPermissions as s, checkApiKeyPermission as t, launchSandbox as u };
|
|
2888
|
+
//# sourceMappingURL=kly.mjs.map
|