ketoy-dev 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 +17 -0
- package/README.md +101 -0
- package/SECURITY.md +34 -0
- package/dist/ketoy.js +3165 -0
- package/dist/ketoy.js.map +1 -0
- package/package.json +78 -0
- package/skills/ketoy/README.md +50 -0
- package/skills/ketoy/SKILL.md +148 -0
- package/skills/ketoy/examples/capabilities-stubs.kt +60 -0
- package/skills/ketoy/examples/hilt-config.kt +192 -0
- package/skills/ketoy/examples/no-hilt-config.kt +101 -0
- package/skills/ketoy/examples/todo-screen.kt +156 -0
- package/skills/ketoy/guides/build-and-analyze.md +87 -0
- package/skills/ketoy/guides/diagnose-errors.md +129 -0
- package/skills/ketoy/guides/init-project.md +127 -0
- package/skills/ketoy/guides/migrate.md +190 -0
- package/skills/ketoy/guides/publish-deferred.md +46 -0
- package/skills/ketoy/guides/safe-edits.md +141 -0
- package/skills/ketoy/reference/architecture-cheatsheet.md +122 -0
- package/skills/ketoy/reference/capabilities.md +122 -0
- package/skills/ketoy/reference/forbidden-apis.md +149 -0
- package/skills/ketoy/reference/supported-composables.md +80 -0
- package/skills/ketoy/reference/supported-constructors.md +57 -0
- package/skills/ketoy/reference/supported-modifiers.md +76 -0
- package/skills/ketoy/templates/app-build.gradle.kts.tmpl +109 -0
- package/skills/ketoy/templates/ketoy-capabilities.json.tmpl +21 -0
- package/skills/ketoy/templates/manifest-snippet.xml.tmpl +33 -0
- package/templates/HelloKetoyScreen.kt.tmpl +51 -0
- package/templates/MainActivity.kt.tmpl +53 -0
- package/templates/MyApplication.kt.tmpl +88 -0
- package/templates/ketoy-capabilities.json.tmpl +5 -0
package/dist/ketoy.js
ADDED
|
@@ -0,0 +1,3165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import process2 from "process";
|
|
6
|
+
|
|
7
|
+
// src/lib/paths.ts
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
var CONFIG_DIR = join(homedir(), ".ketoy-cli");
|
|
11
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
12
|
+
var PROJECT_STATE_DIR = ".ketoy";
|
|
13
|
+
var PROJECT_STATE_FILE = join(PROJECT_STATE_DIR, "state.json");
|
|
14
|
+
var CLI_VERSION = "0.1.0";
|
|
15
|
+
|
|
16
|
+
// src/lib/log.ts
|
|
17
|
+
import pc from "picocolors";
|
|
18
|
+
var PREFIX = pc.bold(pc.magenta("ketoy"));
|
|
19
|
+
var log = {
|
|
20
|
+
info(msg) {
|
|
21
|
+
process.stdout.write(`${PREFIX} ${msg}
|
|
22
|
+
`);
|
|
23
|
+
},
|
|
24
|
+
success(msg) {
|
|
25
|
+
process.stdout.write(`${PREFIX} ${pc.green("\u2713")} ${msg}
|
|
26
|
+
`);
|
|
27
|
+
},
|
|
28
|
+
warn(msg) {
|
|
29
|
+
process.stderr.write(`${PREFIX} ${pc.yellow("!")} ${msg}
|
|
30
|
+
`);
|
|
31
|
+
},
|
|
32
|
+
error(msg) {
|
|
33
|
+
process.stderr.write(`${PREFIX} ${pc.red("\u2717")} ${msg}
|
|
34
|
+
`);
|
|
35
|
+
},
|
|
36
|
+
step(n, total, msg) {
|
|
37
|
+
process.stdout.write(`${PREFIX} ${pc.dim(`[${n}/${total}]`)} ${msg}
|
|
38
|
+
`);
|
|
39
|
+
},
|
|
40
|
+
detail(msg) {
|
|
41
|
+
process.stdout.write(` ${pc.dim(msg)}
|
|
42
|
+
`);
|
|
43
|
+
},
|
|
44
|
+
raw(msg) {
|
|
45
|
+
process.stdout.write(msg);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/lib/errors.ts
|
|
50
|
+
var KetoyCliError = class extends Error {
|
|
51
|
+
exitCode;
|
|
52
|
+
constructor(message, exitCode = 1) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "KetoyCliError";
|
|
55
|
+
this.exitCode = exitCode;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var UserAbortError = class extends KetoyCliError {
|
|
59
|
+
constructor(message = "Aborted by user") {
|
|
60
|
+
super(message, 130);
|
|
61
|
+
this.name = "UserAbortError";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var ConfigError = class extends KetoyCliError {
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message, 2);
|
|
67
|
+
this.name = "ConfigError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var DetectionError = class extends KetoyCliError {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message, 3);
|
|
73
|
+
this.name = "DetectionError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var NoInternetError = class extends KetoyCliError {
|
|
77
|
+
constructor(message) {
|
|
78
|
+
super(message, 4);
|
|
79
|
+
this.name = "NoInternetError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/lib/ketoy-version.ts
|
|
84
|
+
var VERSION_URL = process.env["KETOY_VERSION_URL"] ?? "https://ketoy.dev/v1/version";
|
|
85
|
+
var FETCH_TIMEOUT_MS = Number(process.env["KETOY_VERSION_TIMEOUT_MS"] ?? "5000");
|
|
86
|
+
var VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
87
|
+
var cached = null;
|
|
88
|
+
async function fetchVersion() {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetch(VERSION_URL, {
|
|
94
|
+
method: "GET",
|
|
95
|
+
headers: { accept: "text/plain, application/json;q=0.9" },
|
|
96
|
+
signal: controller.signal
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const err = e;
|
|
100
|
+
const offline = err.name === "AbortError" || err.code === "ENOTFOUND" || err.code === "ECONNREFUSED" || err.code === "ECONNRESET" || err.code === "ETIMEDOUT" || /fetch failed|network|getaddrinfo/i.test(err.message);
|
|
101
|
+
if (offline) {
|
|
102
|
+
throw new NoInternetError(
|
|
103
|
+
`No internet \u2014 cannot reach ${VERSION_URL}. The Ketoy version is fetched live and not embedded in the CLI; connect to the network and retry. (${err.message})`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
throw new KetoyCliError(`Failed to fetch Ketoy version from ${VERSION_URL}: ${err.message}`);
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new KetoyCliError(
|
|
112
|
+
`Ketoy version endpoint returned HTTP ${response.status} ${response.statusText} for ${VERSION_URL}.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const body = (await response.text()).trim();
|
|
116
|
+
if (!VERSION_RE.test(body)) {
|
|
117
|
+
throw new KetoyCliError(
|
|
118
|
+
`Ketoy version endpoint returned an unexpected body: ${JSON.stringify(body.slice(0, 80))}. Expected a semver string like "0.3.4-alpha".`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return body;
|
|
122
|
+
}
|
|
123
|
+
async function getKetoyVersion() {
|
|
124
|
+
if (cached !== null) return cached;
|
|
125
|
+
cached = fetchVersion().catch((e) => {
|
|
126
|
+
cached = null;
|
|
127
|
+
throw e;
|
|
128
|
+
});
|
|
129
|
+
return cached;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/config/store.ts
|
|
133
|
+
import { mkdir, readFile, writeFile, chmod, access } from "fs/promises";
|
|
134
|
+
import { dirname } from "path";
|
|
135
|
+
|
|
136
|
+
// src/config/schema.ts
|
|
137
|
+
import { z } from "zod";
|
|
138
|
+
var ProviderIdSchema = z.enum([
|
|
139
|
+
"anthropic",
|
|
140
|
+
"openai",
|
|
141
|
+
"google",
|
|
142
|
+
"mistral",
|
|
143
|
+
"groq",
|
|
144
|
+
"xai",
|
|
145
|
+
"openrouter",
|
|
146
|
+
"ollama"
|
|
147
|
+
]);
|
|
148
|
+
var GlobalConfigSchema = z.object({
|
|
149
|
+
model: z.string().default("anthropic:claude-sonnet-4-5"),
|
|
150
|
+
defaultProvider: ProviderIdSchema.default("anthropic"),
|
|
151
|
+
apiKeys: z.record(ProviderIdSchema, z.string()).default({}),
|
|
152
|
+
ollama: z.object({
|
|
153
|
+
baseUrl: z.string().default("http://localhost:11434")
|
|
154
|
+
}).default({ baseUrl: "http://localhost:11434" }),
|
|
155
|
+
agent: z.object({
|
|
156
|
+
maxSteps: z.number().int().min(1).max(200).default(50),
|
|
157
|
+
autoApproveReads: z.boolean().default(true),
|
|
158
|
+
autoApproveSafeBash: z.boolean().default(true)
|
|
159
|
+
}).default({ maxSteps: 50, autoApproveReads: true, autoApproveSafeBash: true })
|
|
160
|
+
});
|
|
161
|
+
var ProjectStateSchema = z.object({
|
|
162
|
+
ketoyVersion: z.string(),
|
|
163
|
+
initRanOn: z.string(),
|
|
164
|
+
hilt: z.boolean(),
|
|
165
|
+
applicationId: z.string(),
|
|
166
|
+
namespace: z.string(),
|
|
167
|
+
packagePath: z.string(),
|
|
168
|
+
bundlePath: z.string()
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// src/config/store.ts
|
|
172
|
+
var cached2 = null;
|
|
173
|
+
async function fileExists(path) {
|
|
174
|
+
try {
|
|
175
|
+
await access(path);
|
|
176
|
+
return true;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function loadConfig() {
|
|
182
|
+
if (cached2) return cached2;
|
|
183
|
+
if (!await fileExists(CONFIG_FILE)) {
|
|
184
|
+
cached2 = GlobalConfigSchema.parse({});
|
|
185
|
+
return cached2;
|
|
186
|
+
}
|
|
187
|
+
const raw = await readFile(CONFIG_FILE, "utf-8");
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
throw new ConfigError(`Malformed config at ${CONFIG_FILE}: ${e.message}`);
|
|
193
|
+
}
|
|
194
|
+
const result = GlobalConfigSchema.safeParse(parsed);
|
|
195
|
+
if (!result.success) {
|
|
196
|
+
throw new ConfigError(`Invalid config at ${CONFIG_FILE}: ${result.error.message}`);
|
|
197
|
+
}
|
|
198
|
+
cached2 = result.data;
|
|
199
|
+
return cached2;
|
|
200
|
+
}
|
|
201
|
+
async function saveConfig(config) {
|
|
202
|
+
await mkdir(dirname(CONFIG_FILE), { recursive: true, mode: 448 });
|
|
203
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
204
|
+
encoding: "utf-8",
|
|
205
|
+
mode: 384
|
|
206
|
+
});
|
|
207
|
+
await chmod(CONFIG_FILE, 384).catch(() => void 0);
|
|
208
|
+
cached2 = config;
|
|
209
|
+
}
|
|
210
|
+
async function setApiKey(provider, key) {
|
|
211
|
+
const config = await loadConfig();
|
|
212
|
+
const updated = {
|
|
213
|
+
...config,
|
|
214
|
+
apiKeys: { ...config.apiKeys, [provider]: key }
|
|
215
|
+
};
|
|
216
|
+
await saveConfig(updated);
|
|
217
|
+
}
|
|
218
|
+
async function removeApiKey(provider) {
|
|
219
|
+
const config = await loadConfig();
|
|
220
|
+
const { [provider]: _removed, ...rest } = config.apiKeys;
|
|
221
|
+
await saveConfig({ ...config, apiKeys: rest });
|
|
222
|
+
}
|
|
223
|
+
async function getApiKey(provider) {
|
|
224
|
+
const config = await loadConfig();
|
|
225
|
+
return config.apiKeys[provider];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/commands/version.ts
|
|
229
|
+
async function runVersionCommand() {
|
|
230
|
+
const config = await loadConfig();
|
|
231
|
+
log.info(`${pc.bold("ketoy")} ${CLI_VERSION}`);
|
|
232
|
+
const ketoyVersion = await getKetoyVersion();
|
|
233
|
+
log.detail(`Ketoy target: ${ketoyVersion}`);
|
|
234
|
+
log.detail(`Default model: ${config.model}`);
|
|
235
|
+
log.detail(`Node: ${process.version}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/commands/auth.ts
|
|
239
|
+
import { password, select, input } from "@inquirer/prompts";
|
|
240
|
+
|
|
241
|
+
// src/providers/registry.ts
|
|
242
|
+
var PROVIDERS = {
|
|
243
|
+
anthropic: {
|
|
244
|
+
id: "anthropic",
|
|
245
|
+
displayName: "Anthropic",
|
|
246
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
247
|
+
apiKeyHelp: "Get a key at https://console.anthropic.com/settings/keys",
|
|
248
|
+
signupUrl: "https://console.anthropic.com",
|
|
249
|
+
defaultModel: "claude-sonnet-4-5",
|
|
250
|
+
sampleModels: [
|
|
251
|
+
"claude-sonnet-4-5",
|
|
252
|
+
"claude-opus-4-1",
|
|
253
|
+
"claude-haiku-4-5"
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
openai: {
|
|
257
|
+
id: "openai",
|
|
258
|
+
displayName: "OpenAI",
|
|
259
|
+
envVar: "OPENAI_API_KEY",
|
|
260
|
+
apiKeyHelp: "Get a key at https://platform.openai.com/api-keys",
|
|
261
|
+
signupUrl: "https://platform.openai.com",
|
|
262
|
+
defaultModel: "gpt-4o",
|
|
263
|
+
sampleModels: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"]
|
|
264
|
+
},
|
|
265
|
+
google: {
|
|
266
|
+
id: "google",
|
|
267
|
+
displayName: "Google (Gemini)",
|
|
268
|
+
envVar: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
269
|
+
apiKeyHelp: "Get a key at https://aistudio.google.com/apikey",
|
|
270
|
+
signupUrl: "https://aistudio.google.com",
|
|
271
|
+
defaultModel: "gemini-2.0-flash-exp",
|
|
272
|
+
sampleModels: ["gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash"]
|
|
273
|
+
},
|
|
274
|
+
mistral: {
|
|
275
|
+
id: "mistral",
|
|
276
|
+
displayName: "Mistral",
|
|
277
|
+
envVar: "MISTRAL_API_KEY",
|
|
278
|
+
apiKeyHelp: "Get a key at https://console.mistral.ai/api-keys/",
|
|
279
|
+
signupUrl: "https://console.mistral.ai",
|
|
280
|
+
defaultModel: "mistral-large-latest",
|
|
281
|
+
sampleModels: ["mistral-large-latest", "mistral-small-latest", "codestral-latest"]
|
|
282
|
+
},
|
|
283
|
+
groq: {
|
|
284
|
+
id: "groq",
|
|
285
|
+
displayName: "Groq",
|
|
286
|
+
envVar: "GROQ_API_KEY",
|
|
287
|
+
apiKeyHelp: "Get a key at https://console.groq.com/keys",
|
|
288
|
+
signupUrl: "https://console.groq.com",
|
|
289
|
+
defaultModel: "llama-3.3-70b-versatile",
|
|
290
|
+
sampleModels: ["llama-3.3-70b-versatile", "llama-3.1-70b-versatile", "mixtral-8x7b-32768"]
|
|
291
|
+
},
|
|
292
|
+
xai: {
|
|
293
|
+
id: "xai",
|
|
294
|
+
displayName: "xAI (Grok)",
|
|
295
|
+
envVar: "XAI_API_KEY",
|
|
296
|
+
apiKeyHelp: "Get a key at https://console.x.ai",
|
|
297
|
+
signupUrl: "https://console.x.ai",
|
|
298
|
+
defaultModel: "grok-2-latest",
|
|
299
|
+
sampleModels: ["grok-2-latest", "grok-beta"]
|
|
300
|
+
},
|
|
301
|
+
openrouter: {
|
|
302
|
+
id: "openrouter",
|
|
303
|
+
displayName: "OpenRouter (any model)",
|
|
304
|
+
envVar: "OPENROUTER_API_KEY",
|
|
305
|
+
apiKeyHelp: "Get a key at https://openrouter.ai/keys",
|
|
306
|
+
signupUrl: "https://openrouter.ai",
|
|
307
|
+
defaultModel: "anthropic/claude-sonnet-4",
|
|
308
|
+
sampleModels: [
|
|
309
|
+
"anthropic/claude-sonnet-4",
|
|
310
|
+
"openai/gpt-4o",
|
|
311
|
+
"google/gemini-2.0-flash-exp",
|
|
312
|
+
"meta-llama/llama-3.1-405b-instruct"
|
|
313
|
+
]
|
|
314
|
+
},
|
|
315
|
+
ollama: {
|
|
316
|
+
id: "ollama",
|
|
317
|
+
displayName: "Ollama (local models)",
|
|
318
|
+
envVar: "",
|
|
319
|
+
apiKeyHelp: "No API key required \u2014 Ollama runs locally. Install at https://ollama.com",
|
|
320
|
+
signupUrl: "https://ollama.com",
|
|
321
|
+
defaultModel: "llama3.1:70b",
|
|
322
|
+
sampleModels: ["llama3.1:70b", "qwen2.5:32b", "codellama:34b"],
|
|
323
|
+
needsBaseUrl: true
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
function parseModelId(modelId) {
|
|
327
|
+
const idx = modelId.indexOf(":");
|
|
328
|
+
if (idx === -1) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Invalid model id "${modelId}". Use the form "<provider>:<name>" \u2014 e.g. "anthropic:claude-sonnet-4-5".`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const providerRaw = modelId.slice(0, idx);
|
|
334
|
+
const name = modelId.slice(idx + 1);
|
|
335
|
+
if (!(providerRaw in PROVIDERS)) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Unknown provider "${providerRaw}". Known providers: ${Object.keys(PROVIDERS).join(", ")}.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (!name) {
|
|
341
|
+
throw new Error(`Empty model name in "${modelId}". Use "<provider>:<name>".`);
|
|
342
|
+
}
|
|
343
|
+
return { provider: providerRaw, name };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/commands/auth.ts
|
|
347
|
+
async function runAuthCommand(args) {
|
|
348
|
+
const providerKeys = Object.keys(PROVIDERS);
|
|
349
|
+
let provider;
|
|
350
|
+
if (args.provider) {
|
|
351
|
+
if (!providerKeys.includes(args.provider)) {
|
|
352
|
+
throw new KetoyCliError(
|
|
353
|
+
`Unknown provider "${args.provider}". Known: ${providerKeys.join(", ")}.`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
provider = args.provider;
|
|
357
|
+
} else {
|
|
358
|
+
provider = await select({
|
|
359
|
+
message: "Choose a provider:",
|
|
360
|
+
choices: providerKeys.map((p) => ({
|
|
361
|
+
name: `${PROVIDERS[p].displayName} (${p})`,
|
|
362
|
+
value: p
|
|
363
|
+
}))
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const info = PROVIDERS[provider];
|
|
367
|
+
if (args.remove) {
|
|
368
|
+
await removeApiKey(provider);
|
|
369
|
+
log.success(`Removed credentials for ${info.displayName}.`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (provider === "ollama") {
|
|
373
|
+
const current = (await loadConfig()).ollama.baseUrl;
|
|
374
|
+
const base = await input({
|
|
375
|
+
message: "Ollama base URL:",
|
|
376
|
+
default: current
|
|
377
|
+
});
|
|
378
|
+
const config2 = await loadConfig();
|
|
379
|
+
await saveConfig({ ...config2, ollama: { baseUrl: base } });
|
|
380
|
+
log.success(`Ollama base URL saved: ${base}`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
log.info(info.apiKeyHelp);
|
|
384
|
+
const existing = await getApiKey(provider);
|
|
385
|
+
if (existing) {
|
|
386
|
+
log.detail(`A key for ${info.displayName} is already saved (ends in \u2026${existing.slice(-4)}).`);
|
|
387
|
+
}
|
|
388
|
+
const key = await password({
|
|
389
|
+
message: `${info.displayName} API key:`,
|
|
390
|
+
mask: "*",
|
|
391
|
+
validate: (v) => v.trim().length > 0 ? true : "API key cannot be empty."
|
|
392
|
+
});
|
|
393
|
+
await setApiKey(provider, key.trim());
|
|
394
|
+
const config = await loadConfig();
|
|
395
|
+
if (config.defaultProvider !== provider) {
|
|
396
|
+
log.info(
|
|
397
|
+
`Tip: run ${pc.bold(`ketoy config set model ${provider}:${info.defaultModel}`)} to use this provider by default.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
log.success(`Saved ${info.displayName} key to ${pc.dim("~/.ketoy-cli/config.json")} (mode 0600).`);
|
|
401
|
+
}
|
|
402
|
+
async function listAuthStatus() {
|
|
403
|
+
const config = await loadConfig();
|
|
404
|
+
const providerKeys = Object.keys(PROVIDERS);
|
|
405
|
+
log.info("Provider credentials:");
|
|
406
|
+
for (const p of providerKeys) {
|
|
407
|
+
const info = PROVIDERS[p];
|
|
408
|
+
const key = config.apiKeys[p];
|
|
409
|
+
const status = key ? pc.green(`set (\u2026${key.slice(-4)})`) : pc.dim("not set");
|
|
410
|
+
log.detail(`${info.displayName.padEnd(28)} ${status}`);
|
|
411
|
+
}
|
|
412
|
+
log.detail(`Default model: ${config.model}`);
|
|
413
|
+
try {
|
|
414
|
+
log.detail(`Will use model on next chat: ${parseModelId(config.model).provider}`);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
log.warn(
|
|
417
|
+
`Default model "${config.model}" is malformed (${e.message}). Run \`ketoy config set model <provider>:<name>\`.`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/commands/config.ts
|
|
423
|
+
async function runConfigList() {
|
|
424
|
+
const config = await loadConfig();
|
|
425
|
+
log.info("Config:");
|
|
426
|
+
log.detail(`model: ${config.model}`);
|
|
427
|
+
log.detail(`defaultProvider: ${config.defaultProvider}`);
|
|
428
|
+
log.detail(`ollama.baseUrl: ${config.ollama.baseUrl}`);
|
|
429
|
+
log.detail(`agent.maxSteps: ${config.agent.maxSteps}`);
|
|
430
|
+
log.detail(`agent.autoApproveReads: ${config.agent.autoApproveReads}`);
|
|
431
|
+
log.detail(`agent.autoApproveSafeBash: ${config.agent.autoApproveSafeBash}`);
|
|
432
|
+
log.detail(`Providers configured: ${Object.keys(config.apiKeys).join(", ") || pc.dim("(none)")}`);
|
|
433
|
+
}
|
|
434
|
+
var REDACTED_KEYS = /* @__PURE__ */ new Set(["apiKeys"]);
|
|
435
|
+
function isRedacted(key) {
|
|
436
|
+
const top = key.split(".")[0] ?? "";
|
|
437
|
+
return REDACTED_KEYS.has(top);
|
|
438
|
+
}
|
|
439
|
+
var FORBIDDEN_KEY_PARTS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
440
|
+
function assertSafeKey(key) {
|
|
441
|
+
for (const part of key.split(".")) {
|
|
442
|
+
if (FORBIDDEN_KEY_PARTS.has(part)) {
|
|
443
|
+
throw new KetoyCliError(`Refusing key "${key}" \u2014 reserved property name "${part}".`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async function runConfigGet(key) {
|
|
448
|
+
assertSafeKey(key);
|
|
449
|
+
if (isRedacted(key)) {
|
|
450
|
+
throw new KetoyCliError(
|
|
451
|
+
`Refusing to print "${key}" \u2014 contains secrets. Use \`ketoy auth --list\` for a redacted view.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const config = await loadConfig();
|
|
455
|
+
const value = readByDottedKey(config, key);
|
|
456
|
+
if (value === void 0) {
|
|
457
|
+
log.warn(`Unknown config key: ${key}`);
|
|
458
|
+
process.exitCode = 2;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
log.info(typeof value === "object" ? JSON.stringify(value, null, 2) : String(value));
|
|
462
|
+
}
|
|
463
|
+
async function runConfigSet(key, value) {
|
|
464
|
+
assertSafeKey(key);
|
|
465
|
+
if (isRedacted(key)) {
|
|
466
|
+
throw new KetoyCliError(
|
|
467
|
+
`Refusing to set "${key}" directly. Use \`ketoy auth <provider>\` to store API keys securely.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const config = await loadConfig();
|
|
471
|
+
const obj = JSON.parse(JSON.stringify(config));
|
|
472
|
+
const parsedValue = coerceValue(key, value);
|
|
473
|
+
writeByDottedKey(obj, key, parsedValue);
|
|
474
|
+
const reparsed = GlobalConfigSchema.parse(obj);
|
|
475
|
+
await saveConfig(reparsed);
|
|
476
|
+
log.success(`Updated ${pc.bold(key)} = ${JSON.stringify(parsedValue)}`);
|
|
477
|
+
}
|
|
478
|
+
function coerceValue(key, value) {
|
|
479
|
+
if (key === "model") {
|
|
480
|
+
parseModelId(value);
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
if (key === "defaultProvider") {
|
|
484
|
+
if (!(value in PROVIDERS)) {
|
|
485
|
+
throw new KetoyCliError(
|
|
486
|
+
`Unknown provider "${value}". Known: ${Object.keys(PROVIDERS).join(", ")}.`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return value;
|
|
490
|
+
}
|
|
491
|
+
if (value === "true") return true;
|
|
492
|
+
if (value === "false") return false;
|
|
493
|
+
if (/^-?\d+$/.test(value)) return Number(value);
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
function readByDottedKey(obj, key) {
|
|
497
|
+
return key.split(".").reduce((acc, part) => {
|
|
498
|
+
if (acc && typeof acc === "object" && Object.prototype.hasOwnProperty.call(acc, part)) {
|
|
499
|
+
return acc[part];
|
|
500
|
+
}
|
|
501
|
+
return void 0;
|
|
502
|
+
}, obj);
|
|
503
|
+
}
|
|
504
|
+
function writeByDottedKey(obj, key, value) {
|
|
505
|
+
const parts = key.split(".");
|
|
506
|
+
let cursor = obj;
|
|
507
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
508
|
+
const part = parts[i];
|
|
509
|
+
if (FORBIDDEN_KEY_PARTS.has(part)) {
|
|
510
|
+
throw new KetoyCliError(`Refusing key "${key}" \u2014 reserved property name "${part}".`);
|
|
511
|
+
}
|
|
512
|
+
const next = cursor[part];
|
|
513
|
+
if (next === void 0 || typeof next !== "object" || next === null) {
|
|
514
|
+
cursor[part] = /* @__PURE__ */ Object.create(null);
|
|
515
|
+
}
|
|
516
|
+
cursor = cursor[part];
|
|
517
|
+
}
|
|
518
|
+
const last = parts[parts.length - 1];
|
|
519
|
+
if (FORBIDDEN_KEY_PARTS.has(last)) {
|
|
520
|
+
throw new KetoyCliError(`Refusing key "${key}" \u2014 reserved property name "${last}".`);
|
|
521
|
+
}
|
|
522
|
+
cursor[last] = value;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/commands/init/index.ts
|
|
526
|
+
import { confirm } from "@inquirer/prompts";
|
|
527
|
+
|
|
528
|
+
// src/commands/init/detect.ts
|
|
529
|
+
import { readFile as readFile2, access as access2 } from "fs/promises";
|
|
530
|
+
import { join as join2 } from "path";
|
|
531
|
+
import { glob } from "tinyglobby";
|
|
532
|
+
async function fileExists2(p) {
|
|
533
|
+
try {
|
|
534
|
+
await access2(p);
|
|
535
|
+
return true;
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function detectProject(projectRoot) {
|
|
541
|
+
const settingsKts = join2(projectRoot, "settings.gradle.kts");
|
|
542
|
+
const settingsGradle = join2(projectRoot, "settings.gradle");
|
|
543
|
+
const rootSettings = await fileExists2(settingsKts) ? settingsKts : await fileExists2(settingsGradle) ? settingsGradle : null;
|
|
544
|
+
if (!rootSettings) {
|
|
545
|
+
throw new DetectionError(
|
|
546
|
+
"No settings.gradle.kts (or settings.gradle) at the project root. Run from an Android project root."
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (rootSettings.endsWith(".gradle")) {
|
|
550
|
+
throw new DetectionError(
|
|
551
|
+
"Groovy settings.gradle detected \u2014 Ketoy requires Kotlin DSL (settings.gradle.kts). Migrate the build files first."
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const appBuildKts = join2(projectRoot, "app", "build.gradle.kts");
|
|
555
|
+
if (!await fileExists2(appBuildKts)) {
|
|
556
|
+
throw new DetectionError(
|
|
557
|
+
`No app/build.gradle.kts found. Expected the Android app module at ${join2(projectRoot, "app")}.`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
const appBuildSource = await readFile2(appBuildKts, "utf-8");
|
|
561
|
+
const namespace = matchOne(appBuildSource, /namespace\s*=\s*"([\w.]+)"/);
|
|
562
|
+
const applicationId = matchOne(appBuildSource, /applicationId\s*=\s*"([\w.]+)"/);
|
|
563
|
+
const minSdkRaw = matchOne(appBuildSource, /minSdk\s*=\s*(\d+)/);
|
|
564
|
+
if (!namespace) throw new DetectionError(`Could not read android.namespace from ${appBuildKts}.`);
|
|
565
|
+
if (!applicationId) throw new DetectionError(`Could not read defaultConfig.applicationId from ${appBuildKts}.`);
|
|
566
|
+
if (!minSdkRaw) throw new DetectionError(`Could not read defaultConfig.minSdk from ${appBuildKts}.`);
|
|
567
|
+
const minSdk = Number(minSdkRaw);
|
|
568
|
+
const manifestPath = join2(projectRoot, "app", "src", "main", "AndroidManifest.xml");
|
|
569
|
+
if (!await fileExists2(manifestPath)) {
|
|
570
|
+
throw new DetectionError(`Missing AndroidManifest.xml at ${manifestPath}.`);
|
|
571
|
+
}
|
|
572
|
+
const packageDotPath = applicationId.replace(/\./g, "/");
|
|
573
|
+
const kotlinRoot = join2(projectRoot, "app", "src", "main", "kotlin", packageDotPath);
|
|
574
|
+
const javaRoot = join2(projectRoot, "app", "src", "main", "java", packageDotPath);
|
|
575
|
+
let packageSrcRoot;
|
|
576
|
+
let packagePath;
|
|
577
|
+
if (await fileExists2(kotlinRoot)) {
|
|
578
|
+
packageSrcRoot = "kotlin";
|
|
579
|
+
packagePath = kotlinRoot;
|
|
580
|
+
} else if (await fileExists2(javaRoot)) {
|
|
581
|
+
packageSrcRoot = "java";
|
|
582
|
+
packagePath = javaRoot;
|
|
583
|
+
} else {
|
|
584
|
+
packageSrcRoot = "kotlin";
|
|
585
|
+
packagePath = kotlinRoot;
|
|
586
|
+
}
|
|
587
|
+
const hasHilt = /dagger\.hilt|hilt\.android|@HiltAndroidApp/.test(appBuildSource) || await searchForHilt(projectRoot);
|
|
588
|
+
const manifestSource = await readFile2(manifestPath, "utf-8");
|
|
589
|
+
const appTagMatch = /<application\b([^>]*)>/.exec(manifestSource);
|
|
590
|
+
const appTagAttrs = appTagMatch ? appTagMatch[1] ?? "" : "";
|
|
591
|
+
const applicationNameAttr = matchOne(appTagAttrs, /android:name\s*=\s*"([.\w]+)"/);
|
|
592
|
+
const existingApplicationClassFq = applicationNameAttr ? applicationNameAttr.startsWith(".") ? applicationId + applicationNameAttr : applicationNameAttr : null;
|
|
593
|
+
const mainActivityCandidates = await glob("app/src/main/{java,kotlin}/**/MainActivity.kt", {
|
|
594
|
+
cwd: projectRoot,
|
|
595
|
+
absolute: true,
|
|
596
|
+
onlyFiles: true
|
|
597
|
+
});
|
|
598
|
+
const existingMainActivityPath = mainActivityCandidates[0] ?? null;
|
|
599
|
+
const rootBuildKts = join2(projectRoot, "build.gradle.kts");
|
|
600
|
+
const rootBuildGradlePath = await fileExists2(rootBuildKts) ? rootBuildKts : null;
|
|
601
|
+
const versionCatalog = join2(projectRoot, "gradle", "libs.versions.toml");
|
|
602
|
+
const versionCatalogPath = await fileExists2(versionCatalog) ? versionCatalog : null;
|
|
603
|
+
return {
|
|
604
|
+
projectRoot,
|
|
605
|
+
appModulePath: join2(projectRoot, "app"),
|
|
606
|
+
appModuleRelative: "app",
|
|
607
|
+
buildGradleKtsPath: appBuildKts,
|
|
608
|
+
manifestPath,
|
|
609
|
+
namespace,
|
|
610
|
+
applicationId,
|
|
611
|
+
minSdk,
|
|
612
|
+
packagePath,
|
|
613
|
+
packageSrcRoot,
|
|
614
|
+
hasHilt,
|
|
615
|
+
existingApplicationClassFq,
|
|
616
|
+
existingMainActivityPath,
|
|
617
|
+
rootSettingsPath: rootSettings,
|
|
618
|
+
rootBuildGradlePath,
|
|
619
|
+
versionCatalogPath
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function matchOne(source, re) {
|
|
623
|
+
const m = re.exec(source);
|
|
624
|
+
return m && m[1] !== void 0 ? m[1] : null;
|
|
625
|
+
}
|
|
626
|
+
async function searchForHilt(projectRoot) {
|
|
627
|
+
const matches = await glob("app/src/main/**/*.kt", {
|
|
628
|
+
cwd: projectRoot,
|
|
629
|
+
absolute: true,
|
|
630
|
+
onlyFiles: true
|
|
631
|
+
});
|
|
632
|
+
for (const f of matches) {
|
|
633
|
+
const content = await readFile2(f, "utf-8").catch(() => "");
|
|
634
|
+
if (/dagger\.hilt|@HiltAndroidApp/.test(content)) return true;
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/commands/init/plan.ts
|
|
640
|
+
import { join as join3, relative } from "path";
|
|
641
|
+
import { access as access3 } from "fs/promises";
|
|
642
|
+
|
|
643
|
+
// src/alpha-pins.ts
|
|
644
|
+
var ALPHA_PINS = [
|
|
645
|
+
// ── Compiler/toolchain ─────────────────────────────────────────────
|
|
646
|
+
{
|
|
647
|
+
key: "kotlin",
|
|
648
|
+
required: "2.0.21",
|
|
649
|
+
aliases: ["kotlin", "kotlinVersion"],
|
|
650
|
+
label: "Kotlin"
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
key: "agp",
|
|
654
|
+
required: "8.13.2",
|
|
655
|
+
aliases: ["agp", "androidGradlePlugin", "gradlePlugin"],
|
|
656
|
+
label: "Android Gradle Plugin"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
key: "composeBom",
|
|
660
|
+
required: "2024.10.00",
|
|
661
|
+
aliases: ["composeBom", "compose-bom", "composeBomVersion"],
|
|
662
|
+
label: "Jetpack Compose BOM"
|
|
663
|
+
},
|
|
664
|
+
// ── AndroidX libraries — newer releases require AGP 8.9+, which
|
|
665
|
+
// breaks against our pinned AGP. Pin to the latest release that
|
|
666
|
+
// works with AGP 8.13.x. Versions track KetoyVMTest's catalog.
|
|
667
|
+
{
|
|
668
|
+
key: "coreKtx",
|
|
669
|
+
required: "1.13.1",
|
|
670
|
+
aliases: ["coreKtx", "core-ktx", "androidxCoreKtx"],
|
|
671
|
+
label: "androidx.core:core-ktx"
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
key: "lifecycleRuntimeKtx",
|
|
675
|
+
required: "2.8.6",
|
|
676
|
+
aliases: ["lifecycleRuntimeKtx", "lifecycle-runtime-ktx", "androidxLifecycle"],
|
|
677
|
+
label: "androidx.lifecycle:lifecycle-runtime-ktx"
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
key: "activityCompose",
|
|
681
|
+
required: "1.9.3",
|
|
682
|
+
aliases: ["activityCompose", "activity-compose", "androidxActivityCompose"],
|
|
683
|
+
label: "androidx.activity:activity-compose"
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
key: "junitVersion",
|
|
687
|
+
required: "1.2.1",
|
|
688
|
+
aliases: ["junitVersion", "androidxJunit", "androidx-junit", "androidxJunitExt"],
|
|
689
|
+
label: "androidx.test.ext:junit"
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
key: "espressoCore",
|
|
693
|
+
required: "3.6.1",
|
|
694
|
+
aliases: ["espressoCore", "espresso-core", "androidxEspresso"],
|
|
695
|
+
label: "androidx.test.espresso:espresso-core"
|
|
696
|
+
}
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
// src/commands/init/plan.ts
|
|
700
|
+
async function fileExists3(p) {
|
|
701
|
+
try {
|
|
702
|
+
await access3(p);
|
|
703
|
+
return true;
|
|
704
|
+
} catch {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
var COMPILER_PLUGIN_ID = "dev.ketoy.compiler";
|
|
709
|
+
var APP_CLASS = "MyApplication";
|
|
710
|
+
var SCREEN_ENTRY_POINT = "HelloKetoyScreen";
|
|
711
|
+
var BUNDLE_ASSET = "ketoy/main.ktx";
|
|
712
|
+
async function planInit(detection, planOptions = { installKetoyScreen: true }) {
|
|
713
|
+
const warnings = [];
|
|
714
|
+
const actions = [];
|
|
715
|
+
const ketoyVersion = await getKetoyVersion();
|
|
716
|
+
if (detection.minSdk < 26) {
|
|
717
|
+
warnings.push(
|
|
718
|
+
`Project minSdk is ${detection.minSdk}; Ketoy requires API 26+. Bump minSdk to 26 (or higher) in app/build.gradle.kts before continuing.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
const root = detection.projectRoot;
|
|
722
|
+
const appBuildRel = relative(root, detection.buildGradleKtsPath);
|
|
723
|
+
const manifestRel = relative(root, detection.manifestPath);
|
|
724
|
+
const pkg = detection.applicationId;
|
|
725
|
+
const pkgDirRel = relative(root, detection.packagePath);
|
|
726
|
+
const versionCatalogRel = "gradle/libs.versions.toml";
|
|
727
|
+
const versionCatalogPath = join3(root, versionCatalogRel);
|
|
728
|
+
if (await fileExists3(versionCatalogPath)) {
|
|
729
|
+
const labels = ALPHA_PINS.map((p) => `${p.label}=${p.required}`).join(", ");
|
|
730
|
+
actions.push({
|
|
731
|
+
kind: "pin-alpha-versions",
|
|
732
|
+
description: `Pin alpha-required versions in ${versionCatalogRel}: ${labels}`,
|
|
733
|
+
targetRel: versionCatalogRel,
|
|
734
|
+
payload: { pins: ALPHA_PINS },
|
|
735
|
+
highRisk: true
|
|
736
|
+
});
|
|
737
|
+
} else {
|
|
738
|
+
const labels = ALPHA_PINS.map((p) => `${p.label} ${p.required}`).join(", ");
|
|
739
|
+
warnings.push(
|
|
740
|
+
`No gradle/libs.versions.toml found \u2014 verify your project uses ${labels} manually. Ketoy 0.3.x will not build against newer Kotlin/AGP/Compose. This pinning requirement is temporary and goes away with ADR-0004.`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
actions.push({
|
|
744
|
+
kind: "ensure-gradle-property",
|
|
745
|
+
description: "Ensure android.useAndroidX=true in gradle.properties (required by AGP 8.7)",
|
|
746
|
+
targetRel: "gradle.properties",
|
|
747
|
+
payload: { key: "android.useAndroidX", value: "true" },
|
|
748
|
+
highRisk: true
|
|
749
|
+
});
|
|
750
|
+
actions.push({
|
|
751
|
+
kind: "ensure-gradle-property",
|
|
752
|
+
description: "Ensure android.enableJetifier=false in gradle.properties",
|
|
753
|
+
targetRel: "gradle.properties",
|
|
754
|
+
payload: { key: "android.enableJetifier", value: "false" },
|
|
755
|
+
highRisk: true
|
|
756
|
+
});
|
|
757
|
+
actions.push({
|
|
758
|
+
kind: "agp8-compat",
|
|
759
|
+
description: `Rewrite block-style compileSdk { ... } in ${appBuildRel} to the AGP-8.x property form (compileSdk = N)`,
|
|
760
|
+
targetRel: appBuildRel,
|
|
761
|
+
highRisk: true
|
|
762
|
+
});
|
|
763
|
+
if (detection.versionCatalogPath) {
|
|
764
|
+
actions.push({
|
|
765
|
+
kind: "ensure-toml-plugin",
|
|
766
|
+
description: `Add kotlin-android plugin to [plugins] in gradle/libs.versions.toml`,
|
|
767
|
+
targetRel: relative(root, detection.versionCatalogPath),
|
|
768
|
+
payload: {
|
|
769
|
+
key: "kotlin-android",
|
|
770
|
+
id: "org.jetbrains.kotlin.android",
|
|
771
|
+
versionRef: "kotlin"
|
|
772
|
+
},
|
|
773
|
+
highRisk: true
|
|
774
|
+
});
|
|
775
|
+
} else {
|
|
776
|
+
warnings.push(
|
|
777
|
+
"No gradle/libs.versions.toml found \u2014 add the kotlin-android plugin alias manually."
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
if (detection.rootBuildGradlePath) {
|
|
781
|
+
actions.push({
|
|
782
|
+
kind: "insert-plugin-alias",
|
|
783
|
+
description: `Declare alias(libs.plugins.kotlin.android) apply false in root build.gradle.kts`,
|
|
784
|
+
targetRel: relative(root, detection.rootBuildGradlePath),
|
|
785
|
+
payload: {
|
|
786
|
+
aliasPath: "libs.plugins.kotlin.android",
|
|
787
|
+
applyFalse: true
|
|
788
|
+
},
|
|
789
|
+
highRisk: true
|
|
790
|
+
});
|
|
791
|
+
} else {
|
|
792
|
+
warnings.push(
|
|
793
|
+
"No root build.gradle.kts \u2014 add `alias(libs.plugins.kotlin.android) apply false` manually."
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
actions.push({
|
|
797
|
+
kind: "insert-plugin-alias",
|
|
798
|
+
description: `Insert alias(libs.plugins.kotlin.android) into plugins { } in ${appBuildRel}`,
|
|
799
|
+
targetRel: appBuildRel,
|
|
800
|
+
payload: {
|
|
801
|
+
aliasPath: "libs.plugins.kotlin.android",
|
|
802
|
+
applyFalse: false
|
|
803
|
+
},
|
|
804
|
+
highRisk: true
|
|
805
|
+
});
|
|
806
|
+
actions.push({
|
|
807
|
+
kind: "insert-plugin",
|
|
808
|
+
description: `Insert id("${COMPILER_PLUGIN_ID}") version "${ketoyVersion}" into plugins { } in ${appBuildRel}`,
|
|
809
|
+
targetRel: appBuildRel,
|
|
810
|
+
payload: { pluginId: COMPILER_PLUGIN_ID, version: ketoyVersion },
|
|
811
|
+
highRisk: true
|
|
812
|
+
});
|
|
813
|
+
const deps = [
|
|
814
|
+
{ configuration: "implementation", notation: `platform("dev.ketoy.vm:ketoy-bom:${ketoyVersion}")` },
|
|
815
|
+
{ configuration: "implementation", notation: `"dev.ketoy.vm:ketoy-runtime"` },
|
|
816
|
+
{ configuration: "implementation", notation: `"dev.ketoy.vm:ketoy-annotations"` },
|
|
817
|
+
{ configuration: "implementation", notation: `"dev.ketoy.vm:ketoy-capabilities-core"` },
|
|
818
|
+
{ configuration: "implementation", notation: `"dev.ketoy.vm:ketoy-capabilities-navigation"` },
|
|
819
|
+
{ configuration: "implementation", notation: `"dev.ketoy.vm:ketoy-adapters-material3"` }
|
|
820
|
+
];
|
|
821
|
+
actions.push({
|
|
822
|
+
kind: "append-deps",
|
|
823
|
+
description: `Append Ketoy ${ketoyVersion} dependencies to dependencies { } in ${appBuildRel}`,
|
|
824
|
+
targetRel: appBuildRel,
|
|
825
|
+
payload: { groupComment: `Ketoy ${ketoyVersion}`, entries: deps },
|
|
826
|
+
highRisk: true
|
|
827
|
+
});
|
|
828
|
+
const ketoyBlockBody = [
|
|
829
|
+
"// ADR-0003 inline-source app bundle: compile the @KetoyComposable",
|
|
830
|
+
"// closure inside this module into ONE signed .ktx at compileReleaseKotlin.",
|
|
831
|
+
"exportFromAppModule.set(true)",
|
|
832
|
+
'bundleId.set("main")',
|
|
833
|
+
'bundleVariant.set("release")',
|
|
834
|
+
'capabilityRegistryFile.set(file("ketoy-capabilities.json"))',
|
|
835
|
+
"// minimum host APK versionCode required to activate this bundle.",
|
|
836
|
+
"// 0 = universally compatible (default).",
|
|
837
|
+
"minAppVersion.set(0)",
|
|
838
|
+
"// Emit source line numbers for the dev overlay.",
|
|
839
|
+
"debugMode.set(true)",
|
|
840
|
+
"",
|
|
841
|
+
"// Optional: sign the bundle with Ed25519. Generate via:",
|
|
842
|
+
"// openssl genpkey -algorithm Ed25519 -outform DER -out key.der",
|
|
843
|
+
"// tail -c 32 key.der > app/keys/release-private.key",
|
|
844
|
+
"// Without a key the plugin emits an unsigned bundle gracefully.",
|
|
845
|
+
'val signingKey = file("keys/release-private.key")',
|
|
846
|
+
"if (signingKey.exists()) {",
|
|
847
|
+
" signingKeyFile.set(signingKey)",
|
|
848
|
+
"}"
|
|
849
|
+
].join("\n");
|
|
850
|
+
actions.push({
|
|
851
|
+
kind: "append-block",
|
|
852
|
+
description: `Append ketoy { ... } block to ${appBuildRel}`,
|
|
853
|
+
targetRel: appBuildRel,
|
|
854
|
+
payload: { blockName: "ketoy", body: ketoyBlockBody },
|
|
855
|
+
highRisk: true
|
|
856
|
+
});
|
|
857
|
+
actions.push({
|
|
858
|
+
kind: "bump-java-compatibility",
|
|
859
|
+
description: `Set compileOptions.{source,target}Compatibility to JavaVersion.VERSION_17 in ${appBuildRel}`,
|
|
860
|
+
targetRel: appBuildRel,
|
|
861
|
+
payload: { minLevel: 17 },
|
|
862
|
+
highRisk: true
|
|
863
|
+
});
|
|
864
|
+
const kotlinBlockBody = [
|
|
865
|
+
"compilerOptions {",
|
|
866
|
+
" jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)",
|
|
867
|
+
"}"
|
|
868
|
+
].join("\n");
|
|
869
|
+
actions.push({
|
|
870
|
+
kind: "append-block",
|
|
871
|
+
description: `Append kotlin { compilerOptions { jvmTarget = JVM_17 } } block to ${appBuildRel}`,
|
|
872
|
+
targetRel: appBuildRel,
|
|
873
|
+
payload: { blockName: "kotlin", body: kotlinBlockBody },
|
|
874
|
+
highRisk: true
|
|
875
|
+
});
|
|
876
|
+
if (detection.existingApplicationClassFq) {
|
|
877
|
+
warnings.push(
|
|
878
|
+
`Existing Application class detected: ${detection.existingApplicationClassFq}. We will NOT touch the manifest's android:name. Integrate the Ketoy runtime + bundle loader bootstrap into your existing Application class manually \u2014 see the new ${APP_CLASS}.kt example we will write next to it for the canonical setup.`
|
|
879
|
+
);
|
|
880
|
+
actions.push({
|
|
881
|
+
kind: "write-file",
|
|
882
|
+
description: `Create reference ${join3(pkgDirRel, `${APP_CLASS}.kt`)} (alongside your existing Application \u2014 copy bootstrap code into yours)`,
|
|
883
|
+
targetRel: join3(pkgDirRel, `${APP_CLASS}.kt`),
|
|
884
|
+
payload: { template: "MyApplication.kt.tmpl", vars: { PACKAGE: pkg } }
|
|
885
|
+
});
|
|
886
|
+
} else {
|
|
887
|
+
actions.push({
|
|
888
|
+
kind: "set-manifest-name",
|
|
889
|
+
description: `Set android:name=".${APP_CLASS}" on <application> in ${manifestRel}`,
|
|
890
|
+
targetRel: manifestRel,
|
|
891
|
+
payload: { value: `.${APP_CLASS}` },
|
|
892
|
+
highRisk: true
|
|
893
|
+
});
|
|
894
|
+
actions.push({
|
|
895
|
+
kind: "write-file",
|
|
896
|
+
description: `Create ${join3(pkgDirRel, `${APP_CLASS}.kt`)} (KetoyRuntime + KetoyConfig + adapter registration + bundle loader)`,
|
|
897
|
+
targetRel: join3(pkgDirRel, `${APP_CLASS}.kt`),
|
|
898
|
+
payload: { template: "MyApplication.kt.tmpl", vars: { PACKAGE: pkg } }
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (detection.existingMainActivityPath) {
|
|
902
|
+
if (planOptions.installKetoyScreen) {
|
|
903
|
+
const maRel = relative(root, detection.existingMainActivityPath);
|
|
904
|
+
actions.push({
|
|
905
|
+
kind: "wrap-main-activity",
|
|
906
|
+
description: `Install KetoyScreen + simple Hello Android fallback inside the theme block in ${maRel}`,
|
|
907
|
+
targetRel: maRel,
|
|
908
|
+
payload: {
|
|
909
|
+
packageOfApp: pkg,
|
|
910
|
+
appClassSimpleName: APP_CLASS,
|
|
911
|
+
entryPoint: SCREEN_ENTRY_POINT,
|
|
912
|
+
bundleAsset: BUNDLE_ASSET
|
|
913
|
+
},
|
|
914
|
+
highRisk: true
|
|
915
|
+
});
|
|
916
|
+
} else {
|
|
917
|
+
warnings.push(
|
|
918
|
+
"Skipping MainActivity edit (declined). Integrate KetoyScreen manually \u2014 see the snippet printed at the end of init, https://ketoy.dev/docs, or run `ketoy chat`."
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
} else if (planOptions.installKetoyScreen) {
|
|
922
|
+
actions.push({
|
|
923
|
+
kind: "write-file",
|
|
924
|
+
description: `Create ${join3(pkgDirRel, "MainActivity.kt")}`,
|
|
925
|
+
targetRel: join3(pkgDirRel, "MainActivity.kt"),
|
|
926
|
+
payload: { template: "MainActivity.kt.tmpl", vars: { PACKAGE: pkg } }
|
|
927
|
+
});
|
|
928
|
+
} else {
|
|
929
|
+
warnings.push(
|
|
930
|
+
"No MainActivity.kt found and KetoyScreen install declined \u2014 you must create one manually. See `ketoy chat` or the docs."
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
actions.push({
|
|
934
|
+
kind: "write-file",
|
|
935
|
+
description: `Create ${join3(pkgDirRel, `${SCREEN_ENTRY_POINT}.kt`)}`,
|
|
936
|
+
targetRel: join3(pkgDirRel, `${SCREEN_ENTRY_POINT}.kt`),
|
|
937
|
+
payload: { template: "HelloKetoyScreen.kt.tmpl", vars: { PACKAGE: pkg } }
|
|
938
|
+
});
|
|
939
|
+
actions.push({
|
|
940
|
+
kind: "write-file",
|
|
941
|
+
description: `Create app/ketoy-capabilities.json`,
|
|
942
|
+
targetRel: "app/ketoy-capabilities.json",
|
|
943
|
+
payload: { template: "ketoy-capabilities.json.tmpl", vars: { PACKAGE: pkg } }
|
|
944
|
+
});
|
|
945
|
+
actions.push({
|
|
946
|
+
kind: "gitignore",
|
|
947
|
+
description: "Add `**/keys/*-private.key` to .gitignore",
|
|
948
|
+
targetRel: ".gitignore",
|
|
949
|
+
payload: { entries: ["**/keys/*-private.key"] }
|
|
950
|
+
});
|
|
951
|
+
const finalActions = [];
|
|
952
|
+
for (const a of actions) {
|
|
953
|
+
if (a.kind === "write-file") {
|
|
954
|
+
const abs = join3(root, a.targetRel);
|
|
955
|
+
if (await fileExists3(abs)) {
|
|
956
|
+
warnings.push(`File already exists, skipping: ${a.targetRel}`);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
finalActions.push(a);
|
|
961
|
+
}
|
|
962
|
+
return { actions: finalActions, warnings };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/commands/init/edit.ts
|
|
966
|
+
import { mkdir as mkdir2, writeFile as writeFile10 } from "fs/promises";
|
|
967
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
968
|
+
|
|
969
|
+
// src/safe-edit/gradle-kts.ts
|
|
970
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
971
|
+
function findBlockBounds(source, header) {
|
|
972
|
+
const m = header.exec(source);
|
|
973
|
+
if (!m) return null;
|
|
974
|
+
const headerEnd = m.index + m[0].length;
|
|
975
|
+
let depth = 1;
|
|
976
|
+
let i = headerEnd;
|
|
977
|
+
while (i < source.length && depth > 0) {
|
|
978
|
+
const ch = source[i];
|
|
979
|
+
if (ch === "{") depth++;
|
|
980
|
+
else if (ch === "}") depth--;
|
|
981
|
+
if (depth === 0) return { open: headerEnd, close: i };
|
|
982
|
+
i++;
|
|
983
|
+
}
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
function indentationOf(line) {
|
|
987
|
+
const m = /^[ \t]*/.exec(line);
|
|
988
|
+
return m ? m[0] : "";
|
|
989
|
+
}
|
|
990
|
+
function insertInsideBlock(source, open, close, body) {
|
|
991
|
+
const before = source.slice(0, close);
|
|
992
|
+
const after = source.slice(close);
|
|
993
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
994
|
+
const closingLine = lastNewline >= 0 ? before.slice(lastNewline + 1) : before;
|
|
995
|
+
const baseIndent = indentationOf(closingLine);
|
|
996
|
+
const lineIndent = baseIndent + " ";
|
|
997
|
+
const indented = body.split("\n").map((l) => l.length === 0 ? l : lineIndent + l).join("\n");
|
|
998
|
+
const needsLeadingNewline = lastNewline === -1 || before[before.length - 1] !== "\n";
|
|
999
|
+
const prefix = needsLeadingNewline ? "\n" : "";
|
|
1000
|
+
return source.slice(0, close) + prefix + indented + "\n" + baseIndent + after.slice(0);
|
|
1001
|
+
}
|
|
1002
|
+
async function insertPlugin(path, pluginId, options = {}) {
|
|
1003
|
+
const source = await readFile3(path, "utf-8");
|
|
1004
|
+
if (new RegExp(`id\\s*\\(\\s*"${pluginId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*\\)`).test(source)) {
|
|
1005
|
+
return { changed: false };
|
|
1006
|
+
}
|
|
1007
|
+
const bounds = findBlockBounds(source, /\bplugins\s*\{/);
|
|
1008
|
+
if (!bounds) {
|
|
1009
|
+
throw new Error(`No top-level plugins { } block found in ${path}.`);
|
|
1010
|
+
}
|
|
1011
|
+
const line = options.version ? `id("${pluginId}") version "${options.version}"` : `id("${pluginId}")`;
|
|
1012
|
+
const updated = insertInsideBlock(source, bounds.open, bounds.close, line);
|
|
1013
|
+
await writeFile2(path, updated, "utf-8");
|
|
1014
|
+
return { changed: true };
|
|
1015
|
+
}
|
|
1016
|
+
async function appendDependencyGroup(path, groupComment, entries) {
|
|
1017
|
+
const source = await readFile3(path, "utf-8");
|
|
1018
|
+
const bounds = findBlockBounds(source, /\bdependencies\s*\{/);
|
|
1019
|
+
if (!bounds) {
|
|
1020
|
+
throw new Error(`No top-level dependencies { } block found in ${path}.`);
|
|
1021
|
+
}
|
|
1022
|
+
const missing = entries.filter((e) => !source.includes(e.notation));
|
|
1023
|
+
if (missing.length === 0) return { changed: false, addedCount: 0 };
|
|
1024
|
+
const lines = [`// ${groupComment}`];
|
|
1025
|
+
for (const e of missing) lines.push(`${e.configuration}(${e.notation})`);
|
|
1026
|
+
const body = lines.join("\n");
|
|
1027
|
+
const updated = insertInsideBlock(source, bounds.open, bounds.close, body);
|
|
1028
|
+
await writeFile2(path, updated, "utf-8");
|
|
1029
|
+
return { changed: true, addedCount: missing.length };
|
|
1030
|
+
}
|
|
1031
|
+
async function topLevelBlockExists(path, blockName) {
|
|
1032
|
+
const source = await readFile3(path, "utf-8");
|
|
1033
|
+
return new RegExp(`(^|\\n)\\s*${blockName}\\s*\\{`).test(source);
|
|
1034
|
+
}
|
|
1035
|
+
async function appendTopLevelBlock(path, blockName, body) {
|
|
1036
|
+
if (await topLevelBlockExists(path, blockName)) return { changed: false };
|
|
1037
|
+
const source = await readFile3(path, "utf-8");
|
|
1038
|
+
const trimmed = source.replace(/\s+$/, "");
|
|
1039
|
+
const block = `${blockName} {
|
|
1040
|
+
${body.split("\n").map((l) => l.length === 0 ? l : " " + l).join("\n")}
|
|
1041
|
+
}
|
|
1042
|
+
`;
|
|
1043
|
+
await writeFile2(path, trimmed + "\n\n" + block, "utf-8");
|
|
1044
|
+
return { changed: true };
|
|
1045
|
+
}
|
|
1046
|
+
async function insertPluginAlias(path, aliasPath, options = {}) {
|
|
1047
|
+
const source = await readFile3(path, "utf-8");
|
|
1048
|
+
const escaped = aliasPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1049
|
+
if (new RegExp(`alias\\s*\\(\\s*${escaped}\\s*\\)`).test(source)) {
|
|
1050
|
+
return { changed: false };
|
|
1051
|
+
}
|
|
1052
|
+
const bounds = findBlockBounds(source, /\bplugins\s*\{/);
|
|
1053
|
+
if (!bounds) {
|
|
1054
|
+
throw new Error(`No top-level plugins { } block found in ${path}.`);
|
|
1055
|
+
}
|
|
1056
|
+
const line = options.applyFalse ? `alias(${aliasPath}) apply false` : `alias(${aliasPath})`;
|
|
1057
|
+
const updated = insertInsideBlock(source, bounds.open, bounds.close, line);
|
|
1058
|
+
await writeFile2(path, updated, "utf-8");
|
|
1059
|
+
return { changed: true };
|
|
1060
|
+
}
|
|
1061
|
+
var JAVA_VERSION_NUM = {
|
|
1062
|
+
VERSION_1_8: 8,
|
|
1063
|
+
VERSION_9: 9,
|
|
1064
|
+
VERSION_10: 10,
|
|
1065
|
+
VERSION_11: 11,
|
|
1066
|
+
VERSION_12: 12,
|
|
1067
|
+
VERSION_13: 13,
|
|
1068
|
+
VERSION_14: 14,
|
|
1069
|
+
VERSION_15: 15,
|
|
1070
|
+
VERSION_16: 16,
|
|
1071
|
+
VERSION_17: 17,
|
|
1072
|
+
VERSION_18: 18,
|
|
1073
|
+
VERSION_19: 19,
|
|
1074
|
+
VERSION_20: 20,
|
|
1075
|
+
VERSION_21: 21
|
|
1076
|
+
};
|
|
1077
|
+
async function bumpJavaCompatibility(path, minLevel) {
|
|
1078
|
+
let source = await readFile3(path, "utf-8");
|
|
1079
|
+
const rewrites = [];
|
|
1080
|
+
for (const prop of ["sourceCompatibility", "targetCompatibility"]) {
|
|
1081
|
+
const re = new RegExp(`(\\b${prop}\\s*=\\s*JavaVersion\\.)(VERSION_[\\w_]+)`);
|
|
1082
|
+
const m = re.exec(source);
|
|
1083
|
+
if (m && m[2]) {
|
|
1084
|
+
const currentName = m[2];
|
|
1085
|
+
const current = JAVA_VERSION_NUM[currentName] ?? null;
|
|
1086
|
+
if (current === null || current >= minLevel) continue;
|
|
1087
|
+
const newName = `VERSION_${minLevel}`;
|
|
1088
|
+
source = source.slice(0, m.index) + m[1] + newName + source.slice(m.index + m[0].length);
|
|
1089
|
+
rewrites.push(`${prop} ${currentName} \u2192 ${newName}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const compileOptionsBlock = findBlockBounds(source, /\bcompileOptions\s*\{/);
|
|
1093
|
+
if (compileOptionsBlock) {
|
|
1094
|
+
for (const prop of ["sourceCompatibility", "targetCompatibility"]) {
|
|
1095
|
+
if (!new RegExp(`\\b${prop}\\s*=`).test(source.slice(compileOptionsBlock.open, compileOptionsBlock.close))) {
|
|
1096
|
+
const line = `${prop} = JavaVersion.VERSION_${minLevel}`;
|
|
1097
|
+
source = insertInsideBlock(source, compileOptionsBlock.open, compileOptionsBlock.close, line);
|
|
1098
|
+
rewrites.push(`added ${prop} = VERSION_${minLevel}`);
|
|
1099
|
+
const refreshed = findBlockBounds(source, /\bcompileOptions\s*\{/);
|
|
1100
|
+
if (refreshed) {
|
|
1101
|
+
compileOptionsBlock.open = refreshed.open;
|
|
1102
|
+
compileOptionsBlock.close = refreshed.close;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (rewrites.length === 0) {
|
|
1108
|
+
return { changed: false, rewrites: [] };
|
|
1109
|
+
}
|
|
1110
|
+
await writeFile2(path, source, "utf-8");
|
|
1111
|
+
return { changed: true, rewrites };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/safe-edit/manifest-xml.ts
|
|
1115
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1116
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1117
|
+
var APP_TAG_RE = /<application\b([^>]*)>/;
|
|
1118
|
+
async function setApplicationName(path, androidName) {
|
|
1119
|
+
const source = await readFile4(path, "utf-8");
|
|
1120
|
+
const match = APP_TAG_RE.exec(source);
|
|
1121
|
+
if (!match) {
|
|
1122
|
+
throw new Error(`No <application> tag found in ${path}.`);
|
|
1123
|
+
}
|
|
1124
|
+
const tagAttrs = match[1] ?? "";
|
|
1125
|
+
const tagStart = match.index;
|
|
1126
|
+
const tagEnd = tagStart + match[0].length;
|
|
1127
|
+
const nameAttr = /\bandroid:name\s*=\s*"([^"]*)"/.exec(tagAttrs);
|
|
1128
|
+
if (nameAttr && nameAttr[1] === androidName) {
|
|
1129
|
+
return { changed: false, previousValue: nameAttr[1] };
|
|
1130
|
+
}
|
|
1131
|
+
let newAttrs;
|
|
1132
|
+
let previousValue;
|
|
1133
|
+
if (nameAttr) {
|
|
1134
|
+
previousValue = nameAttr[1] ?? null;
|
|
1135
|
+
newAttrs = tagAttrs.replace(
|
|
1136
|
+
/\bandroid:name\s*=\s*"[^"]*"/,
|
|
1137
|
+
`android:name="${androidName}"`
|
|
1138
|
+
);
|
|
1139
|
+
} else {
|
|
1140
|
+
previousValue = null;
|
|
1141
|
+
const trimmed = tagAttrs.replace(/\s+$/, "");
|
|
1142
|
+
newAttrs = `${trimmed.length > 0 ? trimmed : ""}
|
|
1143
|
+
android:name="${androidName}"`;
|
|
1144
|
+
}
|
|
1145
|
+
const replacement = `<application${newAttrs}>`;
|
|
1146
|
+
const updated = source.slice(0, tagStart) + replacement + source.slice(tagEnd);
|
|
1147
|
+
await writeFile3(path, updated, "utf-8");
|
|
1148
|
+
return { changed: true, previousValue };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/safe-edit/gitignore.ts
|
|
1152
|
+
import { readFile as readFile5, writeFile as writeFile4, access as access4 } from "fs/promises";
|
|
1153
|
+
async function fileExists4(path) {
|
|
1154
|
+
try {
|
|
1155
|
+
await access4(path);
|
|
1156
|
+
return true;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async function ensureGitignoreEntries(path, entries) {
|
|
1162
|
+
const exists = await fileExists4(path);
|
|
1163
|
+
const source = exists ? await readFile5(path, "utf-8") : "";
|
|
1164
|
+
const lines = new Set(
|
|
1165
|
+
source.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"))
|
|
1166
|
+
);
|
|
1167
|
+
const added = entries.filter((e) => !lines.has(e.trim()));
|
|
1168
|
+
if (added.length === 0) return { added: [] };
|
|
1169
|
+
const prefix = source.length === 0 || source.endsWith("\n") ? "" : "\n";
|
|
1170
|
+
const block = "\n# Ketoy\n" + added.join("\n") + "\n";
|
|
1171
|
+
await writeFile4(path, source + prefix + block, "utf-8");
|
|
1172
|
+
return { added };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/safe-edit/main-activity-wrap.ts
|
|
1176
|
+
import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
1177
|
+
|
|
1178
|
+
// src/safe-edit/kotlin-file.ts
|
|
1179
|
+
import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
1180
|
+
async function addImport(path, importStatement) {
|
|
1181
|
+
const source = await readFile6(path, "utf-8");
|
|
1182
|
+
const target = `import ${importStatement}`;
|
|
1183
|
+
if (new RegExp(`^${target.replace(/\./g, "\\.")}\\b`, "m").test(source)) {
|
|
1184
|
+
return { changed: false };
|
|
1185
|
+
}
|
|
1186
|
+
const importBlockRe = /(?:^|\n)import\s+[\w.*]+\s*$/gm;
|
|
1187
|
+
let lastIdx = -1;
|
|
1188
|
+
for (let m; m = importBlockRe.exec(source); ) {
|
|
1189
|
+
lastIdx = m.index + m[0].length;
|
|
1190
|
+
}
|
|
1191
|
+
if (lastIdx >= 0) {
|
|
1192
|
+
const updated = source.slice(0, lastIdx) + `
|
|
1193
|
+
${target}` + source.slice(lastIdx);
|
|
1194
|
+
await writeFile5(path, updated, "utf-8");
|
|
1195
|
+
return { changed: true };
|
|
1196
|
+
}
|
|
1197
|
+
const pkgRe = /^package\s+[\w.]+\s*$/m;
|
|
1198
|
+
const pkg = pkgRe.exec(source);
|
|
1199
|
+
if (pkg) {
|
|
1200
|
+
const insertAt = pkg.index + pkg[0].length;
|
|
1201
|
+
const updated = source.slice(0, insertAt) + `
|
|
1202
|
+
|
|
1203
|
+
${target}` + source.slice(insertAt);
|
|
1204
|
+
await writeFile5(path, updated, "utf-8");
|
|
1205
|
+
return { changed: true };
|
|
1206
|
+
}
|
|
1207
|
+
await writeFile5(path, target + "\n\n" + source, "utf-8");
|
|
1208
|
+
return { changed: true };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/safe-edit/main-activity-wrap.ts
|
|
1212
|
+
var MARKER = "LocalKetoyRuntime provides app.ketoyRuntime";
|
|
1213
|
+
async function wrapMainActivityWithKetoyScreen(path, options) {
|
|
1214
|
+
let source = await readFile7(path, "utf-8");
|
|
1215
|
+
if (source.includes(MARKER)) {
|
|
1216
|
+
return { changed: false, reason: "Already wrapped" };
|
|
1217
|
+
}
|
|
1218
|
+
const setContentMatch = /setContent\s*\{/.exec(source);
|
|
1219
|
+
if (!setContentMatch) {
|
|
1220
|
+
return { changed: false, reason: "No setContent { \u2026 } block found in MainActivity" };
|
|
1221
|
+
}
|
|
1222
|
+
const setContentOpen = setContentMatch.index + setContentMatch[0].length;
|
|
1223
|
+
const setContentClose = findMatchingBrace(source, setContentOpen);
|
|
1224
|
+
if (setContentClose === -1) {
|
|
1225
|
+
return { changed: false, reason: "Could not balance braces in setContent { \u2026 }" };
|
|
1226
|
+
}
|
|
1227
|
+
const setContentBody = source.slice(setContentOpen, setContentClose);
|
|
1228
|
+
const themeRe = /\b([A-Z][A-Za-z0-9_]*Theme)\s*\{/;
|
|
1229
|
+
const themeMatch = themeRe.exec(setContentBody);
|
|
1230
|
+
if (themeMatch) {
|
|
1231
|
+
const themeOpenAbs = setContentOpen + themeMatch.index + themeMatch[0].length;
|
|
1232
|
+
const themeCloseAbs = findMatchingBrace(source, themeOpenAbs);
|
|
1233
|
+
if (themeCloseAbs === -1) {
|
|
1234
|
+
return { changed: false, reason: "Could not balance theme braces" };
|
|
1235
|
+
}
|
|
1236
|
+
const themeStartAbs = setContentOpen + themeMatch.index;
|
|
1237
|
+
const themeOuterIndent = lineIndentBefore(source, themeStartAbs);
|
|
1238
|
+
const themeBodyIndent = themeOuterIndent + " ";
|
|
1239
|
+
const snippet = buildKetoyScreenSnippet(themeBodyIndent, options);
|
|
1240
|
+
source = source.slice(0, themeOpenAbs) + "\n" + snippet + "\n" + themeOuterIndent + source.slice(themeCloseAbs);
|
|
1241
|
+
} else {
|
|
1242
|
+
const setContentLineIndent = lineIndentBefore(source, setContentMatch.index);
|
|
1243
|
+
const bodyIndent = setContentLineIndent + " ";
|
|
1244
|
+
const snippet = buildKetoyScreenSnippet(bodyIndent, options);
|
|
1245
|
+
source = source.slice(0, setContentOpen) + "\n" + snippet + "\n" + setContentLineIndent + source.slice(setContentClose);
|
|
1246
|
+
}
|
|
1247
|
+
const appAssignment = `val app = application as ${options.appClassSimpleName}`;
|
|
1248
|
+
if (!source.includes(appAssignment)) {
|
|
1249
|
+
const superMatch = /super\.onCreate\s*\([^)]*\)\s*\n/.exec(source);
|
|
1250
|
+
if (superMatch) {
|
|
1251
|
+
const insertAt = superMatch.index + superMatch[0].length;
|
|
1252
|
+
const indent = lineIndentBefore(source, superMatch.index);
|
|
1253
|
+
source = source.slice(0, insertAt) + `${indent}${appAssignment}
|
|
1254
|
+
` + source.slice(insertAt);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
await writeFile6(path, source, "utf-8");
|
|
1258
|
+
const imports = [
|
|
1259
|
+
"androidx.compose.foundation.layout.Arrangement",
|
|
1260
|
+
"androidx.compose.foundation.layout.Column",
|
|
1261
|
+
"androidx.compose.foundation.layout.fillMaxSize",
|
|
1262
|
+
"androidx.compose.material3.Text",
|
|
1263
|
+
"androidx.compose.runtime.CompositionLocalProvider",
|
|
1264
|
+
"androidx.compose.ui.Alignment",
|
|
1265
|
+
"androidx.compose.ui.Modifier",
|
|
1266
|
+
"dev.ketoy.runtime.bundle.KetoyBundleSource",
|
|
1267
|
+
"dev.ketoy.runtime.compose.KetoyScreen",
|
|
1268
|
+
"dev.ketoy.runtime.compose.LocalKetoyBundleLoader",
|
|
1269
|
+
"dev.ketoy.runtime.compose.LocalKetoyRuntime",
|
|
1270
|
+
`${options.packageOfApp}.${options.appClassSimpleName}`
|
|
1271
|
+
];
|
|
1272
|
+
for (const imp of imports) {
|
|
1273
|
+
await addImport(path, imp);
|
|
1274
|
+
}
|
|
1275
|
+
return { changed: true };
|
|
1276
|
+
}
|
|
1277
|
+
function ketoyScreenManualSnippet(options) {
|
|
1278
|
+
return [
|
|
1279
|
+
`// In MainActivity.onCreate:`,
|
|
1280
|
+
`val app = application as ${options.appClassSimpleName}`,
|
|
1281
|
+
``,
|
|
1282
|
+
`// Inside your existing setContent { <YourAppTheme> { \u2026 } } block, replace`,
|
|
1283
|
+
`// the theme's body with:`,
|
|
1284
|
+
buildKetoyScreenSnippet("", options)
|
|
1285
|
+
].join("\n");
|
|
1286
|
+
}
|
|
1287
|
+
function buildKetoyScreenSnippet(indent, options) {
|
|
1288
|
+
return [
|
|
1289
|
+
`${indent}CompositionLocalProvider(`,
|
|
1290
|
+
`${indent} LocalKetoyRuntime provides app.ketoyRuntime,`,
|
|
1291
|
+
`${indent} LocalKetoyBundleLoader provides app.ketoyBundleLoader,`,
|
|
1292
|
+
`${indent}) {`,
|
|
1293
|
+
`${indent} KetoyScreen(`,
|
|
1294
|
+
`${indent} entryPoint = "${options.entryPoint}",`,
|
|
1295
|
+
`${indent} bundleSource = KetoyBundleSource.Asset("${options.bundleAsset}"),`,
|
|
1296
|
+
`${indent} ) {`,
|
|
1297
|
+
`${indent} // Native fallback \u2014 rendered when the .ktx bundle is absent,`,
|
|
1298
|
+
`${indent} // incompatible, or corrupt. Replace with your own native screen.`,
|
|
1299
|
+
`${indent} Column(`,
|
|
1300
|
+
`${indent} modifier = Modifier.fillMaxSize(),`,
|
|
1301
|
+
`${indent} verticalArrangement = Arrangement.Center,`,
|
|
1302
|
+
`${indent} horizontalAlignment = Alignment.CenterHorizontally,`,
|
|
1303
|
+
`${indent} ) {`,
|
|
1304
|
+
`${indent} Text("Hello Android")`,
|
|
1305
|
+
`${indent} }`,
|
|
1306
|
+
`${indent} }`,
|
|
1307
|
+
`${indent}}`
|
|
1308
|
+
].join("\n");
|
|
1309
|
+
}
|
|
1310
|
+
function findMatchingBrace(source, afterOpen) {
|
|
1311
|
+
let depth = 1;
|
|
1312
|
+
for (let i = afterOpen; i < source.length; i++) {
|
|
1313
|
+
const ch = source[i];
|
|
1314
|
+
if (ch === "{") depth++;
|
|
1315
|
+
else if (ch === "}") {
|
|
1316
|
+
depth--;
|
|
1317
|
+
if (depth === 0) return i;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return -1;
|
|
1321
|
+
}
|
|
1322
|
+
function lineIndentBefore(source, pos) {
|
|
1323
|
+
const lineStart = source.lastIndexOf("\n", Math.max(0, pos - 1)) + 1;
|
|
1324
|
+
const lineUpTo = source.slice(lineStart, pos);
|
|
1325
|
+
const m = /^[ \t]*/.exec(lineUpTo);
|
|
1326
|
+
return m ? m[0] : "";
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/safe-edit/version-catalog.ts
|
|
1330
|
+
import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
|
|
1331
|
+
async function applyVersionPins(path, pins) {
|
|
1332
|
+
let source = await readFile8(path, "utf-8");
|
|
1333
|
+
let section = findVersionsSection(source);
|
|
1334
|
+
if (!section) {
|
|
1335
|
+
throw new Error(`No [versions] section in ${path}.`);
|
|
1336
|
+
}
|
|
1337
|
+
const results = [];
|
|
1338
|
+
for (const pin of pins) {
|
|
1339
|
+
const candidates = pin.aliases ?? [pin.key];
|
|
1340
|
+
let found = null;
|
|
1341
|
+
for (const k of candidates) {
|
|
1342
|
+
const m = findKeyInRange(source, section, k);
|
|
1343
|
+
if (m) {
|
|
1344
|
+
found = m;
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (found) {
|
|
1349
|
+
if (found.value === pin.required) {
|
|
1350
|
+
results.push({
|
|
1351
|
+
key: found.key,
|
|
1352
|
+
required: pin.required,
|
|
1353
|
+
oldValue: found.value,
|
|
1354
|
+
action: "unchanged",
|
|
1355
|
+
label: pin.label
|
|
1356
|
+
});
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
const newLine = `${found.indent}${found.key} = "${pin.required}"`;
|
|
1360
|
+
source = source.slice(0, found.lineStart) + newLine + source.slice(found.lineEnd);
|
|
1361
|
+
section = findVersionsSection(source) ?? section;
|
|
1362
|
+
results.push({
|
|
1363
|
+
key: found.key,
|
|
1364
|
+
required: pin.required,
|
|
1365
|
+
oldValue: found.value,
|
|
1366
|
+
action: "pinned",
|
|
1367
|
+
label: pin.label
|
|
1368
|
+
});
|
|
1369
|
+
} else {
|
|
1370
|
+
const before = source.slice(0, section.end);
|
|
1371
|
+
const after = source.slice(section.end);
|
|
1372
|
+
const lead = before.endsWith("\n") ? "" : "\n";
|
|
1373
|
+
const newLine = `${lead}${pin.key} = "${pin.required}"
|
|
1374
|
+
`;
|
|
1375
|
+
source = before + newLine + after;
|
|
1376
|
+
section = findVersionsSection(source) ?? section;
|
|
1377
|
+
results.push({
|
|
1378
|
+
key: pin.key,
|
|
1379
|
+
required: pin.required,
|
|
1380
|
+
oldValue: null,
|
|
1381
|
+
action: "added",
|
|
1382
|
+
label: pin.label
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
await writeFile7(path, source, "utf-8");
|
|
1387
|
+
return results;
|
|
1388
|
+
}
|
|
1389
|
+
function findVersionsSection(source) {
|
|
1390
|
+
return findSection(source, "versions");
|
|
1391
|
+
}
|
|
1392
|
+
function findSection(source, name) {
|
|
1393
|
+
const header = new RegExp(`^\\[${escapeRegex(name)}\\]\\s*$`, "m").exec(source);
|
|
1394
|
+
if (!header) return null;
|
|
1395
|
+
const start = header.index + header[0].length;
|
|
1396
|
+
const nextHeaderRe = /\n\[[^\]]+\]/g;
|
|
1397
|
+
nextHeaderRe.lastIndex = start;
|
|
1398
|
+
const next = nextHeaderRe.exec(source);
|
|
1399
|
+
const end = next ? next.index : source.length;
|
|
1400
|
+
return { start, end };
|
|
1401
|
+
}
|
|
1402
|
+
async function ensurePluginEntry(path, options) {
|
|
1403
|
+
let source = await readFile8(path, "utf-8");
|
|
1404
|
+
let section = findSection(source, "plugins");
|
|
1405
|
+
if (!section) {
|
|
1406
|
+
const trimmed = source.replace(/\s+$/, "");
|
|
1407
|
+
const newBlock = `
|
|
1408
|
+
|
|
1409
|
+
[plugins]
|
|
1410
|
+
${options.key} = { id = "${options.id}", version.ref = "${options.versionRef}" }
|
|
1411
|
+
`;
|
|
1412
|
+
await writeFile7(path, trimmed + newBlock, "utf-8");
|
|
1413
|
+
return { changed: true };
|
|
1414
|
+
}
|
|
1415
|
+
const region = source.slice(section.start, section.end);
|
|
1416
|
+
const idRe = new RegExp(`\\bid\\s*=\\s*"${escapeRegex(options.id)}"`);
|
|
1417
|
+
const keyRe = new RegExp(`(^|\\n)\\s*${escapeRegex(options.key)}\\s*=`);
|
|
1418
|
+
if (idRe.test(region) || keyRe.test(region)) {
|
|
1419
|
+
return { changed: false, reason: "Plugin already declared in [plugins]" };
|
|
1420
|
+
}
|
|
1421
|
+
const before = source.slice(0, section.end);
|
|
1422
|
+
const after = source.slice(section.end);
|
|
1423
|
+
const lead = before.endsWith("\n") ? "" : "\n";
|
|
1424
|
+
const newLine = `${lead}${options.key} = { id = "${options.id}", version.ref = "${options.versionRef}" }
|
|
1425
|
+
`;
|
|
1426
|
+
source = before + newLine + after;
|
|
1427
|
+
await writeFile7(path, source, "utf-8");
|
|
1428
|
+
return { changed: true };
|
|
1429
|
+
}
|
|
1430
|
+
function escapeRegex(s) {
|
|
1431
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1432
|
+
}
|
|
1433
|
+
function findKeyInRange(source, section, key) {
|
|
1434
|
+
const region = source.slice(section.start, section.end);
|
|
1435
|
+
const re = new RegExp(
|
|
1436
|
+
`(^|\\n)([ \\t]*)(${escapeRegex(key)})[ \\t]*=[ \\t]*"([^"]*)"`,
|
|
1437
|
+
""
|
|
1438
|
+
);
|
|
1439
|
+
const m = re.exec(region);
|
|
1440
|
+
if (!m) return null;
|
|
1441
|
+
const leadingNl = m[1] ?? "";
|
|
1442
|
+
const indent = m[2] ?? "";
|
|
1443
|
+
const matchedKey = m[3] ?? "";
|
|
1444
|
+
const value = m[4] ?? "";
|
|
1445
|
+
const lineStart = section.start + m.index + leadingNl.length;
|
|
1446
|
+
const lineEnd = lineStart + indent.length + matchedKey.length + ' = "'.length + value.length + 1;
|
|
1447
|
+
return { key: matchedKey, value, indent, lineStart, lineEnd };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// src/safe-edit/agp8-compat.ts
|
|
1451
|
+
import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
1452
|
+
async function migrateAgp8AndroidBlock(path) {
|
|
1453
|
+
const original = await readFile9(path, "utf-8");
|
|
1454
|
+
const { source, mutations } = transformSource(original);
|
|
1455
|
+
if (mutations.length === 0) {
|
|
1456
|
+
return { mutations: [], changed: false };
|
|
1457
|
+
}
|
|
1458
|
+
await writeFile8(path, source, "utf-8");
|
|
1459
|
+
return { mutations, changed: true };
|
|
1460
|
+
}
|
|
1461
|
+
function transformSource(input3) {
|
|
1462
|
+
let source = input3;
|
|
1463
|
+
const mutations = [];
|
|
1464
|
+
for (const property of ["compileSdk", "targetSdk"]) {
|
|
1465
|
+
const result = transformOne(source, property);
|
|
1466
|
+
if (result) {
|
|
1467
|
+
source = result.source;
|
|
1468
|
+
mutations.push(result.mutation);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
return { source, mutations };
|
|
1472
|
+
}
|
|
1473
|
+
function transformOne(source, property) {
|
|
1474
|
+
const blockHeader = new RegExp(`(^|\\n)([ \\t]*)${escape(property)}\\s*\\{`, "g");
|
|
1475
|
+
const match = blockHeader.exec(source);
|
|
1476
|
+
if (!match) return null;
|
|
1477
|
+
const headerStart = match.index + (match[1] ?? "").length;
|
|
1478
|
+
const indent = match[2] ?? "";
|
|
1479
|
+
const openBraceIdx = match.index + match[0].length - 1;
|
|
1480
|
+
const closeBraceIdx = findMatchingBrace2(source, openBraceIdx + 1);
|
|
1481
|
+
if (closeBraceIdx === -1) return null;
|
|
1482
|
+
const body = source.slice(openBraceIdx + 1, closeBraceIdx);
|
|
1483
|
+
const releaseMatch = /\brelease\s*\(\s*(\d+)\s*\)/.exec(body);
|
|
1484
|
+
if (!releaseMatch) {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
const apiLevel = Number(releaseMatch[1]);
|
|
1488
|
+
const droppedMinor = /\bminorApiLevel\s*=\s*\d+/.test(body);
|
|
1489
|
+
const replacement = `${indent}${property} = ${apiLevel}`;
|
|
1490
|
+
const updated = source.slice(0, headerStart) + replacement + source.slice(closeBraceIdx + 1);
|
|
1491
|
+
return {
|
|
1492
|
+
source: updated,
|
|
1493
|
+
mutation: { property, apiLevel, droppedMinor }
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function findMatchingBrace2(source, after) {
|
|
1497
|
+
let depth = 1;
|
|
1498
|
+
for (let i = after; i < source.length; i++) {
|
|
1499
|
+
const ch = source[i];
|
|
1500
|
+
if (ch === "{") depth++;
|
|
1501
|
+
else if (ch === "}") {
|
|
1502
|
+
depth--;
|
|
1503
|
+
if (depth === 0) return i;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return -1;
|
|
1507
|
+
}
|
|
1508
|
+
function escape(s) {
|
|
1509
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/safe-edit/gradle-properties.ts
|
|
1513
|
+
import { readFile as readFile10, writeFile as writeFile9, access as access5 } from "fs/promises";
|
|
1514
|
+
async function fileExists5(p) {
|
|
1515
|
+
try {
|
|
1516
|
+
await access5(p);
|
|
1517
|
+
return true;
|
|
1518
|
+
} catch {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
async function ensureGradleProperty(path, key, value) {
|
|
1523
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1524
|
+
const exists = await fileExists5(path);
|
|
1525
|
+
const source = exists ? await readFile10(path, "utf-8") : "";
|
|
1526
|
+
const lineRe = new RegExp(`^(${escaped})\\s*=\\s*(.*)$`, "m");
|
|
1527
|
+
const match = lineRe.exec(source);
|
|
1528
|
+
if (match) {
|
|
1529
|
+
const currentValue = (match[2] ?? "").trim();
|
|
1530
|
+
if (currentValue === value) {
|
|
1531
|
+
return { changed: false, previousValue: currentValue, conflict: false };
|
|
1532
|
+
}
|
|
1533
|
+
return { changed: false, previousValue: currentValue, conflict: true };
|
|
1534
|
+
}
|
|
1535
|
+
const prefix = source.length === 0 || source.endsWith("\n") ? "" : "\n";
|
|
1536
|
+
const block = `${prefix}
|
|
1537
|
+
# Ketoy alpha \u2014 required for AGP 8.7 compatibility
|
|
1538
|
+
${key}=${value}
|
|
1539
|
+
`;
|
|
1540
|
+
await writeFile9(path, source + block, "utf-8");
|
|
1541
|
+
return { changed: true, previousValue: null, conflict: false };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/templates.ts
|
|
1545
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
1546
|
+
import { dirname as dirname2, join as join4, resolve } from "path";
|
|
1547
|
+
import { fileURLToPath } from "url";
|
|
1548
|
+
var cachedRoot = null;
|
|
1549
|
+
async function getTemplatesRoot() {
|
|
1550
|
+
if (cachedRoot) return cachedRoot;
|
|
1551
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
1552
|
+
const candidates = [
|
|
1553
|
+
resolve(here, "..", "templates"),
|
|
1554
|
+
resolve(here, "..", "..", "templates")
|
|
1555
|
+
];
|
|
1556
|
+
for (const c of candidates) {
|
|
1557
|
+
try {
|
|
1558
|
+
await readFile11(join4(c, "MyApplication.kt.tmpl"), "utf-8");
|
|
1559
|
+
cachedRoot = c;
|
|
1560
|
+
return c;
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
throw new Error("Could not locate templates directory.");
|
|
1565
|
+
}
|
|
1566
|
+
async function loadTemplate(name, vars) {
|
|
1567
|
+
const root = await getTemplatesRoot();
|
|
1568
|
+
const raw = await readFile11(join4(root, name), "utf-8");
|
|
1569
|
+
return Object.entries(vars).reduce(
|
|
1570
|
+
(acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v),
|
|
1571
|
+
raw
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/commands/init/edit.ts
|
|
1576
|
+
async function applyPlan(projectRoot, plan) {
|
|
1577
|
+
const result = { appliedCount: 0, skipped: [], errors: [] };
|
|
1578
|
+
let step = 0;
|
|
1579
|
+
const total = plan.actions.length;
|
|
1580
|
+
for (const action of plan.actions) {
|
|
1581
|
+
step++;
|
|
1582
|
+
log.step(step, total, action.description);
|
|
1583
|
+
try {
|
|
1584
|
+
const ok = await applyAction(projectRoot, action);
|
|
1585
|
+
if (ok) result.appliedCount++;
|
|
1586
|
+
else result.skipped.push(action.description);
|
|
1587
|
+
} catch (e) {
|
|
1588
|
+
const msg = `${action.description}: ${e.message}`;
|
|
1589
|
+
result.errors.push(msg);
|
|
1590
|
+
log.error(msg);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return result;
|
|
1594
|
+
}
|
|
1595
|
+
async function applyAction(projectRoot, action) {
|
|
1596
|
+
const target = join5(projectRoot, action.targetRel);
|
|
1597
|
+
switch (action.kind) {
|
|
1598
|
+
case "insert-plugin": {
|
|
1599
|
+
const { pluginId, version } = action.payload;
|
|
1600
|
+
const { changed } = await insertPlugin(target, pluginId, version ? { version } : {});
|
|
1601
|
+
if (!changed) log.detail("Already applied; skipping.");
|
|
1602
|
+
return changed;
|
|
1603
|
+
}
|
|
1604
|
+
case "append-deps": {
|
|
1605
|
+
const { groupComment, entries } = action.payload;
|
|
1606
|
+
const { changed, addedCount } = await appendDependencyGroup(target, groupComment, entries);
|
|
1607
|
+
if (!changed) log.detail("All dependencies already declared; skipping.");
|
|
1608
|
+
else log.detail(`Added ${addedCount} dependency entries.`);
|
|
1609
|
+
return changed;
|
|
1610
|
+
}
|
|
1611
|
+
case "append-block": {
|
|
1612
|
+
const { blockName, body } = action.payload;
|
|
1613
|
+
const { changed } = await appendTopLevelBlock(target, blockName, body);
|
|
1614
|
+
if (!changed) log.detail(`${blockName} { } block already present; skipping.`);
|
|
1615
|
+
return changed;
|
|
1616
|
+
}
|
|
1617
|
+
case "set-manifest-name": {
|
|
1618
|
+
const { value } = action.payload;
|
|
1619
|
+
const { changed, previousValue } = await setApplicationName(target, value);
|
|
1620
|
+
if (!changed) log.detail("Already set; skipping.");
|
|
1621
|
+
else if (previousValue) log.detail(`Replaced previous value: ${previousValue}`);
|
|
1622
|
+
return changed;
|
|
1623
|
+
}
|
|
1624
|
+
case "write-file": {
|
|
1625
|
+
const { template, vars } = action.payload;
|
|
1626
|
+
const content = await loadTemplate(template, vars);
|
|
1627
|
+
await mkdir2(dirname3(target), { recursive: true });
|
|
1628
|
+
await writeFile10(target, content, "utf-8");
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
case "wrap-main-activity": {
|
|
1632
|
+
const { packageOfApp, appClassSimpleName, entryPoint, bundleAsset } = action.payload;
|
|
1633
|
+
const result = await wrapMainActivityWithKetoyScreen(target, {
|
|
1634
|
+
packageOfApp,
|
|
1635
|
+
appClassSimpleName,
|
|
1636
|
+
entryPoint,
|
|
1637
|
+
bundleAsset
|
|
1638
|
+
});
|
|
1639
|
+
if (!result.changed) {
|
|
1640
|
+
log.detail(result.reason ?? "No change needed.");
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
case "gitignore": {
|
|
1646
|
+
const { entries } = action.payload;
|
|
1647
|
+
const { added } = await ensureGitignoreEntries(target, entries);
|
|
1648
|
+
if (added.length === 0) {
|
|
1649
|
+
log.detail("All entries already present; skipping.");
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
log.detail(`Added: ${added.join(", ")}`);
|
|
1653
|
+
return true;
|
|
1654
|
+
}
|
|
1655
|
+
case "ensure-toml-plugin": {
|
|
1656
|
+
const { key, id, versionRef } = action.payload;
|
|
1657
|
+
const { changed, reason } = await ensurePluginEntry(target, { key, id, versionRef });
|
|
1658
|
+
if (!changed) {
|
|
1659
|
+
log.detail(reason ?? "Already declared; skipping.");
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
log.detail(`Added: ${key} = { id = "${id}", version.ref = "${versionRef}" }`);
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
case "insert-plugin-alias": {
|
|
1666
|
+
const { aliasPath, applyFalse } = action.payload;
|
|
1667
|
+
const { changed } = await insertPluginAlias(target, aliasPath, { applyFalse });
|
|
1668
|
+
if (!changed) {
|
|
1669
|
+
log.detail("Already applied; skipping.");
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
log.detail(`Added: alias(${aliasPath})${applyFalse ? " apply false" : ""}`);
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
case "bump-java-compatibility": {
|
|
1676
|
+
const { minLevel } = action.payload;
|
|
1677
|
+
const { changed, rewrites } = await bumpJavaCompatibility(target, minLevel);
|
|
1678
|
+
if (!changed) {
|
|
1679
|
+
log.detail(`compileOptions already at JavaVersion.VERSION_${minLevel} or higher; skipping.`);
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
for (const r of rewrites) log.detail(r);
|
|
1683
|
+
return true;
|
|
1684
|
+
}
|
|
1685
|
+
// REMOVABLE (alpha pinning) — see src/alpha-pins.ts header for the deletion checklist.
|
|
1686
|
+
case "ensure-gradle-property": {
|
|
1687
|
+
const { key, value } = action.payload;
|
|
1688
|
+
const result = await ensureGradleProperty(target, key, value);
|
|
1689
|
+
if (result.conflict) {
|
|
1690
|
+
log.detail(
|
|
1691
|
+
`${key} already set to "${result.previousValue}" \u2014 leaving alone (requested "${value}"). Verify this is intentional.`
|
|
1692
|
+
);
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
if (!result.changed) {
|
|
1696
|
+
log.detail(`${key}=${value} already set; skipping.`);
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1699
|
+
log.detail(`Added: ${key}=${value}`);
|
|
1700
|
+
return true;
|
|
1701
|
+
}
|
|
1702
|
+
// REMOVABLE (alpha pinning) — see src/alpha-pins.ts header for the deletion checklist.
|
|
1703
|
+
case "agp8-compat": {
|
|
1704
|
+
const { mutations, changed } = await migrateAgp8AndroidBlock(target);
|
|
1705
|
+
if (!changed) {
|
|
1706
|
+
log.detail("Already in AGP-8.x property form; skipping.");
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
for (const m of mutations) {
|
|
1710
|
+
const minor = m.droppedMinor ? pc.dim(" (dropped minorApiLevel \u2014 AGP 8.x has no equivalent)") : "";
|
|
1711
|
+
log.detail(`${m.property}: block-style \u2192 ${m.property} = ${m.apiLevel}${minor}`);
|
|
1712
|
+
}
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
// REMOVABLE (alpha pinning) — see src/alpha-pins.ts header for the deletion checklist.
|
|
1716
|
+
case "pin-alpha-versions": {
|
|
1717
|
+
const { pins } = action.payload;
|
|
1718
|
+
const results = await applyVersionPins(target, pins);
|
|
1719
|
+
let changed = false;
|
|
1720
|
+
for (const r of results) {
|
|
1721
|
+
const label = r.label ?? r.key;
|
|
1722
|
+
if (r.action === "unchanged") {
|
|
1723
|
+
log.detail(`${label}: already at ${r.required}.`);
|
|
1724
|
+
} else if (r.action === "pinned") {
|
|
1725
|
+
changed = true;
|
|
1726
|
+
log.detail(`${label}: ${pc.yellow(r.oldValue ?? "?")} \u2192 ${pc.green(r.required)}`);
|
|
1727
|
+
} else {
|
|
1728
|
+
changed = true;
|
|
1729
|
+
log.detail(`${label}: added ${r.key} = ${pc.green(r.required)}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return changed;
|
|
1733
|
+
}
|
|
1734
|
+
default: {
|
|
1735
|
+
const exhaustive = action.kind;
|
|
1736
|
+
throw new Error(`Unhandled action kind: ${String(exhaustive)}`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/config/project-state.ts
|
|
1742
|
+
import { mkdir as mkdir3, readFile as readFile12, writeFile as writeFile11, access as access6 } from "fs/promises";
|
|
1743
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
1744
|
+
async function saveProjectState(projectRoot, state) {
|
|
1745
|
+
const path = join6(projectRoot, PROJECT_STATE_FILE);
|
|
1746
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1747
|
+
await writeFile11(path, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/commands/init/index.ts
|
|
1751
|
+
async function runInitCommand(projectRoot, options) {
|
|
1752
|
+
log.info(`Detecting project structure at ${pc.dim(projectRoot)}\u2026`);
|
|
1753
|
+
const detection = await detectProject(projectRoot);
|
|
1754
|
+
log.detail(
|
|
1755
|
+
`namespace=${detection.namespace}, applicationId=${detection.applicationId}, minSdk=${detection.minSdk}`
|
|
1756
|
+
);
|
|
1757
|
+
log.detail(`hilt detected in project=${detection.hasHilt}, srcRoot=${detection.packageSrcRoot}`);
|
|
1758
|
+
let useHilt;
|
|
1759
|
+
if (options.hilt !== null) {
|
|
1760
|
+
useHilt = options.hilt;
|
|
1761
|
+
} else if (detection.hasHilt) {
|
|
1762
|
+
log.warn(
|
|
1763
|
+
"Hilt usage detected in the existing project. Ketoy init produces a non-Hilt bootstrap by default."
|
|
1764
|
+
);
|
|
1765
|
+
useHilt = await confirm({
|
|
1766
|
+
message: "Generate a Hilt-aware Ketoy setup instead?",
|
|
1767
|
+
default: false
|
|
1768
|
+
}).catch(() => false);
|
|
1769
|
+
} else {
|
|
1770
|
+
useHilt = false;
|
|
1771
|
+
}
|
|
1772
|
+
if (useHilt) {
|
|
1773
|
+
log.warn("Hilt setup requires app-specific decisions and is not scaffolded by `ketoy init`.");
|
|
1774
|
+
log.info(
|
|
1775
|
+
`Run ${pc.bold("ketoy chat")} and ask:
|
|
1776
|
+
${pc.dim('"Set up Ketoy with Hilt in this project \u2014 wire KetoyCapabilityProvider and the')}
|
|
1777
|
+
${pc.dim(' config customizer into my existing @Module / @InstallIn(SingletonComponent::class)."')}
|
|
1778
|
+
|
|
1779
|
+
The agent has full access to your build files and existing Hilt modules, and will apply
|
|
1780
|
+
surgical, additive edits \u2014 the same safety guarantees as \`ketoy init\` itself. Aborting init.`
|
|
1781
|
+
);
|
|
1782
|
+
throw new UserAbortError("Hilt setup deferred to `ketoy chat`.");
|
|
1783
|
+
}
|
|
1784
|
+
let installScreen;
|
|
1785
|
+
if (options.installKetoyScreen !== null) {
|
|
1786
|
+
installScreen = options.installKetoyScreen;
|
|
1787
|
+
} else if (options.yes) {
|
|
1788
|
+
log.warn(
|
|
1789
|
+
"--yes without --install-screen / --no-install-screen \u2014 defaulting to NOT installing KetoyScreen in MainActivity. Use ketoy chat or the docs to integrate manually."
|
|
1790
|
+
);
|
|
1791
|
+
installScreen = false;
|
|
1792
|
+
} else {
|
|
1793
|
+
log.info("");
|
|
1794
|
+
log.info(pc.bold("Install KetoyScreen in MainActivity?"));
|
|
1795
|
+
log.detail(pc.green("Recommended") + " if this is a fresh Android Studio project (the default");
|
|
1796
|
+
log.detail("Greeting() UI is replaced with KetoyScreen + a simple Hello Android fallback).");
|
|
1797
|
+
log.detail(pc.yellow("Decline") + " if MainActivity has custom UI you want to keep \u2014 instead use");
|
|
1798
|
+
log.detail("the docs (https://ketoy.dev/docs) or `ketoy chat` to integrate KetoyScreen by hand.");
|
|
1799
|
+
installScreen = await confirm({
|
|
1800
|
+
message: "Replace setContent body with KetoyScreen?",
|
|
1801
|
+
default: true
|
|
1802
|
+
}).catch(() => false);
|
|
1803
|
+
}
|
|
1804
|
+
const plan = await planInit(detection, { installKetoyScreen: installScreen });
|
|
1805
|
+
log.info(`Plan \u2014 non-Hilt setup, ${plan.actions.length} action(s):`);
|
|
1806
|
+
for (const a of plan.actions) {
|
|
1807
|
+
const tag = a.highRisk ? pc.yellow(" \u26A0") : pc.cyan(" \u2022");
|
|
1808
|
+
log.raw(`${tag} ${a.description}
|
|
1809
|
+
`);
|
|
1810
|
+
}
|
|
1811
|
+
if (plan.warnings.length > 0) {
|
|
1812
|
+
log.raw("\n");
|
|
1813
|
+
for (const w of plan.warnings) log.warn(w);
|
|
1814
|
+
}
|
|
1815
|
+
if (detection.minSdk < 26) {
|
|
1816
|
+
throw new DetectionError(
|
|
1817
|
+
`Ketoy requires minSdk >= 26. Current: ${detection.minSdk}. Update app/build.gradle.kts and re-run.`
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
if (options.dryRun) {
|
|
1821
|
+
log.info("Dry run \u2014 no files were modified.");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
log.raw("\n");
|
|
1825
|
+
if (!options.yes) {
|
|
1826
|
+
const ok = await confirm({
|
|
1827
|
+
message: `Apply ${plan.actions.length} action(s) to ${detection.appModuleRelative}/?`,
|
|
1828
|
+
default: true
|
|
1829
|
+
}).catch(() => false);
|
|
1830
|
+
if (!ok) throw new UserAbortError();
|
|
1831
|
+
}
|
|
1832
|
+
log.raw("\n");
|
|
1833
|
+
const result = await applyPlan(projectRoot, plan);
|
|
1834
|
+
log.raw("\n");
|
|
1835
|
+
if (result.errors.length > 0) {
|
|
1836
|
+
log.error(`Init completed with ${result.errors.length} error(s).`);
|
|
1837
|
+
} else {
|
|
1838
|
+
log.success(`Init complete \u2014 ${result.appliedCount} action(s) applied.`);
|
|
1839
|
+
}
|
|
1840
|
+
await saveProjectState(projectRoot, {
|
|
1841
|
+
ketoyVersion: await getKetoyVersion(),
|
|
1842
|
+
initRanOn: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1843
|
+
hilt: false,
|
|
1844
|
+
applicationId: detection.applicationId,
|
|
1845
|
+
namespace: detection.namespace,
|
|
1846
|
+
packagePath: detection.packagePath,
|
|
1847
|
+
bundlePath: "app/src/main/assets/ketoy/main.ktx"
|
|
1848
|
+
});
|
|
1849
|
+
log.info("Next steps:");
|
|
1850
|
+
log.detail("1. Sync Gradle (Android Studio: File \u2192 Sync Project)");
|
|
1851
|
+
log.detail("2. ./gradlew :app:assembleDebug # produces the .ktx bundle and the APK");
|
|
1852
|
+
if (installScreen) {
|
|
1853
|
+
log.detail("3. Install and run \u2014 the KBC HelloKetoyScreen renders inside MainActivity");
|
|
1854
|
+
log.detail("4. ketoy chat # ask the agent to add screens, capabilities, etc.");
|
|
1855
|
+
} else {
|
|
1856
|
+
log.detail("3. Integrate KetoyScreen into MainActivity yourself \u2014 snippet below");
|
|
1857
|
+
log.detail("4. ketoy chat # ask the agent to wire it up if needed");
|
|
1858
|
+
log.info("");
|
|
1859
|
+
log.info(pc.bold("Manual integration \u2014 paste inside your MainActivity setContent { YourAppTheme { \u2026 } }:"));
|
|
1860
|
+
log.raw("\n");
|
|
1861
|
+
log.raw(
|
|
1862
|
+
ketoyScreenManualSnippet({
|
|
1863
|
+
packageOfApp: detection.applicationId,
|
|
1864
|
+
appClassSimpleName: "MyApplication",
|
|
1865
|
+
entryPoint: "HelloKetoyScreen",
|
|
1866
|
+
bundleAsset: "ketoy/main.ktx"
|
|
1867
|
+
})
|
|
1868
|
+
);
|
|
1869
|
+
log.raw("\n\n");
|
|
1870
|
+
}
|
|
1871
|
+
if (result.errors.length > 0) {
|
|
1872
|
+
throw new KetoyCliError("init completed with errors \u2014 see above.");
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/commands/chat.ts
|
|
1877
|
+
import { input as input2, confirm as confirm5 } from "@inquirer/prompts";
|
|
1878
|
+
|
|
1879
|
+
// src/agent/loop.ts
|
|
1880
|
+
import { streamText } from "ai";
|
|
1881
|
+
|
|
1882
|
+
// src/agent/model.ts
|
|
1883
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
1884
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
1885
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
1886
|
+
import { createMistral } from "@ai-sdk/mistral";
|
|
1887
|
+
import { createGroq } from "@ai-sdk/groq";
|
|
1888
|
+
import { createXai } from "@ai-sdk/xai";
|
|
1889
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
1890
|
+
import { createOllama } from "ollama-ai-provider";
|
|
1891
|
+
async function resolveModel(modelIdOverride) {
|
|
1892
|
+
const config = await loadConfig();
|
|
1893
|
+
const modelId = modelIdOverride ?? config.model;
|
|
1894
|
+
const { provider, name } = parseModelId(modelId);
|
|
1895
|
+
const info = PROVIDERS[provider];
|
|
1896
|
+
const apiKey = config.apiKeys[provider];
|
|
1897
|
+
if (provider !== "ollama" && !apiKey) {
|
|
1898
|
+
throw new ConfigError(
|
|
1899
|
+
`No API key configured for ${info.displayName}. Run: ketoy auth ${provider}`
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
switch (provider) {
|
|
1903
|
+
case "anthropic":
|
|
1904
|
+
return { model: createAnthropic({ apiKey })(name), providerId: provider, modelName: name };
|
|
1905
|
+
case "openai":
|
|
1906
|
+
return { model: createOpenAI({ apiKey })(name), providerId: provider, modelName: name };
|
|
1907
|
+
case "google":
|
|
1908
|
+
return {
|
|
1909
|
+
model: createGoogleGenerativeAI({ apiKey })(name),
|
|
1910
|
+
providerId: provider,
|
|
1911
|
+
modelName: name
|
|
1912
|
+
};
|
|
1913
|
+
case "mistral":
|
|
1914
|
+
return { model: createMistral({ apiKey })(name), providerId: provider, modelName: name };
|
|
1915
|
+
case "groq":
|
|
1916
|
+
return { model: createGroq({ apiKey })(name), providerId: provider, modelName: name };
|
|
1917
|
+
case "xai":
|
|
1918
|
+
return { model: createXai({ apiKey })(name), providerId: provider, modelName: name };
|
|
1919
|
+
case "openrouter":
|
|
1920
|
+
return {
|
|
1921
|
+
model: createOpenRouter({ apiKey }).chat(name),
|
|
1922
|
+
providerId: provider,
|
|
1923
|
+
modelName: name
|
|
1924
|
+
};
|
|
1925
|
+
case "ollama":
|
|
1926
|
+
return {
|
|
1927
|
+
model: createOllama({ baseURL: `${config.ollama.baseUrl}/api` })(name),
|
|
1928
|
+
providerId: provider,
|
|
1929
|
+
modelName: name
|
|
1930
|
+
};
|
|
1931
|
+
default: {
|
|
1932
|
+
const exhaustive = provider;
|
|
1933
|
+
throw new Error(`Unhandled provider: ${String(exhaustive)}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// src/skill/loader.ts
|
|
1939
|
+
import { readFile as readFile13, readdir, stat } from "fs/promises";
|
|
1940
|
+
import { join as join7, dirname as dirname5, resolve as resolve2 } from "path";
|
|
1941
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1942
|
+
var cachedRoot2 = null;
|
|
1943
|
+
async function dirExists(path) {
|
|
1944
|
+
try {
|
|
1945
|
+
const s = await stat(path);
|
|
1946
|
+
return s.isDirectory();
|
|
1947
|
+
} catch {
|
|
1948
|
+
return false;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
async function getSkillRoot() {
|
|
1952
|
+
if (cachedRoot2) return cachedRoot2;
|
|
1953
|
+
const envOverride = process.env["KETOY_SKILL_DIR"];
|
|
1954
|
+
if (envOverride && await dirExists(envOverride)) {
|
|
1955
|
+
cachedRoot2 = resolve2(envOverride);
|
|
1956
|
+
return cachedRoot2;
|
|
1957
|
+
}
|
|
1958
|
+
const here = dirname5(fileURLToPath2(import.meta.url));
|
|
1959
|
+
const candidates = [
|
|
1960
|
+
resolve2(here, "..", "skills", "ketoy"),
|
|
1961
|
+
resolve2(here, "..", "..", "skills", "ketoy"),
|
|
1962
|
+
resolve2(here, "..", "..", "..", "skills", "ketoy")
|
|
1963
|
+
];
|
|
1964
|
+
for (const c of candidates) {
|
|
1965
|
+
if (await dirExists(c)) {
|
|
1966
|
+
cachedRoot2 = c;
|
|
1967
|
+
return cachedRoot2;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
throw new Error(
|
|
1971
|
+
`Could not locate the Ketoy skill directory. Set KETOY_SKILL_DIR to override. Tried: ${candidates.join(", ")}`
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
async function readSkillFile(relName) {
|
|
1975
|
+
const root = await getSkillRoot();
|
|
1976
|
+
const normalized = relName.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1977
|
+
if (normalized.includes("..")) return null;
|
|
1978
|
+
const path = join7(root, normalized);
|
|
1979
|
+
try {
|
|
1980
|
+
return await readFile13(path, "utf-8");
|
|
1981
|
+
} catch {
|
|
1982
|
+
return null;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
async function listSkillFiles() {
|
|
1986
|
+
const root = await getSkillRoot();
|
|
1987
|
+
const out = [];
|
|
1988
|
+
async function walk(dir, prefix) {
|
|
1989
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1990
|
+
for (const e of entries) {
|
|
1991
|
+
const full = join7(dir, e.name);
|
|
1992
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
1993
|
+
if (e.isDirectory()) {
|
|
1994
|
+
await walk(full, rel);
|
|
1995
|
+
} else if (e.isFile()) {
|
|
1996
|
+
out.push(rel);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
await walk(root, "");
|
|
2001
|
+
return out.sort();
|
|
2002
|
+
}
|
|
2003
|
+
async function buildSkillContext(taskHint) {
|
|
2004
|
+
const main = await readSkillFile("SKILL.md");
|
|
2005
|
+
if (main === null) throw new Error("SKILL.md missing from skill root.");
|
|
2006
|
+
const lower = taskHint.toLowerCase();
|
|
2007
|
+
const toLoad = /* @__PURE__ */ new Set(["SKILL.md"]);
|
|
2008
|
+
const add = (name) => toLoad.add(name);
|
|
2009
|
+
if (/\b(init|scaffold|set up|setup|add ketoy|integrate)\b/.test(lower)) {
|
|
2010
|
+
add("guides/init-project.md");
|
|
2011
|
+
add("guides/safe-edits.md");
|
|
2012
|
+
}
|
|
2013
|
+
if (/\b(migrate|migration|convert|port)\b/.test(lower)) {
|
|
2014
|
+
add("guides/migrate.md");
|
|
2015
|
+
}
|
|
2016
|
+
if (/\b(error|fail|broken|crash|diagnose|fix|why)\b/.test(lower)) {
|
|
2017
|
+
add("guides/diagnose-errors.md");
|
|
2018
|
+
}
|
|
2019
|
+
if (/\b(supported|can i use|is there|available|catalog)\b/.test(lower)) {
|
|
2020
|
+
add("reference/supported-composables.md");
|
|
2021
|
+
add("reference/forbidden-apis.md");
|
|
2022
|
+
}
|
|
2023
|
+
if (/\b(capability|stub|host registry|capabilityregistry|capability id)\b/.test(lower)) {
|
|
2024
|
+
add("reference/capabilities.md");
|
|
2025
|
+
}
|
|
2026
|
+
if (/\b(modifier|padding|fillmax)\b/.test(lower)) {
|
|
2027
|
+
add("reference/supported-modifiers.md");
|
|
2028
|
+
}
|
|
2029
|
+
if (/\b(bundle|\.ktx|analyze|inspect|build)\b/.test(lower)) {
|
|
2030
|
+
add("guides/build-and-analyze.md");
|
|
2031
|
+
}
|
|
2032
|
+
if (toLoad.size === 1) {
|
|
2033
|
+
add("reference/architecture-cheatsheet.md");
|
|
2034
|
+
}
|
|
2035
|
+
const parts = [main];
|
|
2036
|
+
const loaded = ["SKILL.md"];
|
|
2037
|
+
for (const name of toLoad) {
|
|
2038
|
+
if (name === "SKILL.md") continue;
|
|
2039
|
+
const content = await readSkillFile(name);
|
|
2040
|
+
if (content !== null) {
|
|
2041
|
+
parts.push(`
|
|
2042
|
+
|
|
2043
|
+
--- BEGIN ${name} ---
|
|
2044
|
+
|
|
2045
|
+
${content}
|
|
2046
|
+
|
|
2047
|
+
--- END ${name} ---
|
|
2048
|
+
`);
|
|
2049
|
+
loaded.push(name);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
return { systemPrompt: parts.join(""), loadedFiles: loaded };
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// src/agent/prompts.ts
|
|
2056
|
+
async function buildSystemPrompt(opts) {
|
|
2057
|
+
const [{ systemPrompt: skill, loadedFiles }, ketoyVersion] = await Promise.all([
|
|
2058
|
+
buildSkillContext(opts.taskHint),
|
|
2059
|
+
getKetoyVersion()
|
|
2060
|
+
]);
|
|
2061
|
+
const header = `You are the Ketoy CLI agent, version ${CLI_VERSION}, targeting Ketoy ${ketoyVersion}.
|
|
2062
|
+
You are running on model "${opts.modelName}".
|
|
2063
|
+
Working directory: ${opts.projectRoot}
|
|
2064
|
+
Today: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
|
|
2065
|
+
|
|
2066
|
+
Operating principles:
|
|
2067
|
+
- Be specific. Quote real symbol names from the user's tree. Don't paraphrase.
|
|
2068
|
+
- Use tools to inspect the user's actual code before answering. Never assume.
|
|
2069
|
+
- For multi-step work, state the plan, then execute step by step with intermediate verification.
|
|
2070
|
+
- NEVER rewrite existing files in full. Edits to build.gradle.kts, AndroidManifest.xml,
|
|
2071
|
+
MainActivity.kt, Application classes, and settings.gradle.kts MUST be surgical \u2014 single-line
|
|
2072
|
+
additions or single-block appends at well-identified anchors. The user has invested work
|
|
2073
|
+
in those files; treat them as sacred.
|
|
2074
|
+
- For high-risk file edits, the edit_file tool will show a diff and ask the user. Do not
|
|
2075
|
+
try to bypass this.
|
|
2076
|
+
- Refuse whole-project migrations. Migration is per-file, one-shot. Always audit first.
|
|
2077
|
+
- Publishing is deferred until the Ketoy backend ships. Don't write publish code.
|
|
2078
|
+
|
|
2079
|
+
Tools available:
|
|
2080
|
+
- read_file, write_file, edit_file, grep, glob, bash, analyze_ktx, skill_file
|
|
2081
|
+
|
|
2082
|
+
The skill below is your operational manual. Treat reference/ as ground truth.
|
|
2083
|
+
=== SKILL CONTENT ===
|
|
2084
|
+
|
|
2085
|
+
`;
|
|
2086
|
+
return { prompt: header + skill, loadedFiles };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// src/agent/tools/read-file.ts
|
|
2090
|
+
import { readFile as readFile14, stat as stat2 } from "fs/promises";
|
|
2091
|
+
import { z as z2 } from "zod";
|
|
2092
|
+
import { tool } from "ai";
|
|
2093
|
+
|
|
2094
|
+
// src/agent/tools/paths.ts
|
|
2095
|
+
import { isAbsolute, relative as relative2, resolve as resolve3, sep } from "path";
|
|
2096
|
+
function safeJoin(projectRoot, path) {
|
|
2097
|
+
const rootAbs = resolve3(projectRoot);
|
|
2098
|
+
const abs = resolve3(rootAbs, path);
|
|
2099
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) return null;
|
|
2100
|
+
const rel = relative2(rootAbs, abs);
|
|
2101
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
2102
|
+
return { abs, rel };
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/agent/tools/read-file.ts
|
|
2106
|
+
var MAX_BYTES = 256 * 1024;
|
|
2107
|
+
function makeReadFileTool(projectRoot) {
|
|
2108
|
+
return tool({
|
|
2109
|
+
description: "Read a file from the working project. Returns up to 256 KB of UTF-8 text. Use this before editing any file.",
|
|
2110
|
+
parameters: z2.object({
|
|
2111
|
+
path: z2.string().describe("Path relative to project root, or absolute under it.")
|
|
2112
|
+
}),
|
|
2113
|
+
execute: async ({ path }) => {
|
|
2114
|
+
const joined = safeJoin(projectRoot, path);
|
|
2115
|
+
if (!joined) return { error: `Refusing to read outside project root: ${path}` };
|
|
2116
|
+
const { abs, rel } = joined;
|
|
2117
|
+
const st = await stat2(abs).catch(() => null);
|
|
2118
|
+
if (!st) return { error: `File not found: ${rel}` };
|
|
2119
|
+
if (st.isDirectory()) return { error: `Path is a directory: ${rel}` };
|
|
2120
|
+
if (st.size > MAX_BYTES) {
|
|
2121
|
+
return { error: `File too large (${st.size} bytes, limit ${MAX_BYTES}): ${rel}` };
|
|
2122
|
+
}
|
|
2123
|
+
const content = await readFile14(abs, "utf-8");
|
|
2124
|
+
return { path: rel, bytes: st.size, content };
|
|
2125
|
+
}
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/agent/tools/write-file.ts
|
|
2130
|
+
import { mkdir as mkdir4, writeFile as writeFile12, access as access7 } from "fs/promises";
|
|
2131
|
+
import { dirname as dirname6 } from "path";
|
|
2132
|
+
import { z as z3 } from "zod";
|
|
2133
|
+
import { tool as tool2 } from "ai";
|
|
2134
|
+
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
2135
|
+
async function fileExists6(path) {
|
|
2136
|
+
try {
|
|
2137
|
+
await access7(path);
|
|
2138
|
+
return true;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return false;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function makeWriteFileTool(projectRoot, opts) {
|
|
2144
|
+
return tool2({
|
|
2145
|
+
description: "Create a NEW file at the given path. Fails if the file already exists \u2014 use edit_file instead. Always prefer surgical edits over creating new files when the user has existing code.",
|
|
2146
|
+
parameters: z3.object({
|
|
2147
|
+
path: z3.string().describe("Path relative to project root."),
|
|
2148
|
+
content: z3.string().describe("Full UTF-8 file content.")
|
|
2149
|
+
}),
|
|
2150
|
+
execute: async ({ path, content }) => {
|
|
2151
|
+
const joined = safeJoin(projectRoot, path);
|
|
2152
|
+
if (!joined) return { error: `Refusing to write outside project root: ${path}` };
|
|
2153
|
+
const { abs, rel } = joined;
|
|
2154
|
+
if (await fileExists6(abs)) {
|
|
2155
|
+
return { error: `File already exists: ${rel}. Use edit_file to modify it.` };
|
|
2156
|
+
}
|
|
2157
|
+
log.info(`Agent wants to create ${pc.bold(rel)} (${content.length} bytes)`);
|
|
2158
|
+
if (!opts.autoApprove) {
|
|
2159
|
+
const ok = await confirm2({ message: `Create ${rel}?`, default: true });
|
|
2160
|
+
if (!ok) return { error: "User declined." };
|
|
2161
|
+
}
|
|
2162
|
+
await mkdir4(dirname6(abs), { recursive: true });
|
|
2163
|
+
await writeFile12(abs, content, "utf-8");
|
|
2164
|
+
log.success(`Created ${rel}`);
|
|
2165
|
+
return { path: rel, bytes: content.length, created: true };
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// src/agent/tools/edit-file.ts
|
|
2171
|
+
import { readFile as readFile15, writeFile as writeFile13 } from "fs/promises";
|
|
2172
|
+
import { z as z4 } from "zod";
|
|
2173
|
+
import { tool as tool3 } from "ai";
|
|
2174
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2175
|
+
|
|
2176
|
+
// src/safe-edit/diff.ts
|
|
2177
|
+
function renderUnifiedDiff(oldText, newText, label = "file") {
|
|
2178
|
+
if (oldText === newText) return pc.dim("(no changes)");
|
|
2179
|
+
const oldLines = oldText.split("\n");
|
|
2180
|
+
const newLines = newText.split("\n");
|
|
2181
|
+
const out = [pc.bold(`--- ${label}`), pc.bold(`+++ ${label}`)];
|
|
2182
|
+
let i = 0;
|
|
2183
|
+
let j = 0;
|
|
2184
|
+
while (i < oldLines.length || j < newLines.length) {
|
|
2185
|
+
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
|
|
2186
|
+
out.push(pc.dim(` ${oldLines[i]}`));
|
|
2187
|
+
i++;
|
|
2188
|
+
j++;
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
let aheadOld = i;
|
|
2192
|
+
let aheadNew = j;
|
|
2193
|
+
while (aheadOld < oldLines.length && aheadNew < newLines.length && oldLines[aheadOld] !== newLines[aheadNew]) {
|
|
2194
|
+
aheadOld++;
|
|
2195
|
+
aheadNew++;
|
|
2196
|
+
}
|
|
2197
|
+
for (let k = i; k < aheadOld; k++) out.push(pc.red(`- ${oldLines[k]}`));
|
|
2198
|
+
for (let k = j; k < aheadNew; k++) out.push(pc.green(`+ ${newLines[k]}`));
|
|
2199
|
+
i = aheadOld;
|
|
2200
|
+
j = aheadNew;
|
|
2201
|
+
if (i === oldLines.length && j < newLines.length) {
|
|
2202
|
+
for (let k = j; k < newLines.length; k++) out.push(pc.green(`+ ${newLines[k]}`));
|
|
2203
|
+
break;
|
|
2204
|
+
}
|
|
2205
|
+
if (j === newLines.length && i < oldLines.length) {
|
|
2206
|
+
for (let k = i; k < oldLines.length; k++) out.push(pc.red(`- ${oldLines[k]}`));
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
return out.join("\n");
|
|
2211
|
+
}
|
|
2212
|
+
var HIGH_RISK_PATTERNS = [
|
|
2213
|
+
/(^|\/)build\.gradle\.kts$/,
|
|
2214
|
+
/(^|\/)settings\.gradle\.kts$/,
|
|
2215
|
+
/(^|\/)AndroidManifest\.xml$/,
|
|
2216
|
+
/(^|\/)MainActivity\.kt$/,
|
|
2217
|
+
/(^|\/)App\.kt$/,
|
|
2218
|
+
/(^|\/)Application\.kt$/,
|
|
2219
|
+
/\/keys\/.*-private\.key$/
|
|
2220
|
+
];
|
|
2221
|
+
function isHighRisk(relPath) {
|
|
2222
|
+
return HIGH_RISK_PATTERNS.some((p) => p.test(relPath));
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/agent/tools/edit-file.ts
|
|
2226
|
+
function makeEditFileTool(projectRoot, opts) {
|
|
2227
|
+
return tool3({
|
|
2228
|
+
description: "Surgically edit an existing file by replacing exact text. old_string must appear EXACTLY ONCE in the file. For high-risk files (build.gradle.kts, AndroidManifest.xml, MainActivity.kt, Application classes) the user is shown a diff and must confirm. Never use this to rewrite entire files \u2014 make minimal, additive edits.",
|
|
2229
|
+
parameters: z4.object({
|
|
2230
|
+
path: z4.string().describe("Path relative to project root."),
|
|
2231
|
+
old_string: z4.string().describe("Exact text to replace. Must occur once."),
|
|
2232
|
+
new_string: z4.string().describe("Replacement text.")
|
|
2233
|
+
}),
|
|
2234
|
+
execute: async ({ path, old_string, new_string }) => {
|
|
2235
|
+
const joined = safeJoin(projectRoot, path);
|
|
2236
|
+
if (!joined) return { error: `Refusing to edit outside project root: ${path}` };
|
|
2237
|
+
const { abs, rel } = joined;
|
|
2238
|
+
const original = await readFile15(abs, "utf-8").catch(() => null);
|
|
2239
|
+
if (original === null) return { error: `File not found: ${rel}` };
|
|
2240
|
+
const occurrences = original.split(old_string).length - 1;
|
|
2241
|
+
if (occurrences === 0) {
|
|
2242
|
+
return { error: `old_string not found in ${rel}. Read the file again and use exact text.` };
|
|
2243
|
+
}
|
|
2244
|
+
if (occurrences > 1) {
|
|
2245
|
+
return {
|
|
2246
|
+
error: `old_string occurs ${occurrences} times in ${rel}. Add surrounding context to make it unique.`
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
if (old_string === new_string) {
|
|
2250
|
+
return { error: "old_string and new_string are identical \u2014 no edit to perform." };
|
|
2251
|
+
}
|
|
2252
|
+
const updated = original.replace(old_string, new_string);
|
|
2253
|
+
const risky = isHighRisk(rel);
|
|
2254
|
+
if (risky || !opts.autoApprove) {
|
|
2255
|
+
log.info(`Agent wants to edit ${pc.bold(rel)}${risky ? pc.yellow(" (high-risk file)") : ""}`);
|
|
2256
|
+
log.raw(renderUnifiedDiff(original, updated, rel) + "\n");
|
|
2257
|
+
const ok = await confirm3({ message: `Apply edit to ${rel}?`, default: !risky });
|
|
2258
|
+
if (!ok) return { error: "User declined the edit." };
|
|
2259
|
+
}
|
|
2260
|
+
await writeFile13(abs, updated, "utf-8");
|
|
2261
|
+
log.success(`Edited ${rel}`);
|
|
2262
|
+
return { path: rel, edited: true };
|
|
2263
|
+
}
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// src/agent/tools/grep.ts
|
|
2268
|
+
import { z as z5 } from "zod";
|
|
2269
|
+
import { tool as tool4 } from "ai";
|
|
2270
|
+
import { glob as glob2 } from "tinyglobby";
|
|
2271
|
+
import { readFile as readFile16 } from "fs/promises";
|
|
2272
|
+
import { relative as relative3 } from "path";
|
|
2273
|
+
var MAX_MATCHES = 200;
|
|
2274
|
+
var MAX_FILE_SIZE = 1024 * 1024;
|
|
2275
|
+
function makeGrepTool(projectRoot) {
|
|
2276
|
+
return tool4({
|
|
2277
|
+
description: "Search file contents using a JavaScript regular expression. Returns up to 200 matches with file path + line number + matching line text. Useful for finding usages, imports, FQ names.",
|
|
2278
|
+
parameters: z5.object({
|
|
2279
|
+
pattern: z5.string().describe("JavaScript-compatible regex pattern."),
|
|
2280
|
+
glob_pattern: z5.string().default("**/*.{kt,kts,xml,json,gradle,properties}").describe("Glob to filter files. Default: Android/Kotlin source files."),
|
|
2281
|
+
case_sensitive: z5.boolean().default(true)
|
|
2282
|
+
}),
|
|
2283
|
+
execute: async ({ pattern, glob_pattern, case_sensitive }) => {
|
|
2284
|
+
let regex;
|
|
2285
|
+
try {
|
|
2286
|
+
regex = new RegExp(pattern, case_sensitive ? "g" : "gi");
|
|
2287
|
+
} catch (e) {
|
|
2288
|
+
return { error: `Invalid regex: ${e.message}` };
|
|
2289
|
+
}
|
|
2290
|
+
const files = await glob2(glob_pattern, {
|
|
2291
|
+
cwd: projectRoot,
|
|
2292
|
+
absolute: true,
|
|
2293
|
+
ignore: ["**/node_modules/**", "**/build/**", "**/.gradle/**", "**/dist/**", "**/.git/**"],
|
|
2294
|
+
onlyFiles: true
|
|
2295
|
+
});
|
|
2296
|
+
const matches = [];
|
|
2297
|
+
for (const abs of files) {
|
|
2298
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
2299
|
+
const content = await readFile16(abs, "utf-8").catch(() => null);
|
|
2300
|
+
if (content === null || content.length > MAX_FILE_SIZE) continue;
|
|
2301
|
+
const lines = content.split("\n");
|
|
2302
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2303
|
+
const line = lines[i];
|
|
2304
|
+
if (line === void 0) continue;
|
|
2305
|
+
regex.lastIndex = 0;
|
|
2306
|
+
if (regex.test(line)) {
|
|
2307
|
+
matches.push({ file: relative3(projectRoot, abs), line: i + 1, text: line.trim().slice(0, 300) });
|
|
2308
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
return { matchCount: matches.length, matches, truncated: matches.length >= MAX_MATCHES };
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// src/agent/tools/glob.ts
|
|
2318
|
+
import { z as z6 } from "zod";
|
|
2319
|
+
import { tool as tool5 } from "ai";
|
|
2320
|
+
import { glob as glob3 } from "tinyglobby";
|
|
2321
|
+
import { relative as relative4 } from "path";
|
|
2322
|
+
var MAX_RESULTS = 500;
|
|
2323
|
+
function makeGlobTool(projectRoot) {
|
|
2324
|
+
return tool5({
|
|
2325
|
+
description: "List files matching a glob pattern. Returns up to 500 paths. Use for locating files by name.",
|
|
2326
|
+
parameters: z6.object({
|
|
2327
|
+
pattern: z6.string().describe('Glob pattern (e.g. "**/*.kt", "app/src/main/**/MainActivity.kt").')
|
|
2328
|
+
}),
|
|
2329
|
+
execute: async ({ pattern }) => {
|
|
2330
|
+
const results = await glob3(pattern, {
|
|
2331
|
+
cwd: projectRoot,
|
|
2332
|
+
absolute: true,
|
|
2333
|
+
ignore: ["**/node_modules/**", "**/build/**", "**/.gradle/**", "**/dist/**", "**/.git/**"],
|
|
2334
|
+
onlyFiles: true
|
|
2335
|
+
});
|
|
2336
|
+
const paths = results.slice(0, MAX_RESULTS).map((p) => relative4(projectRoot, p));
|
|
2337
|
+
return { count: paths.length, paths, truncated: results.length > MAX_RESULTS };
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// src/agent/tools/bash.ts
|
|
2343
|
+
import { z as z7 } from "zod";
|
|
2344
|
+
import { tool as tool6 } from "ai";
|
|
2345
|
+
import { execa } from "execa";
|
|
2346
|
+
import { confirm as confirm4 } from "@inquirer/prompts";
|
|
2347
|
+
var SAFE_BIN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
2348
|
+
"./gradlew",
|
|
2349
|
+
"./gradlew.bat",
|
|
2350
|
+
"git",
|
|
2351
|
+
"ls",
|
|
2352
|
+
"cat",
|
|
2353
|
+
"pwd",
|
|
2354
|
+
"find",
|
|
2355
|
+
"echo",
|
|
2356
|
+
"wc",
|
|
2357
|
+
"head",
|
|
2358
|
+
"tail",
|
|
2359
|
+
"grep",
|
|
2360
|
+
"rg",
|
|
2361
|
+
"xxd",
|
|
2362
|
+
"file",
|
|
2363
|
+
"tree"
|
|
2364
|
+
]);
|
|
2365
|
+
var HARD_DENY = [/rm\s+-rf\s+\//, /:\(\)\s*\{\s*:\s*\|\s*:/, /mkfs/i, /dd\s+if=/i, />\s*\/dev\/sda/];
|
|
2366
|
+
var COMPOUND_METACHARS = /[;&|`$()<>]|\$\(/;
|
|
2367
|
+
function firstToken(command) {
|
|
2368
|
+
const trimmed = command.trim();
|
|
2369
|
+
return trimmed.split(/\s+/)[0] ?? "";
|
|
2370
|
+
}
|
|
2371
|
+
function isSafe(command) {
|
|
2372
|
+
for (const pattern of HARD_DENY) if (pattern.test(command)) return false;
|
|
2373
|
+
if (COMPOUND_METACHARS.test(command)) return false;
|
|
2374
|
+
const tok = firstToken(command);
|
|
2375
|
+
return SAFE_BIN_ALLOWLIST.has(tok);
|
|
2376
|
+
}
|
|
2377
|
+
var MAX_OUTPUT = 32 * 1024;
|
|
2378
|
+
function makeBashTool(projectRoot, opts) {
|
|
2379
|
+
return tool6({
|
|
2380
|
+
description: "Run a shell command from the project root. Read-only and gradle commands run without prompting; any other command requires user approval. Output is truncated at 32 KB.",
|
|
2381
|
+
parameters: z7.object({
|
|
2382
|
+
command: z7.string().describe("Shell command to execute."),
|
|
2383
|
+
timeout_ms: z7.number().int().min(1e3).max(9e5).default(12e4)
|
|
2384
|
+
}),
|
|
2385
|
+
execute: async ({ command, timeout_ms }) => {
|
|
2386
|
+
for (const pattern of HARD_DENY) {
|
|
2387
|
+
if (pattern.test(command)) return { error: `Refusing destructive command: ${command}` };
|
|
2388
|
+
}
|
|
2389
|
+
const safe = isSafe(command);
|
|
2390
|
+
if (!safe || !opts.autoApproveSafe) {
|
|
2391
|
+
log.info(`Agent wants to run: ${pc.bold(command)}`);
|
|
2392
|
+
const ok = await confirm4({ message: "Run this command?", default: safe });
|
|
2393
|
+
if (!ok) return { error: "User declined the command." };
|
|
2394
|
+
}
|
|
2395
|
+
try {
|
|
2396
|
+
const result = await execa(command, {
|
|
2397
|
+
cwd: projectRoot,
|
|
2398
|
+
shell: true,
|
|
2399
|
+
timeout: timeout_ms,
|
|
2400
|
+
reject: false,
|
|
2401
|
+
all: true
|
|
2402
|
+
});
|
|
2403
|
+
const all = (result.all ?? `${result.stdout}
|
|
2404
|
+
${result.stderr}`).slice(0, MAX_OUTPUT);
|
|
2405
|
+
return {
|
|
2406
|
+
command,
|
|
2407
|
+
exitCode: result.exitCode ?? null,
|
|
2408
|
+
output: all,
|
|
2409
|
+
truncated: (result.all?.length ?? 0) > MAX_OUTPUT,
|
|
2410
|
+
timedOut: result.timedOut
|
|
2411
|
+
};
|
|
2412
|
+
} catch (e) {
|
|
2413
|
+
return { error: `Execution failed: ${e.message}` };
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// src/agent/tools/analyze-ktx.ts
|
|
2420
|
+
import { z as z8 } from "zod";
|
|
2421
|
+
import { tool as tool7 } from "ai";
|
|
2422
|
+
|
|
2423
|
+
// src/ktx/reader.ts
|
|
2424
|
+
import { readFile as readFile17 } from "fs/promises";
|
|
2425
|
+
import { brotliDecompressSync } from "zlib";
|
|
2426
|
+
var KTX_MAGIC = Buffer.from([75, 84, 79, 89]);
|
|
2427
|
+
var SIGNATURE_SIZE = 64;
|
|
2428
|
+
var HEADER_SIZE = 14;
|
|
2429
|
+
var SECTION_STRING_POOL = 1;
|
|
2430
|
+
var SECTION_MODIFIER_TABLE = 2;
|
|
2431
|
+
var SECTION_ADAPTER_MANIFEST = 3;
|
|
2432
|
+
var SECTION_CONSTRUCTOR_MANIFEST = 4;
|
|
2433
|
+
var SECTION_CAPABILITY_MANIFEST = 5;
|
|
2434
|
+
var SECTION_FUNCTION_TABLE = 6;
|
|
2435
|
+
var SECTION_CODE = 7;
|
|
2436
|
+
var SECTION_DEBUG_INFO = 8;
|
|
2437
|
+
var SECTION_ENTRY_POINTS = 9;
|
|
2438
|
+
var SECTION_BUNDLE_METADATA = 10;
|
|
2439
|
+
var FLAG_DEBUG_INFO_PRESENT = 1;
|
|
2440
|
+
var FLAG_UNSIGNED = 2;
|
|
2441
|
+
function sectionName(type) {
|
|
2442
|
+
switch (type) {
|
|
2443
|
+
case SECTION_STRING_POOL:
|
|
2444
|
+
return "STRING_POOL";
|
|
2445
|
+
case SECTION_ADAPTER_MANIFEST:
|
|
2446
|
+
return "ADAPTER_MANIFEST";
|
|
2447
|
+
case SECTION_CONSTRUCTOR_MANIFEST:
|
|
2448
|
+
return "CONSTRUCTOR_MANIFEST";
|
|
2449
|
+
case SECTION_CAPABILITY_MANIFEST:
|
|
2450
|
+
return "CAPABILITY_MANIFEST";
|
|
2451
|
+
case SECTION_FUNCTION_TABLE:
|
|
2452
|
+
return "FUNCTION_TABLE";
|
|
2453
|
+
case SECTION_CODE:
|
|
2454
|
+
return "CODE";
|
|
2455
|
+
case SECTION_MODIFIER_TABLE:
|
|
2456
|
+
return "MODIFIER_TABLE";
|
|
2457
|
+
case SECTION_DEBUG_INFO:
|
|
2458
|
+
return "DEBUG_INFO";
|
|
2459
|
+
case SECTION_ENTRY_POINTS:
|
|
2460
|
+
return "ENTRY_POINTS";
|
|
2461
|
+
case SECTION_BUNDLE_METADATA:
|
|
2462
|
+
return "BUNDLE_METADATA";
|
|
2463
|
+
default:
|
|
2464
|
+
return `UNKNOWN(0x${type.toString(16).padStart(2, "0").toUpperCase()})`;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
var Reader = class {
|
|
2468
|
+
constructor(buf) {
|
|
2469
|
+
this.buf = buf;
|
|
2470
|
+
}
|
|
2471
|
+
buf;
|
|
2472
|
+
offset = 0;
|
|
2473
|
+
pos() {
|
|
2474
|
+
return this.offset;
|
|
2475
|
+
}
|
|
2476
|
+
remaining() {
|
|
2477
|
+
return this.buf.length - this.offset;
|
|
2478
|
+
}
|
|
2479
|
+
u8() {
|
|
2480
|
+
if (this.remaining() < 1) throw new Error("Unexpected EOF (u8)");
|
|
2481
|
+
const v = this.buf.readUInt8(this.offset);
|
|
2482
|
+
this.offset += 1;
|
|
2483
|
+
return v;
|
|
2484
|
+
}
|
|
2485
|
+
u16() {
|
|
2486
|
+
if (this.remaining() < 2) throw new Error("Unexpected EOF (u16)");
|
|
2487
|
+
const v = this.buf.readUInt16BE(this.offset);
|
|
2488
|
+
this.offset += 2;
|
|
2489
|
+
return v;
|
|
2490
|
+
}
|
|
2491
|
+
i32() {
|
|
2492
|
+
if (this.remaining() < 4) throw new Error("Unexpected EOF (i32)");
|
|
2493
|
+
const v = this.buf.readInt32BE(this.offset);
|
|
2494
|
+
this.offset += 4;
|
|
2495
|
+
return v;
|
|
2496
|
+
}
|
|
2497
|
+
u32() {
|
|
2498
|
+
if (this.remaining() < 4) throw new Error("Unexpected EOF (u32)");
|
|
2499
|
+
const v = this.buf.readUInt32BE(this.offset);
|
|
2500
|
+
this.offset += 4;
|
|
2501
|
+
return v;
|
|
2502
|
+
}
|
|
2503
|
+
bytes(n) {
|
|
2504
|
+
if (this.remaining() < n) throw new Error(`Unexpected EOF (bytes ${n})`);
|
|
2505
|
+
const out = this.buf.subarray(this.offset, this.offset + n);
|
|
2506
|
+
this.offset += n;
|
|
2507
|
+
return out;
|
|
2508
|
+
}
|
|
2509
|
+
skip(n) {
|
|
2510
|
+
if (this.remaining() < n) throw new Error(`Unexpected EOF (skip ${n})`);
|
|
2511
|
+
this.offset += n;
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
function decompressIfNeeded(s) {
|
|
2515
|
+
if (!s.compressed) return s.payload;
|
|
2516
|
+
if (s.onDiskSize === s.uncompressedSize) return s.payload;
|
|
2517
|
+
return brotliDecompressSync(s.payload);
|
|
2518
|
+
}
|
|
2519
|
+
function parseStringPool(payload) {
|
|
2520
|
+
const r = new Reader(payload);
|
|
2521
|
+
const count = r.i32();
|
|
2522
|
+
const result = [];
|
|
2523
|
+
for (let i = 0; i < count; i++) {
|
|
2524
|
+
const len = r.u16();
|
|
2525
|
+
const text = r.bytes(len).toString("utf-8");
|
|
2526
|
+
result.push(text);
|
|
2527
|
+
}
|
|
2528
|
+
return result;
|
|
2529
|
+
}
|
|
2530
|
+
function parseManifest(payload, pool) {
|
|
2531
|
+
const r = new Reader(payload);
|
|
2532
|
+
const count = r.u16();
|
|
2533
|
+
const out = [];
|
|
2534
|
+
for (let i = 0; i < count; i++) {
|
|
2535
|
+
const id = r.u16();
|
|
2536
|
+
const nameIdx = r.u16();
|
|
2537
|
+
const required = r.u8() !== 0;
|
|
2538
|
+
out.push({ id, name: pool[nameIdx] ?? `?@${nameIdx}`, required });
|
|
2539
|
+
}
|
|
2540
|
+
return out;
|
|
2541
|
+
}
|
|
2542
|
+
function parseFunctionTable(payload) {
|
|
2543
|
+
const r = new Reader(payload);
|
|
2544
|
+
return { count: r.u16() };
|
|
2545
|
+
}
|
|
2546
|
+
function parseEntryPoints(payload, pool) {
|
|
2547
|
+
const r = new Reader(payload);
|
|
2548
|
+
const count = r.u16();
|
|
2549
|
+
const out = [];
|
|
2550
|
+
for (let i = 0; i < count; i++) {
|
|
2551
|
+
const nameIdx = r.u16();
|
|
2552
|
+
const fnIdx = r.u16();
|
|
2553
|
+
out.push({ name: pool[nameIdx] ?? `?@${nameIdx}`, functionIndex: fnIdx });
|
|
2554
|
+
}
|
|
2555
|
+
return out;
|
|
2556
|
+
}
|
|
2557
|
+
function parseModifierTable(payload) {
|
|
2558
|
+
const r = new Reader(payload);
|
|
2559
|
+
return r.u16();
|
|
2560
|
+
}
|
|
2561
|
+
function parseBundleMetadata(payload, pool) {
|
|
2562
|
+
const r = new Reader(payload);
|
|
2563
|
+
const bundleIdIdx = r.u16();
|
|
2564
|
+
const bundleId = pool[bundleIdIdx] ?? "";
|
|
2565
|
+
const composableCount = r.u16();
|
|
2566
|
+
for (let i = 0; i < composableCount; i++) {
|
|
2567
|
+
r.i32();
|
|
2568
|
+
r.u16();
|
|
2569
|
+
const paramCount = r.u16();
|
|
2570
|
+
r.skip(paramCount * 2);
|
|
2571
|
+
r.u8();
|
|
2572
|
+
r.u8();
|
|
2573
|
+
r.u16();
|
|
2574
|
+
}
|
|
2575
|
+
const vmCount = r.u16();
|
|
2576
|
+
for (let i = 0; i < vmCount; i++) {
|
|
2577
|
+
r.u16();
|
|
2578
|
+
r.i32();
|
|
2579
|
+
const eventCount = r.u16();
|
|
2580
|
+
for (let j = 0; j < eventCount; j++) {
|
|
2581
|
+
r.u16();
|
|
2582
|
+
r.i32();
|
|
2583
|
+
}
|
|
2584
|
+
const stateCount = r.u16();
|
|
2585
|
+
r.skip(stateCount * 2);
|
|
2586
|
+
}
|
|
2587
|
+
const minAppVersion = r.remaining() >= 4 ? r.i32() : 0;
|
|
2588
|
+
return { bundleId, composableCount, viewModelCount: vmCount, minAppVersion };
|
|
2589
|
+
}
|
|
2590
|
+
async function readKtxFile(path) {
|
|
2591
|
+
const buf = await readFile17(path);
|
|
2592
|
+
return readKtxBuffer(buf, path);
|
|
2593
|
+
}
|
|
2594
|
+
function readKtxBuffer(buf, path = "<buffer>") {
|
|
2595
|
+
if (buf.length < HEADER_SIZE + SIGNATURE_SIZE) {
|
|
2596
|
+
throw new Error(`File too small (${buf.length} bytes) to be a .ktx bundle.`);
|
|
2597
|
+
}
|
|
2598
|
+
if (buf.subarray(0, 4).compare(KTX_MAGIC) !== 0) {
|
|
2599
|
+
throw new Error(`Wrong magic bytes \u2014 not a .ktx bundle. Got ${buf.subarray(0, 4).toString("hex")}.`);
|
|
2600
|
+
}
|
|
2601
|
+
const r = new Reader(buf);
|
|
2602
|
+
r.skip(4);
|
|
2603
|
+
const formatVersion = r.u16();
|
|
2604
|
+
const minRuntimeVersion = r.u16();
|
|
2605
|
+
const flags = r.u32();
|
|
2606
|
+
const sectionCount = r.u16();
|
|
2607
|
+
const sections = [];
|
|
2608
|
+
for (let i = 0; i < sectionCount; i++) {
|
|
2609
|
+
const type = r.u8();
|
|
2610
|
+
const compressed = r.u8() !== 0;
|
|
2611
|
+
const uncompressedSize = r.u32();
|
|
2612
|
+
const onDiskSize = r.u32();
|
|
2613
|
+
const rawOffset = r.pos();
|
|
2614
|
+
const payload = r.bytes(onDiskSize);
|
|
2615
|
+
sections.push({ type, typeName: sectionName(type), compressed, uncompressedSize, onDiskSize, rawOffset, payload });
|
|
2616
|
+
}
|
|
2617
|
+
const sigOffset = buf.length - SIGNATURE_SIZE;
|
|
2618
|
+
if (r.pos() > sigOffset) {
|
|
2619
|
+
throw new Error(`Section payloads overrun signature trailer (sections end at ${r.pos()}, sig at ${sigOffset}).`);
|
|
2620
|
+
}
|
|
2621
|
+
const signatureBytes = buf.subarray(sigOffset);
|
|
2622
|
+
const unsigned = (flags & FLAG_UNSIGNED) !== 0;
|
|
2623
|
+
const signed = !unsigned && signatureBytes.some((b) => b !== 0);
|
|
2624
|
+
const pool = sections.find((s) => s.type === SECTION_STRING_POOL);
|
|
2625
|
+
const stringPool = pool ? parseStringPool(decompressIfNeeded(pool)) : null;
|
|
2626
|
+
const safeMan = (type) => {
|
|
2627
|
+
if (!stringPool) return null;
|
|
2628
|
+
const s = sections.find((sec) => sec.type === type);
|
|
2629
|
+
if (!s) return null;
|
|
2630
|
+
return parseManifest(decompressIfNeeded(s), stringPool);
|
|
2631
|
+
};
|
|
2632
|
+
const fnTable = sections.find((s) => s.type === SECTION_FUNCTION_TABLE);
|
|
2633
|
+
const functionCount = fnTable ? parseFunctionTable(decompressIfNeeded(fnTable)).count : null;
|
|
2634
|
+
const entries = sections.find((s) => s.type === SECTION_ENTRY_POINTS);
|
|
2635
|
+
const entryPoints = entries && stringPool ? parseEntryPoints(decompressIfNeeded(entries), stringPool) : null;
|
|
2636
|
+
const modTable = sections.find((s) => s.type === SECTION_MODIFIER_TABLE);
|
|
2637
|
+
const modifierTableSize = modTable ? parseModifierTable(decompressIfNeeded(modTable)) : null;
|
|
2638
|
+
const meta = sections.find((s) => s.type === SECTION_BUNDLE_METADATA);
|
|
2639
|
+
const metadata = meta && stringPool ? parseBundleMetadata(decompressIfNeeded(meta), stringPool) : null;
|
|
2640
|
+
return {
|
|
2641
|
+
path,
|
|
2642
|
+
bytes: buf.length,
|
|
2643
|
+
formatVersion,
|
|
2644
|
+
minRuntimeVersion,
|
|
2645
|
+
flags,
|
|
2646
|
+
signed,
|
|
2647
|
+
debugInfoFlagSet: (flags & FLAG_DEBUG_INFO_PRESENT) !== 0,
|
|
2648
|
+
sectionCount,
|
|
2649
|
+
sections: sections.map((s) => ({
|
|
2650
|
+
type: s.type,
|
|
2651
|
+
typeName: s.typeName,
|
|
2652
|
+
compressed: s.compressed,
|
|
2653
|
+
uncompressedSize: s.uncompressedSize,
|
|
2654
|
+
onDiskSize: s.onDiskSize
|
|
2655
|
+
})),
|
|
2656
|
+
signature: { present: signed, hex: signed ? signatureBytes.toString("hex") : null },
|
|
2657
|
+
stringPool,
|
|
2658
|
+
adapterManifest: safeMan(SECTION_ADAPTER_MANIFEST),
|
|
2659
|
+
constructorManifest: safeMan(SECTION_CONSTRUCTOR_MANIFEST),
|
|
2660
|
+
capabilityManifest: safeMan(SECTION_CAPABILITY_MANIFEST),
|
|
2661
|
+
functionCount,
|
|
2662
|
+
entryPoints,
|
|
2663
|
+
modifierTableSize,
|
|
2664
|
+
bundleId: metadata?.bundleId ?? null,
|
|
2665
|
+
composableCount: metadata?.composableCount ?? null,
|
|
2666
|
+
viewModelCount: metadata?.viewModelCount ?? null,
|
|
2667
|
+
minAppVersion: metadata?.minAppVersion ?? null
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// src/agent/tools/analyze-ktx.ts
|
|
2672
|
+
function makeAnalyzeKtxTool(projectRoot) {
|
|
2673
|
+
return tool7({
|
|
2674
|
+
description: "Parse a .ktx bundle and return its structure: format version, sections, manifests (adapter/constructor/capability), function count, entry points, signature status, bundle id. Use to diagnose runtime load errors and verify bundle contents.",
|
|
2675
|
+
parameters: z8.object({
|
|
2676
|
+
path: z8.string().describe('Path to .ktx file relative to project root (e.g. "app/src/main/assets/ketoy/main.ktx").')
|
|
2677
|
+
}),
|
|
2678
|
+
execute: async ({ path }) => {
|
|
2679
|
+
const joined = safeJoin(projectRoot, path);
|
|
2680
|
+
if (!joined) return { error: `Refusing to read outside project root: ${path}` };
|
|
2681
|
+
const { abs, rel } = joined;
|
|
2682
|
+
try {
|
|
2683
|
+
const summary = await readKtxFile(abs);
|
|
2684
|
+
return { ...summary, path: rel };
|
|
2685
|
+
} catch (e) {
|
|
2686
|
+
return { error: `Failed to parse ${rel}: ${e.message}` };
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// src/agent/tools/skill-file.ts
|
|
2693
|
+
import { z as z9 } from "zod";
|
|
2694
|
+
import { tool as tool8 } from "ai";
|
|
2695
|
+
function makeSkillFileTool() {
|
|
2696
|
+
return tool8({
|
|
2697
|
+
description: "Read a file from the Ketoy skill (reference/, guides/, examples/, templates/). Useful when the agent needs deeper context than the system prompt carries \u2014 e.g. specific capability IDs, modifier patterns, or the migration playbook.",
|
|
2698
|
+
parameters: z9.object({
|
|
2699
|
+
name: z9.string().describe('Relative skill path, e.g. "reference/capabilities.md" or "examples/todo-screen.kt".')
|
|
2700
|
+
}),
|
|
2701
|
+
execute: async ({ name }) => {
|
|
2702
|
+
const available = await listSkillFiles();
|
|
2703
|
+
const content = await readSkillFile(name);
|
|
2704
|
+
if (content === null) {
|
|
2705
|
+
return { error: `Skill file not found: ${name}`, available };
|
|
2706
|
+
}
|
|
2707
|
+
return { name, content };
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// src/agent/tools/index.ts
|
|
2713
|
+
async function buildTools(opts) {
|
|
2714
|
+
const config = await loadConfig();
|
|
2715
|
+
const { projectRoot } = opts;
|
|
2716
|
+
return {
|
|
2717
|
+
read_file: makeReadFileTool(projectRoot),
|
|
2718
|
+
write_file: makeWriteFileTool(projectRoot, { autoApprove: false }),
|
|
2719
|
+
edit_file: makeEditFileTool(projectRoot, { autoApprove: false }),
|
|
2720
|
+
grep: makeGrepTool(projectRoot),
|
|
2721
|
+
glob: makeGlobTool(projectRoot),
|
|
2722
|
+
bash: makeBashTool(projectRoot, { autoApproveSafe: config.agent.autoApproveSafeBash }),
|
|
2723
|
+
analyze_ktx: makeAnalyzeKtxTool(projectRoot),
|
|
2724
|
+
skill_file: makeSkillFileTool()
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// src/agent/loop.ts
|
|
2729
|
+
async function runAgent(opts) {
|
|
2730
|
+
const config = await loadConfig();
|
|
2731
|
+
const { model, modelName, providerId } = await resolveModel(opts.modelOverride);
|
|
2732
|
+
const { prompt: system, loadedFiles } = await buildSystemPrompt({
|
|
2733
|
+
taskHint: opts.userPrompt,
|
|
2734
|
+
projectRoot: opts.projectRoot,
|
|
2735
|
+
modelName
|
|
2736
|
+
});
|
|
2737
|
+
const tools = await buildTools({ projectRoot: opts.projectRoot });
|
|
2738
|
+
if (!opts.silent) {
|
|
2739
|
+
log.detail(
|
|
2740
|
+
`model=${providerId}:${modelName} \xB7 skill=${loadedFiles.length} file(s) loaded \xB7 tools=${Object.keys(tools).length}`
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
const messages = [
|
|
2744
|
+
...opts.history ?? [],
|
|
2745
|
+
{ role: "user", content: opts.userPrompt }
|
|
2746
|
+
];
|
|
2747
|
+
const result = streamText({
|
|
2748
|
+
model,
|
|
2749
|
+
system,
|
|
2750
|
+
messages,
|
|
2751
|
+
tools,
|
|
2752
|
+
maxSteps: config.agent.maxSteps,
|
|
2753
|
+
onStepFinish: ({ toolCalls, finishReason }) => {
|
|
2754
|
+
if (opts.silent) return;
|
|
2755
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
2756
|
+
for (const tc of toolCalls) {
|
|
2757
|
+
log.detail(`${pc.cyan("\u2192")} ${tc.toolName}`);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
if (finishReason && finishReason !== "stop" && finishReason !== "tool-calls") {
|
|
2761
|
+
log.detail(`finish: ${finishReason}`);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
if (!opts.silent) log.raw("\n");
|
|
2766
|
+
for await (const chunk of result.textStream) {
|
|
2767
|
+
if (!opts.silent) log.raw(chunk);
|
|
2768
|
+
}
|
|
2769
|
+
if (!opts.silent) log.raw("\n\n");
|
|
2770
|
+
const response = await result.response;
|
|
2771
|
+
const usage = await result.usage;
|
|
2772
|
+
const steps = await result.steps;
|
|
2773
|
+
return {
|
|
2774
|
+
history: [...messages, ...response.messages],
|
|
2775
|
+
loadedSkillFiles: loadedFiles,
|
|
2776
|
+
steps: steps.length,
|
|
2777
|
+
usage: {
|
|
2778
|
+
promptTokens: usage.promptTokens,
|
|
2779
|
+
completionTokens: usage.completionTokens,
|
|
2780
|
+
totalTokens: usage.totalTokens
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// src/commands/chat.ts
|
|
2786
|
+
async function runChatCommand(opts) {
|
|
2787
|
+
log.info(pc.bold("Ketoy chat") + pc.dim(" \u2014 type your request, /exit to quit, /clear to reset history."));
|
|
2788
|
+
log.detail(`Working dir: ${opts.projectRoot}`);
|
|
2789
|
+
if (opts.model) log.detail(`Model override: ${opts.model}`);
|
|
2790
|
+
let history = [];
|
|
2791
|
+
let prompt = opts.initialPrompt;
|
|
2792
|
+
while (true) {
|
|
2793
|
+
let userInput;
|
|
2794
|
+
if (prompt !== void 0) {
|
|
2795
|
+
userInput = prompt;
|
|
2796
|
+
prompt = void 0;
|
|
2797
|
+
log.raw(`${pc.bold("\u203A ")}${userInput}
|
|
2798
|
+
`);
|
|
2799
|
+
} else {
|
|
2800
|
+
try {
|
|
2801
|
+
userInput = await input2({ message: pc.bold("\u203A"), validate: (v) => v.trim().length > 0 });
|
|
2802
|
+
} catch (e) {
|
|
2803
|
+
if (e.name === "ExitPromptError") throw new UserAbortError();
|
|
2804
|
+
throw e;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
const trimmed = userInput.trim();
|
|
2808
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
2809
|
+
log.info("Bye.");
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (trimmed === "/clear") {
|
|
2813
|
+
history = [];
|
|
2814
|
+
log.info("History cleared.");
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2817
|
+
if (trimmed === "/help") {
|
|
2818
|
+
printHelp();
|
|
2819
|
+
continue;
|
|
2820
|
+
}
|
|
2821
|
+
if (trimmed === "/cost") {
|
|
2822
|
+
log.info("Cost tracking is coming in v0.3.x.");
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2825
|
+
if (trimmed.startsWith("/")) {
|
|
2826
|
+
log.warn(`Unknown command: ${trimmed}. Use /help.`);
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
try {
|
|
2830
|
+
const result = await runAgent({
|
|
2831
|
+
userPrompt: userInput,
|
|
2832
|
+
projectRoot: opts.projectRoot,
|
|
2833
|
+
modelOverride: opts.model,
|
|
2834
|
+
history
|
|
2835
|
+
});
|
|
2836
|
+
history = result.history;
|
|
2837
|
+
const tot = result.usage.totalTokens;
|
|
2838
|
+
const tokenLine = tot ? pc.dim(
|
|
2839
|
+
`${result.steps} step(s), ${tot} tokens (${result.usage.promptTokens}\u2191 ${result.usage.completionTokens}\u2193)`
|
|
2840
|
+
) : pc.dim(`${result.steps} step(s)`);
|
|
2841
|
+
log.detail(tokenLine);
|
|
2842
|
+
} catch (e) {
|
|
2843
|
+
log.error(e.message);
|
|
2844
|
+
const cont = await confirm5({ message: "Continue chatting?", default: true }).catch(() => false);
|
|
2845
|
+
if (!cont) return;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
function printHelp() {
|
|
2850
|
+
log.info("Slash commands:");
|
|
2851
|
+
log.detail("/exit \u2014 quit the chat session");
|
|
2852
|
+
log.detail("/clear \u2014 clear conversation history");
|
|
2853
|
+
log.detail("/help \u2014 show this help");
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// src/commands/migrate.ts
|
|
2857
|
+
import { resolve as resolve5, relative as relative5 } from "path";
|
|
2858
|
+
import { access as access8 } from "fs/promises";
|
|
2859
|
+
async function fileExists7(p) {
|
|
2860
|
+
try {
|
|
2861
|
+
await access8(p);
|
|
2862
|
+
return true;
|
|
2863
|
+
} catch {
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
async function runMigrateCommand(opts) {
|
|
2868
|
+
if (opts.files.length === 0) {
|
|
2869
|
+
throw new KetoyCliError("Pass at least one file path to migrate.");
|
|
2870
|
+
}
|
|
2871
|
+
for (const f of opts.files) {
|
|
2872
|
+
const abs = resolve5(opts.projectRoot, f);
|
|
2873
|
+
if (!await fileExists7(abs)) {
|
|
2874
|
+
throw new KetoyCliError(`File not found: ${f}`);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
const list = opts.files.map((f) => relative5(opts.projectRoot, resolve5(opts.projectRoot, f)));
|
|
2878
|
+
log.info(`Migrating ${list.length} file(s) to KBC: ${list.join(", ")}`);
|
|
2879
|
+
const prompt = `Migrate the following Compose source files to Ketoy KBC. Follow the migration playbook in skills/ketoy/guides/migrate.md strictly:
|
|
2880
|
+
|
|
2881
|
+
` + list.map((f) => `- ${f}`).join("\n") + `
|
|
2882
|
+
|
|
2883
|
+
Procedure (do all steps, do not skip):
|
|
2884
|
+
1. Read each file in full.
|
|
2885
|
+
2. Audit each: list composables, state holders, side effects, forbidden APIs.
|
|
2886
|
+
3. For each unsupported call, propose a capability or alternative.
|
|
2887
|
+
4. Show me the migration plan as a checklist and ASK for confirmation before editing.
|
|
2888
|
+
5. After I confirm: update Capabilities.kt, ketoy-capabilities.json, and the host CapabilityRegistry; then rewrite each file with @KetoyComposable + @KetoyEntryPoint, replacing forbidden calls with capability calls.
|
|
2889
|
+
6. Run \`./gradlew :app:ketoyBundle --rerun-tasks\` and resolve any KetoyBC errors.
|
|
2890
|
+
7. Report what changed and any items I need to wire up host-side.
|
|
2891
|
+
|
|
2892
|
+
Do not silently rewrite. Use edit_file with surgical changes where files already exist.`;
|
|
2893
|
+
log.detail(`Sending migration task to agent \u2014 this will take several turns.`);
|
|
2894
|
+
log.raw("\n");
|
|
2895
|
+
const result = await runAgent({
|
|
2896
|
+
userPrompt: prompt,
|
|
2897
|
+
projectRoot: opts.projectRoot,
|
|
2898
|
+
modelOverride: opts.model
|
|
2899
|
+
});
|
|
2900
|
+
log.detail(
|
|
2901
|
+
pc.dim(
|
|
2902
|
+
`Migration agent finished in ${result.steps} step(s)${result.usage.totalTokens ? `, ${result.usage.totalTokens} tokens` : ""}.`
|
|
2903
|
+
)
|
|
2904
|
+
);
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/commands/doctor.ts
|
|
2908
|
+
import { execa as execa2 } from "execa";
|
|
2909
|
+
import { join as join8 } from "path";
|
|
2910
|
+
import { access as access9 } from "fs/promises";
|
|
2911
|
+
async function fileExists8(p) {
|
|
2912
|
+
try {
|
|
2913
|
+
await access9(p);
|
|
2914
|
+
return true;
|
|
2915
|
+
} catch {
|
|
2916
|
+
return false;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
async function runDoctorCommand(opts) {
|
|
2920
|
+
const gradlew = process.platform === "win32" ? join8(opts.projectRoot, "gradlew.bat") : join8(opts.projectRoot, "gradlew");
|
|
2921
|
+
if (!await fileExists8(gradlew)) {
|
|
2922
|
+
throw new KetoyCliError(`No gradle wrapper found at ${gradlew}.`);
|
|
2923
|
+
}
|
|
2924
|
+
log.info(`Running ${pc.bold(`./gradlew ${opts.task}`)} to capture output\u2026`);
|
|
2925
|
+
const taskArgs = opts.task.split(/\s+/).filter((s) => s.length > 0);
|
|
2926
|
+
const result = await execa2(gradlew, [...taskArgs, "--console=plain", "--stacktrace"], {
|
|
2927
|
+
cwd: opts.projectRoot,
|
|
2928
|
+
reject: false,
|
|
2929
|
+
all: true
|
|
2930
|
+
});
|
|
2931
|
+
const fullLog = result.all ?? `${result.stdout}
|
|
2932
|
+
${result.stderr}`;
|
|
2933
|
+
const trimmed = fullLog.length > 32 * 1024 ? fullLog.slice(-32 * 1024) : fullLog;
|
|
2934
|
+
if (result.exitCode === 0) {
|
|
2935
|
+
log.success(`Task ${opts.task} succeeded \u2014 no errors to diagnose.`);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
log.warn(`Task failed with exit code ${result.exitCode}. Asking the agent to diagnose\u2026`);
|
|
2939
|
+
log.raw("\n");
|
|
2940
|
+
const prompt = `The Gradle task \`${opts.task}\` failed. Below is the captured output (last 32 KB).
|
|
2941
|
+
|
|
2942
|
+
Please:
|
|
2943
|
+
1. Identify each distinct error (especially "KetoyBC:" diagnostics).
|
|
2944
|
+
2. For each one, explain the root cause briefly and propose the minimal fix.
|
|
2945
|
+
3. Reference the skill (guides/diagnose-errors.md) when relevant.
|
|
2946
|
+
4. If a fix requires editing files, ASK before applying \u2014 use edit_file for surgical edits only.
|
|
2947
|
+
5. If multiple unrelated errors, prioritize the first KBC compile error since later ones often cascade.
|
|
2948
|
+
|
|
2949
|
+
=== BEGIN OUTPUT ===
|
|
2950
|
+
${trimmed}
|
|
2951
|
+
=== END OUTPUT ===`;
|
|
2952
|
+
await runAgent({
|
|
2953
|
+
userPrompt: prompt,
|
|
2954
|
+
projectRoot: opts.projectRoot,
|
|
2955
|
+
modelOverride: opts.model
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
// src/commands/build.ts
|
|
2960
|
+
import { execa as execa3 } from "execa";
|
|
2961
|
+
import { join as join9 } from "path";
|
|
2962
|
+
import { access as access10, stat as stat3 } from "fs/promises";
|
|
2963
|
+
async function fileExists9(p) {
|
|
2964
|
+
try {
|
|
2965
|
+
await access10(p);
|
|
2966
|
+
return true;
|
|
2967
|
+
} catch {
|
|
2968
|
+
return false;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
async function runBuildCommand(opts) {
|
|
2972
|
+
const gradlew = process.platform === "win32" ? join9(opts.projectRoot, "gradlew.bat") : join9(opts.projectRoot, "gradlew");
|
|
2973
|
+
if (!await fileExists9(gradlew)) {
|
|
2974
|
+
throw new KetoyCliError(`No gradle wrapper found at ${gradlew}. Run from the Android project root.`);
|
|
2975
|
+
}
|
|
2976
|
+
const taskArgs = opts.variant === "bundle" ? [":app:ketoyBundle", "--rerun-tasks"] : opts.variant === "debug" ? [":app:assembleDebug"] : [":app:assembleRelease"];
|
|
2977
|
+
log.info(`Running ${pc.bold(`./gradlew ${taskArgs.join(" ")}`)} \u2026`);
|
|
2978
|
+
const result = await execa3(gradlew, [...taskArgs, "--console=plain"], {
|
|
2979
|
+
cwd: opts.projectRoot,
|
|
2980
|
+
reject: false,
|
|
2981
|
+
stdio: "inherit"
|
|
2982
|
+
});
|
|
2983
|
+
if (result.exitCode !== 0) {
|
|
2984
|
+
throw new KetoyCliError(`Build failed (exit ${result.exitCode}). Run \`ketoy doctor\` to analyze.`, result.exitCode ?? 1);
|
|
2985
|
+
}
|
|
2986
|
+
const bundlePath = join9(opts.projectRoot, "app", "src", "main", "assets", "ketoy", "main.ktx");
|
|
2987
|
+
if (await fileExists9(bundlePath)) {
|
|
2988
|
+
const s = await stat3(bundlePath);
|
|
2989
|
+
log.success(`Build succeeded \u2014 bundle at ${pc.bold("app/src/main/assets/ketoy/main.ktx")} (${s.size} bytes).`);
|
|
2990
|
+
} else {
|
|
2991
|
+
log.success("Build succeeded.");
|
|
2992
|
+
log.warn("Bundle not found at the expected path. Confirm `ketoy { exportFromAppModule = true }` is set in app/build.gradle.kts.");
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// src/commands/analyze.ts
|
|
2997
|
+
import { resolve as resolve6 } from "path";
|
|
2998
|
+
async function runAnalyzeCommand(opts) {
|
|
2999
|
+
const abs = resolve6(process.cwd(), opts.path);
|
|
3000
|
+
let summary;
|
|
3001
|
+
try {
|
|
3002
|
+
summary = await readKtxFile(abs);
|
|
3003
|
+
} catch (e) {
|
|
3004
|
+
throw new KetoyCliError(`Could not parse ${opts.path}: ${e.message}`);
|
|
3005
|
+
}
|
|
3006
|
+
if (opts.json) {
|
|
3007
|
+
const sanitized = {
|
|
3008
|
+
...summary,
|
|
3009
|
+
stringPool: opts.showStrings ? summary.stringPool : null,
|
|
3010
|
+
adapterManifest: opts.showManifest ? summary.adapterManifest : null,
|
|
3011
|
+
constructorManifest: opts.showManifest ? summary.constructorManifest : null,
|
|
3012
|
+
capabilityManifest: opts.showManifest ? summary.capabilityManifest : null
|
|
3013
|
+
};
|
|
3014
|
+
log.raw(JSON.stringify(sanitized, null, 2) + "\n");
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
log.info(pc.bold(opts.path));
|
|
3018
|
+
log.detail(`bytes: ${summary.bytes.toLocaleString()}`);
|
|
3019
|
+
log.detail(`formatVersion: ${summary.formatVersion}`);
|
|
3020
|
+
log.detail(`minRuntimeVersion: ${summary.minRuntimeVersion}`);
|
|
3021
|
+
log.detail(`flags: 0x${summary.flags.toString(16).padStart(8, "0")}`);
|
|
3022
|
+
log.detail(`signed: ${summary.signed ? pc.green("yes") : pc.dim("no")}`);
|
|
3023
|
+
if (summary.bundleId !== null) log.detail(`bundleId: ${summary.bundleId || pc.dim("(empty)")}`);
|
|
3024
|
+
if (summary.minAppVersion !== null) log.detail(`minAppVersion: ${summary.minAppVersion}`);
|
|
3025
|
+
if (summary.functionCount !== null) log.detail(`functions: ${summary.functionCount}`);
|
|
3026
|
+
if (summary.composableCount !== null) log.detail(`composables: ${summary.composableCount}`);
|
|
3027
|
+
if (summary.viewModelCount !== null) log.detail(`viewModels: ${summary.viewModelCount}`);
|
|
3028
|
+
if (summary.modifierTableSize !== null) log.detail(`modifierTable: ${summary.modifierTableSize} entries`);
|
|
3029
|
+
log.info("Sections:");
|
|
3030
|
+
for (const s of summary.sections) {
|
|
3031
|
+
const cmp = s.compressed ? pc.cyan(" brotli") : "";
|
|
3032
|
+
log.detail(
|
|
3033
|
+
`${s.typeName.padEnd(22)} ${String(s.onDiskSize).padStart(8)} on-disk / ${String(s.uncompressedSize).padStart(8)} uncompressed${cmp}`
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
3036
|
+
if (summary.entryPoints && summary.entryPoints.length > 0) {
|
|
3037
|
+
log.info("Entry points:");
|
|
3038
|
+
for (const e of summary.entryPoints) log.detail(`${e.name.padEnd(28)} fn#${e.functionIndex}`);
|
|
3039
|
+
}
|
|
3040
|
+
if (opts.showManifest) {
|
|
3041
|
+
printManifest("Adapter manifest", summary.adapterManifest);
|
|
3042
|
+
printManifest("Constructor manifest", summary.constructorManifest);
|
|
3043
|
+
printManifest("Capability manifest", summary.capabilityManifest);
|
|
3044
|
+
}
|
|
3045
|
+
if (opts.showStrings && summary.stringPool) {
|
|
3046
|
+
log.info(`String pool (${summary.stringPool.length} entries):`);
|
|
3047
|
+
summary.stringPool.forEach((s, i) => {
|
|
3048
|
+
log.detail(`[${i}] ${s}`);
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
function printManifest(title, entries) {
|
|
3053
|
+
if (!entries) return;
|
|
3054
|
+
if (entries.length === 0) {
|
|
3055
|
+
log.info(`${title}: ${pc.dim("(empty)")}`);
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
log.info(`${title} (${entries.length}):`);
|
|
3059
|
+
for (const e of entries) {
|
|
3060
|
+
const id = `0x${e.id.toString(16).padStart(4, "0").toUpperCase()}`;
|
|
3061
|
+
const req = e.required ? "" : pc.dim(" (optional)");
|
|
3062
|
+
log.detail(`${id} ${e.name}${req}`);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// src/cli.ts
|
|
3067
|
+
function buildCli() {
|
|
3068
|
+
const program = new Command();
|
|
3069
|
+
program.name("ketoy").description("AI-powered CLI for Ketoy \u2014 scaffold, write, migrate, diagnose, and build .ktx bundles.").version(CLI_VERSION, "-v, --version", "Show CLI + Ketoy target versions");
|
|
3070
|
+
program.command("version").description("Print CLI + Ketoy versions and default model").action(async () => {
|
|
3071
|
+
await runVersionCommand();
|
|
3072
|
+
});
|
|
3073
|
+
const auth = program.command("auth [provider]").description("Configure API credentials for an AI provider (anthropic, openai, google, mistral, groq, xai, openrouter, ollama)").option("--remove", "Remove the saved credentials for the given provider").option("--list", "List configured providers without prompting").action(async (provider, opts) => {
|
|
3074
|
+
if (opts.list) {
|
|
3075
|
+
await listAuthStatus();
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
await runAuthCommand({ provider, remove: opts.remove });
|
|
3079
|
+
});
|
|
3080
|
+
void auth;
|
|
3081
|
+
const config = program.command("config").description("Inspect or modify global CLI configuration");
|
|
3082
|
+
config.command("list").description("Show current configuration").action(async () => {
|
|
3083
|
+
await runConfigList();
|
|
3084
|
+
});
|
|
3085
|
+
config.command("get <key>").description("Read a single config value (supports dotted keys like agent.maxSteps)").action(async (key) => {
|
|
3086
|
+
await runConfigGet(key);
|
|
3087
|
+
});
|
|
3088
|
+
config.command("set <key> <value>").description("Set a config value").action(async (key, value) => {
|
|
3089
|
+
await runConfigSet(key, value);
|
|
3090
|
+
});
|
|
3091
|
+
program.command("init").description("Add Ketoy to the current Android project via surgical edits").option("--hilt", "Force Hilt-aware setup").option("--no-hilt", "Force non-Hilt setup").option("--install-screen", "Install KetoyScreen in MainActivity (replaces setContent body \u2014 fresh projects)").option("--no-install-screen", "Skip MainActivity edit; integrate KetoyScreen manually").option("-y, --yes", "Skip confirmation prompts", false).option("--dry-run", "Print the plan without writing any files", false).action(async (opts) => {
|
|
3092
|
+
const hiltOption = opts.hilt === void 0 ? null : opts.hilt;
|
|
3093
|
+
const installScreenOption = opts.installScreen === void 0 ? null : opts.installScreen;
|
|
3094
|
+
await runInitCommand(process2.cwd(), {
|
|
3095
|
+
hilt: hiltOption,
|
|
3096
|
+
installKetoyScreen: installScreenOption,
|
|
3097
|
+
yes: opts.yes,
|
|
3098
|
+
dryRun: opts.dryRun
|
|
3099
|
+
});
|
|
3100
|
+
});
|
|
3101
|
+
program.command("chat [prompt...]").description("Open an AI-powered Ketoy assistant in the current project").option("-m, --model <id>", "Override the default model (e.g. openai:gpt-4o)").action(async (prompt, opts) => {
|
|
3102
|
+
await runChatCommand({
|
|
3103
|
+
projectRoot: process2.cwd(),
|
|
3104
|
+
model: opts.model,
|
|
3105
|
+
initialPrompt: prompt && prompt.length > 0 ? prompt.join(" ") : void 0
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
3108
|
+
program.command("migrate <files...>").description("AI-driven per-file migration of Compose source(s) to KBC").option("-m, --model <id>", "Override the default model").action(async (files, opts) => {
|
|
3109
|
+
await runMigrateCommand({ projectRoot: process2.cwd(), files, model: opts.model });
|
|
3110
|
+
});
|
|
3111
|
+
program.command("doctor [task]").description("Run a Gradle task, capture output, and ask the agent to diagnose failures").option("-m, --model <id>", "Override the default model").action(async (task, opts) => {
|
|
3112
|
+
await runDoctorCommand({
|
|
3113
|
+
projectRoot: process2.cwd(),
|
|
3114
|
+
task: task ?? ":app:ketoyBundle --rerun-tasks",
|
|
3115
|
+
model: opts.model
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
3118
|
+
program.command("build").description("Run the Ketoy bundle / Android assemble Gradle task").option("--variant <name>", "bundle, debug, or release", "bundle").action(async (opts) => {
|
|
3119
|
+
const variant = opts.variant === "debug" || opts.variant === "release" || opts.variant === "bundle" ? opts.variant : "bundle";
|
|
3120
|
+
await runBuildCommand({ projectRoot: process2.cwd(), variant });
|
|
3121
|
+
});
|
|
3122
|
+
program.command("analyze <path>").description("Inspect a .ktx bundle").option("--manifest", "Print adapter / constructor / capability manifests").option("--strings", "Print the string pool").option("--json", "Emit JSON instead of human-readable output").action(async (path, opts) => {
|
|
3123
|
+
await runAnalyzeCommand({
|
|
3124
|
+
path,
|
|
3125
|
+
showManifest: !!opts.manifest,
|
|
3126
|
+
showStrings: !!opts.strings,
|
|
3127
|
+
json: !!opts.json
|
|
3128
|
+
});
|
|
3129
|
+
});
|
|
3130
|
+
program.command("publish").description("Publish a bundle (deferred until Ketoy backend GA)").action(() => {
|
|
3131
|
+
log.warn(
|
|
3132
|
+
"Publishing is deferred until the Ketoy backend ships. Until then, upload `.ktx` files to your CDN directly and consume via KetoyBundleSource.Remote(url)."
|
|
3133
|
+
);
|
|
3134
|
+
});
|
|
3135
|
+
return program;
|
|
3136
|
+
}
|
|
3137
|
+
async function runCli(argv) {
|
|
3138
|
+
const program = buildCli();
|
|
3139
|
+
try {
|
|
3140
|
+
await program.parseAsync([...argv]);
|
|
3141
|
+
} catch (e) {
|
|
3142
|
+
if (e instanceof UserAbortError) {
|
|
3143
|
+
log.warn(e.message);
|
|
3144
|
+
process2.exit(e.exitCode);
|
|
3145
|
+
}
|
|
3146
|
+
if (e instanceof KetoyCliError) {
|
|
3147
|
+
log.error(e.message);
|
|
3148
|
+
process2.exit(e.exitCode);
|
|
3149
|
+
}
|
|
3150
|
+
const err = e;
|
|
3151
|
+
if (err.name === "ExitPromptError") {
|
|
3152
|
+
log.warn("Aborted.");
|
|
3153
|
+
process2.exit(130);
|
|
3154
|
+
}
|
|
3155
|
+
log.error(err.message ?? String(e));
|
|
3156
|
+
if (process2.env["KETOY_DEBUG"]) {
|
|
3157
|
+
console.error(err);
|
|
3158
|
+
}
|
|
3159
|
+
process2.exit(1);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
// bin/ketoy.ts
|
|
3164
|
+
await runCli(process.argv);
|
|
3165
|
+
//# sourceMappingURL=ketoy.js.map
|