@odla-ai/cli 0.1.0 → 0.2.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/README.md CHANGED
@@ -46,6 +46,8 @@ npx odla-ai doctor
46
46
  npx odla-ai provision --dry-run
47
47
  npx odla-ai provision --write-dev-vars
48
48
  npx odla-ai smoke --env dev
49
+ npx odla-ai secrets push --env dev
50
+ npx odla-ai skill install
49
51
  npx odla-ai version
50
52
  ```
51
53
 
@@ -81,6 +83,30 @@ odla-db schema with the tenant key, compares expected schema entities, and runs
81
83
  a count aggregate against the first entity. It fails early with a clear message
82
84
  when provisioning has not written `.odla/credentials.local.json`.
83
85
 
86
+ `secrets push --env <env>` moves the env's odla-db key from
87
+ `.odla/credentials.local.json` into the deployed Worker by piping it over
88
+ stdin to `wrangler secret put ODLA_API_KEY` — the value never appears on
89
+ argv, in output, or in an agent's transcript. It preflights `wrangler whoami`
90
+ and the presence of a wrangler config file. The env `prod`/`production`
91
+ targets the top-level wrangler environment (no `--env` flag is passed to
92
+ wrangler) and requires `--yes`; every other env maps to wrangler's
93
+ `--env <name>`. `--dry-run` prints a redacted plan without spawning anything.
94
+
95
+ `skill install` copies the Claude Code skills bundled with this package
96
+ (currently `odla-migrate`, the phased GitHub Pages → odla migration
97
+ procedure) into the project's `.claude/skills/`, or into `~/.claude/skills/`
98
+ with `--global`. Re-runs are idempotent; a locally modified skill file is
99
+ never overwritten without `--force`.
100
+
101
+ `doctor` also lints for common footguns: permission rules that are literally
102
+ `"true"` (public writes always warn; a public `view` warns unless the
103
+ namespace is listed in `db.publicRead`), schema entities missing from the
104
+ rules map, credential files (`.dev.vars`, `.odla/*`) tracked by git, a
105
+ wrangler assets directory pointed at the project root or at a directory
106
+ containing `node_modules` (the `wrangler dev` "spawn EBADF" footgun),
107
+ secret-shaped values in wrangler `vars`, and a package.json script literally
108
+ named `deploy` (CI may auto-deploy it).
109
+
84
110
  ## Config
85
111
 
86
112
  ```js
@@ -94,6 +120,7 @@ export default {
94
120
  schema: "./src/odla/schema.mjs",
95
121
  rules: "./src/odla/rules.mjs",
96
122
  defaultRules: "deny",
123
+ publicRead: [], // namespaces where a `view: "true"` rule is intentional
97
124
  },
98
125
  ai: {
99
126
  provider: process.env.ODLA_AI_PROVIDER ?? "anthropic",
package/dist/bin.cjs CHANGED
@@ -28,7 +28,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
28
28
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
29
 
30
30
  // src/cli.ts
31
- var import_node_fs4 = require("fs");
31
+ var import_node_fs7 = require("fs");
32
32
 
33
33
  // src/config.ts
34
34
  var import_node_fs = require("fs");
@@ -135,6 +135,181 @@ function unique(values) {
135
135
  return [...new Set(values.filter(Boolean))];
136
136
  }
137
137
 
138
+ // src/doctor-checks.ts
139
+ var import_node_child_process2 = require("child_process");
140
+ var import_node_fs3 = require("fs");
141
+ var import_node_path3 = require("path");
142
+
143
+ // src/redact.ts
144
+ var REPLACEMENTS = [
145
+ [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
146
+ [/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
147
+ [/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
148
+ [/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
149
+ [/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
150
+ [/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
151
+ ];
152
+ function redactSecrets(value) {
153
+ let result = value;
154
+ for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
155
+ return result;
156
+ }
157
+ function looksSecret(value) {
158
+ return redactSecrets(value) !== value || value.includes("-----BEGIN");
159
+ }
160
+
161
+ // src/wrangler.ts
162
+ var import_node_child_process = require("child_process");
163
+ var import_node_fs2 = require("fs");
164
+ var import_node_path2 = require("path");
165
+ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
166
+ const child = (0, import_node_child_process.spawn)(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
167
+ let stdout = "";
168
+ let stderr = "";
169
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
170
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
171
+ child.on("error", reject);
172
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stdout, stderr }));
173
+ child.stdin.end(opts?.input ?? "");
174
+ });
175
+ var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
176
+ function findWranglerConfig(rootDir) {
177
+ for (const name of WRANGLER_CONFIG_FILES) {
178
+ const path = (0, import_node_path2.join)(rootDir, name);
179
+ if ((0, import_node_fs2.existsSync)(path)) return path;
180
+ }
181
+ return null;
182
+ }
183
+ function readWranglerConfig(path) {
184
+ if (path.endsWith(".toml")) return null;
185
+ try {
186
+ return JSON.parse(stripJsonComments((0, import_node_fs2.readFileSync)(path, "utf8")));
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+ function stripJsonComments(text) {
192
+ let result = "";
193
+ let inString = false;
194
+ for (let i = 0; i < text.length; i++) {
195
+ const ch = text[i];
196
+ if (inString) {
197
+ result += ch;
198
+ if (ch === "\\") {
199
+ result += text[i + 1] ?? "";
200
+ i++;
201
+ } else if (ch === '"') {
202
+ inString = false;
203
+ }
204
+ continue;
205
+ }
206
+ if (ch === '"') {
207
+ inString = true;
208
+ result += ch;
209
+ continue;
210
+ }
211
+ if (ch === "/" && text[i + 1] === "/") {
212
+ while (i < text.length && text[i] !== "\n") i++;
213
+ result += "\n";
214
+ continue;
215
+ }
216
+ if (ch === "/" && text[i + 1] === "*") {
217
+ i += 2;
218
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
219
+ i++;
220
+ continue;
221
+ }
222
+ result += ch;
223
+ }
224
+ return result;
225
+ }
226
+ async function wranglerLoggedIn(run, cwd) {
227
+ try {
228
+ const result = await run("npx", ["wrangler", "whoami"], { cwd });
229
+ return result.code === 0 && !/not authenticated/i.test(`${result.stdout}${result.stderr}`);
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+ function wranglerPutSecret(run, opts) {
235
+ const args = ["wrangler", "secret", "put", opts.name, ...opts.env ? ["--env", opts.env] : []];
236
+ return run("npx", args, { input: opts.value, cwd: opts.cwd });
237
+ }
238
+
239
+ // src/doctor-checks.ts
240
+ function lintRules(rules, entities, publicRead) {
241
+ const warnings = [];
242
+ if (!rules) return warnings;
243
+ for (const [ns, actions] of Object.entries(rules)) {
244
+ for (const [action, expr] of Object.entries(actions)) {
245
+ if (typeof expr !== "string" || expr.trim() !== "true") continue;
246
+ if (action === "view" && publicRead.includes(ns)) continue;
247
+ warnings.push(
248
+ action === "view" ? `rules.${ns}.view is "true" \u2014 public read; add "${ns}" to db.publicRead if intended` : `rules.${ns}.${action} is "true" \u2014 any client can ${action} ${ns} rows`
249
+ );
250
+ }
251
+ }
252
+ for (const entity of entities) {
253
+ if (!(entity in rules)) warnings.push(`schema entity "${entity}" has no rules entry (all client access denied)`);
254
+ }
255
+ return warnings;
256
+ }
257
+ var defaultExec = (cmd, args, cwd) => (0, import_node_child_process2.execFileSync)(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
258
+ function trackedSecretFiles(rootDir, exec = defaultExec) {
259
+ let output;
260
+ try {
261
+ output = exec("git", ["ls-files", "--", ".dev.vars", ".odla"], rootDir);
262
+ } catch {
263
+ return [];
264
+ }
265
+ return output.split(/\r?\n/).filter(Boolean).map((path) => `${path} is tracked by git \u2014 run "git rm --cached ${path}" and commit; it belongs in .gitignore`);
266
+ }
267
+ function wranglerWarnings(rootDir) {
268
+ const configPath = findWranglerConfig(rootDir);
269
+ if (!configPath) return [];
270
+ const config = readWranglerConfig(configPath);
271
+ if (!config) return [];
272
+ const warnings = [];
273
+ const blocks = [{ label: "", block: config }];
274
+ const envs = config.env;
275
+ if (envs && typeof envs === "object") {
276
+ for (const [name, block] of Object.entries(envs)) {
277
+ if (block && typeof block === "object") blocks.push({ label: `env.${name}.`, block });
278
+ }
279
+ }
280
+ for (const { label, block } of blocks) {
281
+ const assets = block.assets;
282
+ if (assets?.directory) {
283
+ const dir = (0, import_node_path3.resolve)(rootDir, assets.directory);
284
+ if (dir === (0, import_node_path3.resolve)(rootDir)) {
285
+ warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
286
+ } else if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(dir, "node_modules"))) {
287
+ warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
288
+ }
289
+ }
290
+ const vars = block.vars;
291
+ if (vars && typeof vars === "object") {
292
+ for (const [name, value] of Object.entries(vars)) {
293
+ if (name === "ODLA_API_KEY" || typeof value === "string" && looksSecret(value)) {
294
+ warnings.push(`wrangler ${label}vars.${name} looks like a secret \u2014 use "odla-ai secrets push" / wrangler secret put, never vars`);
295
+ }
296
+ }
297
+ }
298
+ }
299
+ const pkg = readPackageJson(rootDir);
300
+ if (pkg && typeof pkg.scripts === "object" && pkg.scripts && "deploy" in pkg.scripts) {
301
+ warnings.push(`package.json has a script named "deploy" \u2014 CI may auto-deploy it; rename to deploy:app`);
302
+ }
303
+ return warnings;
304
+ }
305
+ function readPackageJson(rootDir) {
306
+ try {
307
+ return JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.join)(rootDir, "package.json"), "utf8"));
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
138
313
  // src/doctor.ts
139
314
  async function doctor(options) {
140
315
  const out = options.stdout ?? console;
@@ -167,6 +342,9 @@ async function doctor(options) {
167
342
  }
168
343
  }
169
344
  if (cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv]) warnings.push(`${cfg.ai.keyEnv} is not set; provision will skip provider key storage`);
345
+ warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
346
+ warnings.push(...trackedSecretFiles(cfg.rootDir));
347
+ warnings.push(...wranglerWarnings(cfg.rootDir));
170
348
  if (warnings.length) {
171
349
  out.log("");
172
350
  out.log("warnings:");
@@ -177,25 +355,25 @@ async function doctor(options) {
177
355
  }
178
356
 
179
357
  // src/init.ts
180
- var import_node_fs3 = require("fs");
181
- var import_node_path3 = require("path");
358
+ var import_node_fs5 = require("fs");
359
+ var import_node_path5 = require("path");
182
360
 
183
361
  // src/local.ts
184
- var import_node_fs2 = require("fs");
185
- var import_node_path2 = require("path");
362
+ var import_node_fs4 = require("fs");
363
+ var import_node_path4 = require("path");
186
364
  var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
187
365
  function readJsonFile(path) {
188
366
  try {
189
- return JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
367
+ return JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
190
368
  } catch {
191
369
  return null;
192
370
  }
193
371
  }
194
372
  function writePrivateJson(path, value) {
195
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
196
- (0, import_node_fs2.writeFileSync)(path, `${JSON.stringify(value, null, 2)}
373
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(path), { recursive: true });
374
+ (0, import_node_fs4.writeFileSync)(path, `${JSON.stringify(value, null, 2)}
197
375
  `);
198
- (0, import_node_fs2.chmodSync)(path, 384);
376
+ (0, import_node_fs4.chmodSync)(path, 384);
199
377
  }
200
378
  function readCredentials(path) {
201
379
  return readJsonFile(path);
@@ -220,12 +398,12 @@ function mergeCredential(current, update) {
220
398
  return next;
221
399
  }
222
400
  function ensureGitignore(rootDir) {
223
- const path = (0, import_node_path2.resolve)(rootDir, ".gitignore");
224
- const existing = (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : "";
401
+ const path = (0, import_node_path4.resolve)(rootDir, ".gitignore");
402
+ const existing = (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : "";
225
403
  const missing = GITIGNORE_LINES.filter((line) => !existing.split(/\r?\n/).includes(line));
226
404
  if (missing.length === 0) return;
227
405
  const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
228
- (0, import_node_fs2.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
406
+ (0, import_node_fs4.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
229
407
  `);
230
408
  }
231
409
  function writeDevVars(path, credentials, env) {
@@ -239,22 +417,22 @@ function writeDevVars(path, credentials, env) {
239
417
  `ODLA_TENANT="${entry.tenantId}"`,
240
418
  `ODLA_API_KEY="${entry.dbKey}"`
241
419
  ];
242
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
243
- (0, import_node_fs2.writeFileSync)(path, `${lines.join("\n")}
420
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(path), { recursive: true });
421
+ (0, import_node_fs4.writeFileSync)(path, `${lines.join("\n")}
244
422
  `);
245
- (0, import_node_fs2.chmodSync)(path, 384);
423
+ (0, import_node_fs4.chmodSync)(path, 384);
246
424
  }
247
425
  function displayPath(path, rootDir = process.cwd()) {
248
- const rel = (0, import_node_path2.relative)(rootDir, path);
426
+ const rel = (0, import_node_path4.relative)(rootDir, path);
249
427
  return rel && !rel.startsWith("..") ? rel : path;
250
428
  }
251
429
 
252
430
  // src/init.ts
253
431
  function initProject(options) {
254
432
  const out = options.stdout ?? console;
255
- const rootDir = (0, import_node_path3.resolve)(options.rootDir ?? process.cwd());
256
- const configPath = (0, import_node_path3.resolve)(rootDir, options.configPath ?? "odla.config.mjs");
257
- if ((0, import_node_fs3.existsSync)(configPath) && !options.force) {
433
+ const rootDir = (0, import_node_path5.resolve)(options.rootDir ?? process.cwd());
434
+ const configPath = (0, import_node_path5.resolve)(rootDir, options.configPath ?? "odla.config.mjs");
435
+ if ((0, import_node_fs5.existsSync)(configPath) && !options.force) {
258
436
  throw new Error(`${configPath} already exists. Pass --force to overwrite.`);
259
437
  }
260
438
  if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
@@ -263,20 +441,20 @@ function initProject(options) {
263
441
  const envs = options.envs?.length ? options.envs : ["prod", "dev"];
264
442
  const services = options.services?.length ? options.services : ["db", "ai"];
265
443
  const aiProvider = options.aiProvider ?? "anthropic";
266
- (0, import_node_fs3.mkdirSync)((0, import_node_path3.dirname)(configPath), { recursive: true });
267
- (0, import_node_fs3.mkdirSync)((0, import_node_path3.resolve)(rootDir, "src/odla"), { recursive: true });
268
- (0, import_node_fs3.mkdirSync)((0, import_node_path3.resolve)(rootDir, ".odla"), { recursive: true });
269
- (0, import_node_fs3.writeFileSync)(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
270
- writeIfMissing((0, import_node_path3.resolve)(rootDir, "src/odla/schema.mjs"), schemaTemplate());
271
- writeIfMissing((0, import_node_path3.resolve)(rootDir, "src/odla/rules.mjs"), rulesTemplate());
444
+ (0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
445
+ (0, import_node_fs5.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
446
+ (0, import_node_fs5.mkdirSync)((0, import_node_path5.resolve)(rootDir, ".odla"), { recursive: true });
447
+ (0, import_node_fs5.writeFileSync)(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
448
+ writeIfMissing((0, import_node_path5.resolve)(rootDir, "src/odla/schema.mjs"), schemaTemplate());
449
+ writeIfMissing((0, import_node_path5.resolve)(rootDir, "src/odla/rules.mjs"), rulesTemplate());
272
450
  ensureGitignore(rootDir);
273
451
  out.log(`created ${relativeDisplay(configPath, rootDir)}`);
274
452
  out.log("created src/odla/schema.mjs and src/odla/rules.mjs");
275
453
  out.log("updated .gitignore for local odla credentials");
276
454
  }
277
455
  function writeIfMissing(path, text) {
278
- if ((0, import_node_fs3.existsSync)(path)) return;
279
- (0, import_node_fs3.writeFileSync)(path, text);
456
+ if ((0, import_node_fs5.existsSync)(path)) return;
457
+ (0, import_node_fs5.writeFileSync)(path, text);
280
458
  }
281
459
  function configTemplate(input) {
282
460
  return `export default {
@@ -359,7 +537,7 @@ function relativeDisplay(path, rootDir) {
359
537
  // src/provision.ts
360
538
  var import_apps = require("@odla-ai/apps");
361
539
  var import_ai = require("@odla-ai/ai");
362
- var import_node_path4 = require("path");
540
+ var import_node_path6 = require("path");
363
541
  var import_node_process3 = __toESM(require("process"), 1);
364
542
 
365
543
  // src/token.ts
@@ -367,12 +545,12 @@ var import_db = require("@odla-ai/db");
367
545
  var import_node_process2 = __toESM(require("process"), 1);
368
546
 
369
547
  // src/open.ts
370
- var import_node_child_process = require("child_process");
548
+ var import_node_child_process3 = require("child_process");
371
549
  var import_node_process = __toESM(require("process"), 1);
372
550
  async function openUrl(url, options = {}) {
373
551
  const command = openerFor(options.platform ?? import_node_process.default.platform);
374
- const doSpawn = options.spawnImpl ?? import_node_child_process.spawn;
375
- await new Promise((resolve5, reject) => {
552
+ const doSpawn = options.spawnImpl ?? import_node_child_process3.spawn;
553
+ await new Promise((resolve7, reject) => {
376
554
  const child = doSpawn(command.cmd, [...command.args, url], {
377
555
  stdio: "ignore",
378
556
  detached: true
@@ -380,7 +558,7 @@ async function openUrl(url, options = {}) {
380
558
  child.once("error", reject);
381
559
  child.once("spawn", () => {
382
560
  child.unref();
383
- resolve5();
561
+ resolve7();
384
562
  });
385
563
  });
386
564
  }
@@ -607,17 +785,115 @@ function defaultSecretName(provider) {
607
785
  function resolveWriteDevVarsTarget(cfg, requested) {
608
786
  if (!requested) return null;
609
787
  if (requested === true) return cfg.local.devVarsFile;
610
- return (0, import_node_path4.resolve)((0, import_node_path4.dirname)(cfg.configPath), requested);
788
+ return (0, import_node_path6.resolve)((0, import_node_path6.dirname)(cfg.configPath), requested);
611
789
  }
612
790
  async function safeText(res) {
613
791
  try {
614
- return redact((await res.text()).slice(0, 500));
792
+ return redactSecrets((await res.text()).slice(0, 500));
615
793
  } catch {
616
794
  return "";
617
795
  }
618
796
  }
619
- function redact(value) {
620
- return value.replace(/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]").replace(/sk-[A-Za-z0-9._-]+/g, "sk-[redacted]");
797
+
798
+ // src/secrets.ts
799
+ var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
800
+ async function secretsPush(options) {
801
+ const out = options.stdout ?? console;
802
+ const cfg = await loadProjectConfig(options.configPath);
803
+ const env = options.env;
804
+ if (!cfg.envs.includes(env)) {
805
+ throw new Error(`env "${env}" is not in config envs (${cfg.envs.join(", ")})`);
806
+ }
807
+ if (PROD_ENV_NAMES.has(env) && !options.yes) {
808
+ throw new Error(`refusing to push a secret to "${env}" without --yes`);
809
+ }
810
+ const credentialsPath = displayPath(cfg.local.credentialsFile, cfg.rootDir);
811
+ const credentials = readCredentials(cfg.local.credentialsFile);
812
+ if (!credentials) throw new Error(`no credentials at ${credentialsPath} \u2014 run "odla-ai provision" first`);
813
+ if (credentials.appId !== cfg.app.id) {
814
+ throw new Error(`credentials at ${credentialsPath} are for "${credentials.appId}", not "${cfg.app.id}"`);
815
+ }
816
+ const dbKey = credentials.envs[env]?.dbKey;
817
+ if (!dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
818
+ const wranglerConfig = findWranglerConfig(cfg.rootDir);
819
+ if (!wranglerConfig) {
820
+ throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
821
+ }
822
+ const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
823
+ const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
824
+ if (options.dryRun) {
825
+ out.log(`dry run: would push ODLA_API_KEY (${redactSecrets(dbKey)}) to ${target}`);
826
+ return;
827
+ }
828
+ const run = options.runner ?? defaultRunner;
829
+ if (!await wranglerLoggedIn(run, cfg.rootDir)) {
830
+ throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
831
+ }
832
+ const result = await wranglerPutSecret(run, { name: "ODLA_API_KEY", value: dbKey, env: wranglerEnv, cwd: cfg.rootDir });
833
+ if (result.code !== 0) {
834
+ throw new Error(`wrangler secret put failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
835
+ }
836
+ out.log(`ODLA_API_KEY pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
837
+ }
838
+
839
+ // src/skill.ts
840
+ var import_node_fs6 = require("fs");
841
+ var import_node_os = require("os");
842
+ var import_node_path7 = require("path");
843
+ var import_node_url2 = require("url");
844
+ function installSkill(options = {}) {
845
+ const out = options.stdout ?? console;
846
+ const sourceDir = options.sourceDir ?? (0, import_node_url2.fileURLToPath)(new URL("../skills", importMetaUrl));
847
+ const targetDir = options.global ? (0, import_node_path7.join)(options.homeDir ?? (0, import_node_os.homedir)(), ".claude", "skills") : (0, import_node_path7.resolve)(options.dir ?? process.cwd(), ".claude", "skills");
848
+ const files = listFiles(sourceDir);
849
+ if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
850
+ const written = [];
851
+ const unchanged = [];
852
+ const conflicts = [];
853
+ for (const rel of files) {
854
+ const target = (0, import_node_path7.join)(targetDir, rel);
855
+ const source = (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(sourceDir, rel), "utf8");
856
+ if ((0, import_node_fs6.existsSync)(target)) {
857
+ const current = (0, import_node_fs6.readFileSync)(target, "utf8");
858
+ if (current === source) {
859
+ unchanged.push(rel);
860
+ continue;
861
+ }
862
+ if (!options.force) {
863
+ conflicts.push(rel);
864
+ continue;
865
+ }
866
+ }
867
+ written.push(rel);
868
+ }
869
+ if (conflicts.length > 0) {
870
+ throw new Error(
871
+ `skill files modified locally (re-run with --force to overwrite):
872
+ ${conflicts.map((f) => ` - ${(0, import_node_path7.join)(targetDir, f)}`).join("\n")}`
873
+ );
874
+ }
875
+ for (const rel of written) {
876
+ const target = (0, import_node_path7.join)(targetDir, rel);
877
+ (0, import_node_fs6.mkdirSync)((0, import_node_path7.join)(target, ".."), { recursive: true });
878
+ (0, import_node_fs6.writeFileSync)(target, (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(sourceDir, rel), "utf8"));
879
+ }
880
+ const skills = [...new Set(files.map((f) => f.split(/[\\/]/)[0]))].sort();
881
+ out.log(`skills: ${skills.join(", ")}`);
882
+ out.log(`installed ${written.length} file(s) to ${targetDir}${unchanged.length ? ` (${unchanged.length} unchanged)` : ""}`);
883
+ return { targetDir, written, unchanged };
884
+ }
885
+ function listFiles(dir) {
886
+ if (!(0, import_node_fs6.existsSync)(dir)) return [];
887
+ const results = [];
888
+ const walk = (current) => {
889
+ for (const entry of (0, import_node_fs6.readdirSync)(current, { withFileTypes: true })) {
890
+ const path = (0, import_node_path7.join)(current, entry.name);
891
+ if (entry.isDirectory()) walk(path);
892
+ else results.push((0, import_node_path7.relative)(dir, path));
893
+ }
894
+ };
895
+ walk(dir);
896
+ return results.sort();
621
897
  }
622
898
 
623
899
  // src/smoke.ts
@@ -751,6 +1027,27 @@ async function runCli(argv = process.argv.slice(2)) {
751
1027
  });
752
1028
  return;
753
1029
  }
1030
+ if (command === "skill") {
1031
+ const sub = parsed.positionals[1];
1032
+ if (sub !== "install") throw new Error(`unknown skill subcommand "${sub ?? ""}". Try "odla-ai skill install".`);
1033
+ installSkill({
1034
+ dir: stringOpt(parsed.options.dir),
1035
+ global: parsed.options.global === true,
1036
+ force: parsed.options.force === true
1037
+ });
1038
+ return;
1039
+ }
1040
+ if (command === "secrets") {
1041
+ const sub = parsed.positionals[1];
1042
+ if (sub !== "push") throw new Error(`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev".`);
1043
+ await secretsPush({
1044
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
1045
+ env: requiredString(parsed.options.env, "--env"),
1046
+ dryRun: parsed.options["dry-run"] === true,
1047
+ yes: parsed.options.yes === true
1048
+ });
1049
+ return;
1050
+ }
754
1051
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
755
1052
  }
756
1053
  function parseArgv(argv) {
@@ -809,7 +1106,7 @@ function listOpt(value) {
809
1106
  return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
810
1107
  }
811
1108
  function cliVersion() {
812
- const pkg = JSON.parse((0, import_node_fs4.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
1109
+ const pkg = JSON.parse((0, import_node_fs7.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
813
1110
  return pkg.version ?? "unknown";
814
1111
  }
815
1112
  function printHelp() {
@@ -820,6 +1117,8 @@ Usage:
820
1117
  odla-ai doctor [--config odla.config.mjs]
821
1118
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--open|--no-open] [--rotate-keys] [--write-dev-vars[=path]]
822
1119
  odla-ai smoke [--config odla.config.mjs] [--env dev]
1120
+ odla-ai skill install [--dir <project>] [--global] [--force]
1121
+ odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
823
1122
  odla-ai version
824
1123
 
825
1124
  Commands:
@@ -827,6 +1126,8 @@ Commands:
827
1126
  doctor Validate and summarize the project config without network calls.
828
1127
  provision Register the app, enable services, push schema/rules, configure AI/auth.
829
1128
  smoke Verify local credentials, public-config, live schema, and db aggregate.
1129
+ skill Install the bundled Claude Code skills into .claude/skills/.
1130
+ secrets Push the env's db key into the Worker via wrangler, stdin-piped.
830
1131
  version Print the CLI version.
831
1132
 
832
1133
  Safety: