@devstationlabs/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -5
  2. package/devstation.js +751 -120
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,16 +27,33 @@ Either way, check the install:
27
27
  devstation doctor
28
28
  ```
29
29
 
30
- ## A key
30
+ ## Set up a model
31
31
 
32
- The agent needs a model. Set one of these and `doctor` will go green:
32
+ ```sh
33
+ devstation login
34
+ ```
35
+
36
+ Pick a provider, paste a key (it is hidden as you type), choose a model. It is
37
+ stored in `~/.devstation/credentials.json`, readable only by you, so every new
38
+ terminal just works. Anthropic, OpenRouter, OpenAI, and any OpenAI-compatible
39
+ server — Ollama, LM Studio, Groq — are supported:
40
+
41
+ ```sh
42
+ devstation login openai # then give it http://localhost:11434/v1 for Ollama
43
+ devstation config # what it will use, and where each value came from
44
+ ```
45
+
46
+ Exporting `ANTHROPIC_API_KEY` or `OPENROUTER_API_KEY` still works, and is what a
47
+ server or CI job should do.
48
+
49
+ ## Upgrade
33
50
 
34
51
  ```sh
35
- export ANTHROPIC_API_KEY=... # preferred: prompt caching, native tool use
36
- export OPENROUTER_API_KEY=... # also works
52
+ devstation upgrade
37
53
  ```
38
54
 
39
- Put it in your shell profile so it survives a new terminal.
55
+ It upgrades however it was installed: through npm for an npm install, or by
56
+ downloading and verifying the new binary for the standalone one.
40
57
 
41
58
  ## Use it
42
59
 
package/devstation.js CHANGED
@@ -12504,12 +12504,13 @@ var init_sdk = __esm(() => {
12504
12504
 
12505
12505
  // src/lib/agent/cli/index.ts
12506
12506
  import { createInterface as createInterface2 } from "readline/promises";
12507
+ import { Writable } from "stream";
12507
12508
  import { resolve as resolve4 } from "path";
12508
- import { existsSync as existsSync11 } from "fs";
12509
+ import { existsSync as existsSync12 } from "fs";
12509
12510
 
12510
12511
  // src/lib/agent/cli/args.ts
12511
12512
  var CLI_NAME = "devstation";
12512
- var VERSION = "0.1.1";
12513
+ var VERSION = "0.1.3";
12513
12514
  var COMMANDS = new Set([
12514
12515
  "chat",
12515
12516
  "run",
@@ -12525,6 +12526,9 @@ var COMMANDS = new Set([
12525
12526
  "diff",
12526
12527
  "tools",
12527
12528
  "config",
12529
+ "login",
12530
+ "logout",
12531
+ "upgrade",
12528
12532
  "doctor",
12529
12533
  "version",
12530
12534
  "help"
@@ -12537,6 +12541,9 @@ var OFFLINE_COMMANDS = new Set([
12537
12541
  "diff",
12538
12542
  "tools",
12539
12543
  "config",
12544
+ "login",
12545
+ "logout",
12546
+ "upgrade",
12540
12547
  "doctor",
12541
12548
  "version",
12542
12549
  "help",
@@ -12552,6 +12559,8 @@ function parseArgs(argv, cwd = process.cwd()) {
12552
12559
  yes: false,
12553
12560
  json: false,
12554
12561
  sandbox: (process.env.DEVSTATION_SANDBOX ?? "").toLowerCase() !== "off",
12562
+ project: false,
12563
+ check: false,
12555
12564
  root: cwd
12556
12565
  };
12557
12566
  const words = [];
@@ -12576,6 +12585,12 @@ function parseArgs(argv, cwd = process.cwd()) {
12576
12585
  case "--no-sandbox":
12577
12586
  parsed.sandbox = false;
12578
12587
  break;
12588
+ case "--project":
12589
+ parsed.project = true;
12590
+ break;
12591
+ case "--check":
12592
+ parsed.check = true;
12593
+ break;
12579
12594
  case "-h":
12580
12595
  case "--help":
12581
12596
  parsed.command = "help";
@@ -12664,7 +12679,13 @@ var HELP = `DevStation, the coding agent.
12664
12679
  ${CLI_NAME} memory show what it has been told about this project
12665
12680
  ${CLI_NAME} mcp the MCP servers configured here, and their tools
12666
12681
  ${CLI_NAME} tools list the tools it can use, and which ones ask first
12667
- ${CLI_NAME} config show the settings this run would use
12682
+ ${CLI_NAME} login [provider] store an API key and choose a model
12683
+ ${CLI_NAME} logout [provider] remove stored API keys
12684
+ ${CLI_NAME} upgrade [--check] update to the latest version (--check only reports)
12685
+ ${CLI_NAME} config show the settings a run would use, and where each came from
12686
+ ${CLI_NAME} config set <key> <value> [--project]
12687
+ set provider, model or baseUrl
12688
+ ${CLI_NAME} config get|unset <key>, config path
12668
12689
  ${CLI_NAME} doctor check this machine is set up to run it
12669
12690
  ${CLI_NAME} version print the version
12670
12691
  ${CLI_NAME} help this
@@ -12681,12 +12702,15 @@ Options
12681
12702
  -f, --follow keep watching (status only)
12682
12703
  --json JSON from config, sessions, checkpoints and tools
12683
12704
  --no-sandbox run commands on this machine instead of in a container
12705
+ --project with config set/unset: write this workspace's config, not the global one
12706
+ --check with upgrade: say whether a newer version exists, change nothing
12684
12707
  -h, --help this
12685
12708
  -v, --version the version
12686
12709
 
12687
12710
  The repo command also needs GITHUB_TOKEN, with permission to push to that repository.
12688
12711
 
12689
- Set ANTHROPIC_API_KEY, or OPENROUTER_API_KEY, before running.
12712
+ Set up a model with \`${CLI_NAME} login\`, or export ANTHROPIC_API_KEY or OPENROUTER_API_KEY.
12713
+ Settings live in ~/.devstation/config.json, keys in ~/.devstation/credentials.json.
12690
12714
  `;
12691
12715
  var SESSION_HELP = ` /undo rewind the last checkpoint
12692
12716
  /status what this session has done so far
@@ -12704,7 +12728,7 @@ var SESSION_HELP = ` /undo rewind the last checkpoint
12704
12728
  `;
12705
12729
 
12706
12730
  // src/lib/agent/cli/commands.ts
12707
- import { accessSync, constants as constants2, existsSync as existsSync9 } from "fs";
12731
+ import { accessSync, constants as constants2, existsSync as existsSync10 } from "fs";
12708
12732
 
12709
12733
  // src/lib/agent/git.ts
12710
12734
  import { existsSync } from "fs";
@@ -17877,7 +17901,10 @@ class AnthropicProvider {
17877
17901
  model;
17878
17902
  client;
17879
17903
  constructor(opts = {}) {
17880
- this.client = new Anthropic(opts.apiKey ? { apiKey: opts.apiKey } : {});
17904
+ this.client = new Anthropic({
17905
+ ...opts.apiKey ? { apiKey: opts.apiKey } : {},
17906
+ ...opts.baseUrl ? { baseURL: opts.baseUrl } : {}
17907
+ });
17881
17908
  this.model = opts.model || DEFAULT_MODEL;
17882
17909
  }
17883
17910
  async generate(input) {
@@ -17929,7 +17956,7 @@ class AnthropicProvider {
17929
17956
  }
17930
17957
 
17931
17958
  // src/lib/agent/providers/openrouter.ts
17932
- var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
17959
+ var OPENROUTER_BASE = "https://openrouter.ai/api/v1";
17933
17960
  function stopReasonOf2(raw) {
17934
17961
  switch (raw) {
17935
17962
  case "stop":
@@ -17980,20 +18007,24 @@ function parseArguments(raw) {
17980
18007
 
17981
18008
  class OpenRouterProvider {
17982
18009
  apiKey;
17983
- name = "openrouter";
18010
+ name;
17984
18011
  model;
17985
- constructor(apiKey, model) {
18012
+ endpoint;
18013
+ constructor(apiKey, model, opts = {}) {
17986
18014
  this.apiKey = apiKey;
17987
- this.model = model || process.env.AI_MODEL || "anthropic/claude-sonnet-5";
18015
+ this.name = opts.name ?? "openrouter";
18016
+ const base = (opts.baseUrl || OPENROUTER_BASE).replace(/\/+$/, "");
18017
+ this.endpoint = `${base}/chat/completions`;
18018
+ this.model = model || process.env.AI_MODEL || (this.name === "openrouter" ? "anthropic/claude-sonnet-5" : "");
17988
18019
  }
17989
18020
  async generate(input) {
17990
- const res = await fetch(ENDPOINT, {
18021
+ const openRouter = this.name === "openrouter";
18022
+ const res = await fetch(this.endpoint, {
17991
18023
  method: "POST",
17992
18024
  headers: {
17993
18025
  "content-type": "application/json",
17994
- authorization: `Bearer ${this.apiKey}`,
17995
- "HTTP-Referer": "https://devstation.online",
17996
- "X-Title": "DevStation"
18026
+ ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
18027
+ ...openRouter ? { "HTTP-Referer": "https://devstation.online", "X-Title": "DevStation" } : {}
17997
18028
  },
17998
18029
  body: JSON.stringify({
17999
18030
  model: this.model,
@@ -18011,13 +18042,14 @@ class OpenRouterProvider {
18011
18042
  }
18012
18043
  }))
18013
18044
  } : {},
18014
- ...process.env.AI_REASONING === "on" ? {} : { reasoning: { enabled: false } }
18045
+ ...openRouter && process.env.AI_REASONING !== "on" ? { reasoning: { enabled: false } } : {}
18015
18046
  }),
18016
18047
  signal: input.signal
18017
18048
  });
18018
18049
  if (!res.ok || !res.body) {
18019
18050
  const detail = await res.text().catch(() => "");
18020
- throw new Error(`OpenRouter request failed (${res.status}). ${detail.slice(0, 200)}`);
18051
+ const label = openRouter ? "OpenRouter" : `The endpoint ${this.endpoint}`;
18052
+ throw new Error(`${label} request failed (${res.status}). ${detail.slice(0, 200)}`);
18021
18053
  }
18022
18054
  let text = "";
18023
18055
  let finish = null;
@@ -18085,21 +18117,247 @@ class OpenRouterProvider {
18085
18117
  };
18086
18118
  }
18087
18119
  }
18088
- // src/lib/agent/providers/index.ts
18089
- function providerFromEnv(env2 = process.env, model) {
18090
- if (env2.ANTHROPIC_API_KEY)
18091
- return new AnthropicProvider({ apiKey: env2.ANTHROPIC_API_KEY, model });
18092
- const openRouter = env2.OPENROUTER_API_KEY || env2.AI_API_KEY;
18093
- if (openRouter)
18094
- return new OpenRouterProvider(openRouter, model);
18095
- return null;
18120
+ // src/lib/agent/providers/settings.ts
18121
+ import {
18122
+ chmodSync,
18123
+ existsSync as existsSync4,
18124
+ mkdirSync as mkdirSync3,
18125
+ readFileSync as readFileSync3,
18126
+ renameSync,
18127
+ statSync as statSync2,
18128
+ writeFileSync as writeFileSync3
18129
+ } from "fs";
18130
+ import { dirname as dirname4, join as join5 } from "path";
18131
+ var PROVIDER_IDS = ["anthropic", "openrouter", "openai"];
18132
+ var SETTING_KEYS = ["provider", "model", "baseUrl"];
18133
+ var KEY_ENV = {
18134
+ anthropic: ["ANTHROPIC_API_KEY"],
18135
+ openrouter: ["OPENROUTER_API_KEY", "AI_API_KEY"],
18136
+ openai: ["OPENAI_API_KEY"]
18137
+ };
18138
+ var DEFAULT_BASE_URL = {
18139
+ anthropic: "https://api.anthropic.com",
18140
+ openrouter: "https://openrouter.ai/api/v1",
18141
+ openai: "https://api.openai.com/v1"
18142
+ };
18143
+ function globalDir(home) {
18144
+ return join5(home, ".devstation");
18096
18145
  }
18097
- function configuredProviderName(env2 = process.env) {
18098
- if (env2.ANTHROPIC_API_KEY)
18099
- return "anthropic";
18100
- if (env2.OPENROUTER_API_KEY || env2.AI_API_KEY)
18101
- return "openrouter";
18102
- return null;
18146
+ function globalConfigPath(home) {
18147
+ return join5(globalDir(home), "config.json");
18148
+ }
18149
+ function projectConfigPath(root) {
18150
+ return join5(root, ".devstation", "config.json");
18151
+ }
18152
+ function credentialsPath(home) {
18153
+ return join5(globalDir(home), "credentials.json");
18154
+ }
18155
+ function isProvider(value) {
18156
+ return typeof value === "string" && PROVIDER_IDS.includes(value);
18157
+ }
18158
+ function readSettingsFile(path4, problems = []) {
18159
+ if (!existsSync4(path4))
18160
+ return {};
18161
+ try {
18162
+ const raw = JSON.parse(readFileSync3(path4, "utf8"));
18163
+ const out = {};
18164
+ if (raw.provider !== undefined) {
18165
+ const named = typeof raw.provider === "string" ? raw.provider.toLowerCase() : raw.provider;
18166
+ if (isProvider(named))
18167
+ out.provider = named;
18168
+ else
18169
+ problems.push(`${path4}: unknown provider "${String(raw.provider)}".`);
18170
+ }
18171
+ if (typeof raw.model === "string" && raw.model.trim())
18172
+ out.model = raw.model.trim();
18173
+ if (typeof raw.baseUrl === "string" && raw.baseUrl.trim()) {
18174
+ out.baseUrl = raw.baseUrl.trim().replace(/\/+$/, "");
18175
+ }
18176
+ if ("apiKey" in raw) {
18177
+ problems.push(`${path4} contains "apiKey", which is ignored. Keys belong in ${"~/.devstation/credentials.json"}: run \`devstation login\`.`);
18178
+ }
18179
+ return out;
18180
+ } catch {
18181
+ problems.push(`${path4} is not valid JSON, so it was ignored.`);
18182
+ return {};
18183
+ }
18184
+ }
18185
+ function writeJson(path4, value, mode) {
18186
+ mkdirSync3(dirname4(path4), { recursive: true, mode: 448 });
18187
+ const partial = `${path4}.partial`;
18188
+ writeFileSync3(partial, `${JSON.stringify(value, null, 2)}
18189
+ `, { mode });
18190
+ chmodSync(partial, mode);
18191
+ renameSync(partial, path4);
18192
+ }
18193
+ function writeSettingsFile(path4, settings) {
18194
+ const clean = {};
18195
+ for (const key of SETTING_KEYS) {
18196
+ if (settings[key] !== undefined && settings[key] !== "") {
18197
+ clean[key] = settings[key];
18198
+ }
18199
+ }
18200
+ writeJson(path4, clean, 420);
18201
+ }
18202
+ function readCredentials(home, problems = []) {
18203
+ const path4 = credentialsPath(home);
18204
+ if (!existsSync4(path4))
18205
+ return {};
18206
+ try {
18207
+ const mode = statSync2(path4).mode & 511;
18208
+ if (mode & 63) {
18209
+ problems.push(`${path4} is readable by other users (mode ${mode.toString(8)}). Fix it: chmod 600 ${path4}`);
18210
+ }
18211
+ const raw = JSON.parse(readFileSync3(path4, "utf8"));
18212
+ const out = {};
18213
+ for (const id of PROVIDER_IDS) {
18214
+ if (typeof raw[id] === "string" && raw[id].trim())
18215
+ out[id] = raw[id].trim();
18216
+ }
18217
+ return out;
18218
+ } catch {
18219
+ problems.push(`${path4} is not valid JSON, so no stored keys were read.`);
18220
+ return {};
18221
+ }
18222
+ }
18223
+ function writeCredentials(home, credentials) {
18224
+ mkdirSync3(globalDir(home), { recursive: true, mode: 448 });
18225
+ chmodSync(globalDir(home), 448);
18226
+ writeJson(credentialsPath(home), credentials, 384);
18227
+ }
18228
+ function resolveSettings(opts) {
18229
+ const env2 = opts.env ?? process.env;
18230
+ const home = opts.home ?? env2.HOME ?? "";
18231
+ const warnings = [];
18232
+ const project = readSettingsFile(projectConfigPath(opts.root), warnings);
18233
+ const global = home ? readSettingsFile(globalConfigPath(home), warnings) : {};
18234
+ const stored = home ? readCredentials(home, warnings) : {};
18235
+ const projectLabel = ".devstation/config.json";
18236
+ const globalLabel = "~/.devstation/config.json";
18237
+ let provider = null;
18238
+ let providerSource = "not set";
18239
+ if (env2.DEVSTATION_PROVIDER) {
18240
+ const named = env2.DEVSTATION_PROVIDER.toLowerCase();
18241
+ if (isProvider(named)) {
18242
+ provider = named;
18243
+ providerSource = "DEVSTATION_PROVIDER";
18244
+ } else {
18245
+ warnings.push(`DEVSTATION_PROVIDER="${env2.DEVSTATION_PROVIDER}" is not a provider, so it was ignored.`);
18246
+ }
18247
+ }
18248
+ if (!provider && project.provider) {
18249
+ provider = project.provider;
18250
+ providerSource = projectLabel;
18251
+ }
18252
+ if (!provider && global.provider) {
18253
+ provider = global.provider;
18254
+ providerSource = globalLabel;
18255
+ }
18256
+ if (!provider) {
18257
+ for (const id of ["anthropic", "openrouter", "openai"]) {
18258
+ const hit = KEY_ENV[id].find((name) => env2[name]);
18259
+ if (hit) {
18260
+ provider = id;
18261
+ providerSource = `inferred from ${hit}`;
18262
+ break;
18263
+ }
18264
+ }
18265
+ }
18266
+ if (!provider) {
18267
+ const only = PROVIDER_IDS.filter((id) => stored[id]);
18268
+ if (only.length >= 1) {
18269
+ provider = only[0];
18270
+ providerSource = "inferred from ~/.devstation/credentials.json";
18271
+ }
18272
+ }
18273
+ let model = null;
18274
+ let modelSource = "provider default";
18275
+ if (opts.model) {
18276
+ model = opts.model;
18277
+ modelSource = "--model";
18278
+ } else if (env2.DEVSTATION_MODEL) {
18279
+ model = env2.DEVSTATION_MODEL;
18280
+ modelSource = "DEVSTATION_MODEL";
18281
+ } else if (project.model) {
18282
+ model = project.model;
18283
+ modelSource = projectLabel;
18284
+ } else if (global.model) {
18285
+ model = global.model;
18286
+ modelSource = globalLabel;
18287
+ }
18288
+ let baseUrl = null;
18289
+ let baseUrlSource = "provider default";
18290
+ if (env2.DEVSTATION_BASE_URL) {
18291
+ baseUrl = env2.DEVSTATION_BASE_URL.replace(/\/+$/, "");
18292
+ baseUrlSource = "DEVSTATION_BASE_URL";
18293
+ } else if (project.baseUrl) {
18294
+ baseUrl = project.baseUrl;
18295
+ baseUrlSource = projectLabel;
18296
+ } else if (global.baseUrl) {
18297
+ baseUrl = global.baseUrl;
18298
+ baseUrlSource = globalLabel;
18299
+ }
18300
+ let apiKey = null;
18301
+ let keySource = "none";
18302
+ if (provider) {
18303
+ const hit = KEY_ENV[provider].find((name) => env2[name]);
18304
+ if (hit) {
18305
+ apiKey = env2[hit];
18306
+ keySource = hit;
18307
+ } else if (stored[provider]) {
18308
+ apiKey = stored[provider];
18309
+ keySource = "~/.devstation/credentials.json";
18310
+ }
18311
+ }
18312
+ let problem = null;
18313
+ if (!provider) {
18314
+ problem = "No model provider is configured. Run `devstation login`, or set ANTHROPIC_API_KEY or OPENROUTER_API_KEY.";
18315
+ } else if (!apiKey && provider !== "openai") {
18316
+ problem = `No API key for ${provider}. Run \`devstation login ${provider}\`, or set ${KEY_ENV[provider][0]}.`;
18317
+ } else if (provider === "openai" && !apiKey && !baseUrl) {
18318
+ problem = "No API key for openai. Run `devstation login openai`, set OPENAI_API_KEY, or point baseUrl at a local server that needs none.";
18319
+ } else if (provider === "openai" && !model) {
18320
+ problem = "The openai provider needs a model name, because every compatible server names them differently. Run `devstation config set model <name>`.";
18321
+ }
18322
+ return {
18323
+ provider,
18324
+ model,
18325
+ baseUrl,
18326
+ apiKey,
18327
+ source: {
18328
+ provider: providerSource,
18329
+ model: modelSource,
18330
+ baseUrl: baseUrlSource,
18331
+ apiKey: keySource
18332
+ },
18333
+ problem,
18334
+ warnings
18335
+ };
18336
+ }
18337
+ function maskKey(key) {
18338
+ if (!key)
18339
+ return "none";
18340
+ if (key.length <= 8)
18341
+ return "set";
18342
+ return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
18343
+ }
18344
+ // src/lib/agent/providers/index.ts
18345
+ function providerFromSettings(resolved) {
18346
+ if (resolved.problem || !resolved.provider)
18347
+ return null;
18348
+ const model = resolved.model ?? undefined;
18349
+ const baseUrl = resolved.baseUrl ?? undefined;
18350
+ switch (resolved.provider) {
18351
+ case "anthropic":
18352
+ return new AnthropicProvider({ apiKey: resolved.apiKey ?? undefined, model, baseUrl });
18353
+ case "openrouter":
18354
+ return new OpenRouterProvider(resolved.apiKey ?? "", model, { name: "openrouter", baseUrl });
18355
+ case "openai":
18356
+ return new OpenRouterProvider(resolved.apiKey ?? "", model, {
18357
+ name: "openai",
18358
+ baseUrl: baseUrl ?? "https://api.openai.com/v1"
18359
+ });
18360
+ }
18103
18361
  }
18104
18362
 
18105
18363
  // src/lib/agent/memory/embeddings.ts
@@ -18195,11 +18453,11 @@ async function embedMissing(store, provider, options = {}) {
18195
18453
  }
18196
18454
 
18197
18455
  // src/lib/agent/memory/workspace-index.ts
18198
- import { join as join6 } from "path";
18456
+ import { join as join7 } from "path";
18199
18457
 
18200
18458
  // src/lib/agent/repo-session.ts
18201
- import { readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
18202
- import { join as join5, relative as relative2, sep as sep2 } from "path";
18459
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync3 } from "fs";
18460
+ import { join as join6, relative as relative2, sep as sep2 } from "path";
18203
18461
  var SKIP_DIRS = new Set([
18204
18462
  ".git",
18205
18463
  ".agent",
@@ -18233,7 +18491,7 @@ function readWorkspace(root, maxFileBytes = 1024 * 1024) {
18233
18491
  for (const item of readdirSync3(dir, { withFileTypes: true })) {
18234
18492
  if (item.isSymbolicLink())
18235
18493
  continue;
18236
- const full = join5(dir, item.name);
18494
+ const full = join6(dir, item.name);
18237
18495
  if (item.isDirectory()) {
18238
18496
  if (SKIP_DIRS.has(item.name))
18239
18497
  continue;
@@ -18242,9 +18500,9 @@ function readWorkspace(root, maxFileBytes = 1024 * 1024) {
18242
18500
  }
18243
18501
  if (!item.isFile())
18244
18502
  continue;
18245
- if (statSync2(full).size > maxFileBytes)
18503
+ if (statSync3(full).size > maxFileBytes)
18246
18504
  continue;
18247
- const buffer = readFileSync3(full);
18505
+ const buffer = readFileSync4(full);
18248
18506
  if (looksBinary(buffer))
18249
18507
  continue;
18250
18508
  files[relative2(root, full).split(sep2).join("/")] = buffer.toString("utf8");
@@ -18281,8 +18539,8 @@ ${goal}` };
18281
18539
  // src/lib/agent/memory/store.ts
18282
18540
  import { Database } from "bun:sqlite";
18283
18541
  import { createHash } from "crypto";
18284
- import { mkdirSync as mkdirSync3 } from "fs";
18285
- import { dirname as dirname4 } from "path";
18542
+ import { mkdirSync as mkdirSync4 } from "fs";
18543
+ import { dirname as dirname5 } from "path";
18286
18544
 
18287
18545
  // src/lib/agent/memory/chunk.ts
18288
18546
  var BRACE_LANGUAGES = new Set([
@@ -18542,7 +18800,7 @@ class MemoryStore {
18542
18800
  constructor(path4) {
18543
18801
  this.path = path4;
18544
18802
  if (path4 !== ":memory:")
18545
- mkdirSync3(dirname4(path4), { recursive: true });
18803
+ mkdirSync4(dirname5(path4), { recursive: true });
18546
18804
  this.db = new Database(path4);
18547
18805
  this.db.run("PRAGMA journal_mode = WAL");
18548
18806
  this.migrate();
@@ -18715,7 +18973,7 @@ function toChunk(row) {
18715
18973
 
18716
18974
  // src/lib/agent/memory/workspace-index.ts
18717
18975
  function storePath(root) {
18718
- return join6(root, ".agent", "memory.db");
18976
+ return join7(root, ".agent", "memory.db");
18719
18977
  }
18720
18978
  function openStore(root) {
18721
18979
  return new MemoryStore(storePath(root));
@@ -18735,8 +18993,8 @@ async function indexWorkspace(root, options = {}) {
18735
18993
  }
18736
18994
 
18737
18995
  // src/lib/agent/memory/project-memory.ts
18738
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
18739
- import { dirname as dirname5, join as join7 } from "path";
18996
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
18997
+ import { dirname as dirname6, join as join8 } from "path";
18740
18998
  var DEFAULT_FILE = "PROJECT_MEMORY.md";
18741
18999
  var HEADER = `# Project memory
18742
19000
 
@@ -18745,16 +19003,16 @@ things about this project that are not in the code. Safe to edit or delete by
18745
19003
  hand: the agent only ever appends.
18746
19004
  `;
18747
19005
  function memoryPath(root) {
18748
- const atRoot = join7(root, DEFAULT_FILE);
18749
- if (existsSync4(atRoot))
19006
+ const atRoot = join8(root, DEFAULT_FILE);
19007
+ if (existsSync5(atRoot))
18750
19008
  return atRoot;
18751
- return join7(root, ".agent", DEFAULT_FILE);
19009
+ return join8(root, ".agent", DEFAULT_FILE);
18752
19010
  }
18753
19011
  function readMemory(root) {
18754
19012
  const path4 = memoryPath(root);
18755
- if (!existsSync4(path4))
19013
+ if (!existsSync5(path4))
18756
19014
  return [];
18757
- return parseMemory(readFileSync4(path4, "utf8"));
19015
+ return parseMemory(readFileSync5(path4, "utf8"));
18758
19016
  }
18759
19017
  var ENTRY = /^- \[([^\]]+)\](?:\s*\(([^)]*)\))?\s+([\s\S]*)$/;
18760
19018
  function parseMemory(text) {
@@ -18778,16 +19036,16 @@ function remember(root, note, tag = null, now = new Date) {
18778
19036
  if (!text) {
18779
19037
  return { ok: false, path: path4, message: "There was nothing to remember." };
18780
19038
  }
18781
- const existing = existsSync4(path4) ? readFileSync4(path4, "utf8") : "";
19039
+ const existing = existsSync5(path4) ? readFileSync5(path4, "utf8") : "";
18782
19040
  const entries = parseMemory(existing);
18783
19041
  const normal = (value) => value.toLowerCase().replace(/\s+/g, " ").trim();
18784
19042
  if (entries.some((entry) => normal(entry.note) === normal(text))) {
18785
19043
  return { ok: true, path: path4, duplicate: true, message: "Already remembered; nothing was added." };
18786
19044
  }
18787
19045
  const line = formatEntry({ note: text, tag, at: now.toISOString() });
18788
- mkdirSync4(dirname5(path4), { recursive: true });
19046
+ mkdirSync5(dirname6(path4), { recursive: true });
18789
19047
  const body = existing || HEADER;
18790
- writeFileSync3(path4, `${body.replace(/\n+$/, "")}
19048
+ writeFileSync4(path4, `${body.replace(/\n+$/, "")}
18791
19049
  ${line}
18792
19050
  `);
18793
19051
  return { ok: true, path: path4, message: `Remembered, in ${DEFAULT_FILE}.` };
@@ -18814,22 +19072,22 @@ function renderMemory(entries) {
18814
19072
 
18815
19073
  // src/lib/agent/mcp.ts
18816
19074
  import { spawn as spawn3 } from "child_process";
18817
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
18818
- import { join as join8 } from "path";
19075
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
19076
+ import { join as join9 } from "path";
18819
19077
  function configPaths(root, home = process.env.HOME ?? "") {
18820
19078
  return [
18821
- join8(root, ".devstation", "mcp.json"),
18822
- join8(root, ".mcp.json"),
18823
- ...home ? [join8(home, ".devstation", "mcp.json")] : []
19079
+ join9(root, ".devstation", "mcp.json"),
19080
+ join9(root, ".mcp.json"),
19081
+ ...home ? [join9(home, ".devstation", "mcp.json")] : []
18824
19082
  ];
18825
19083
  }
18826
19084
  function loadConfig(root, home = process.env.HOME ?? "") {
18827
19085
  const merged = { mcpServers: {} };
18828
19086
  for (const path4 of configPaths(root, home).reverse()) {
18829
- if (!existsSync5(path4))
19087
+ if (!existsSync6(path4))
18830
19088
  continue;
18831
19089
  try {
18832
- const parsed = JSON.parse(readFileSync5(path4, "utf8"));
19090
+ const parsed = JSON.parse(readFileSync6(path4, "utf8"));
18833
19091
  for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
18834
19092
  if (server && typeof server.command === "string")
18835
19093
  merged.mcpServers[name] = server;
@@ -19089,12 +19347,12 @@ function failedResult(message) {
19089
19347
 
19090
19348
  // src/lib/agent/sandbox-exec.ts
19091
19349
  import { randomBytes } from "crypto";
19092
- import { existsSync as existsSync7, statSync as statSync4, unlinkSync } from "fs";
19093
- import { isAbsolute as isAbsolute3, join as join10, relative as relative4, resolve as resolve3, sep as sep4 } from "path";
19350
+ import { existsSync as existsSync8, statSync as statSync5, unlinkSync } from "fs";
19351
+ import { isAbsolute as isAbsolute3, join as join11, relative as relative4, resolve as resolve3, sep as sep4 } from "path";
19094
19352
 
19095
19353
  // src/lib/agent/project.ts
19096
- import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
19097
- import { dirname as dirname6, join as join9, relative as relative3, sep as sep3 } from "path";
19354
+ import { existsSync as existsSync7, readFileSync as readFileSync7, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
19355
+ import { dirname as dirname7, join as join10, relative as relative3, sep as sep3 } from "path";
19098
19356
  var SKIP = new Set([
19099
19357
  "node_modules",
19100
19358
  ".git",
@@ -19120,7 +19378,7 @@ var MANIFESTS = [
19120
19378
  var MAX_DEPTH = 2;
19121
19379
  function readScripts(absolute) {
19122
19380
  try {
19123
- const parsed = JSON.parse(readFileSync6(absolute, "utf8"));
19381
+ const parsed = JSON.parse(readFileSync7(absolute, "utf8"));
19124
19382
  return parsed.scripts ?? {};
19125
19383
  } catch {
19126
19384
  return {};
@@ -19130,8 +19388,8 @@ function detectManifests(root) {
19130
19388
  const found = [];
19131
19389
  const scan = (dir, depth) => {
19132
19390
  for (const { file, ecosystem } of MANIFESTS) {
19133
- const absolute = join9(dir, file);
19134
- if (!existsSync6(absolute))
19391
+ const absolute = join10(dir, file);
19392
+ if (!existsSync7(absolute))
19135
19393
  continue;
19136
19394
  found.push({
19137
19395
  dir: relative3(root, dir).split(sep3).join("/"),
@@ -19151,9 +19409,9 @@ function detectManifests(root) {
19151
19409
  for (const entry of entries) {
19152
19410
  if (SKIP.has(entry) || entry.startsWith("."))
19153
19411
  continue;
19154
- const child = join9(dir, entry);
19412
+ const child = join10(dir, entry);
19155
19413
  try {
19156
- if (statSync3(child).isDirectory())
19414
+ if (statSync4(child).isDirectory())
19157
19415
  scan(child, depth + 1);
19158
19416
  } catch {}
19159
19417
  }
@@ -19240,7 +19498,7 @@ function lockfilesFor(manifest) {
19240
19498
  }
19241
19499
  }
19242
19500
  function cwdFor(root, manifest) {
19243
- return manifest.dir ? join9(root, manifest.dir) : root;
19501
+ return manifest.dir ? join10(root, manifest.dir) : root;
19244
19502
  }
19245
19503
 
19246
19504
  // src/lib/agent/sandbox-exec.ts
@@ -19294,7 +19552,7 @@ var NEEDS = {
19294
19552
  go: ["go"]
19295
19553
  };
19296
19554
  async function probeImage(workspace, image, runtime) {
19297
- const ecosystems = [...new Set(detectManifests(workspace).map((m) => m.ecosystem))];
19555
+ const ecosystems = gatingEcosystems(detectManifests(workspace));
19298
19556
  const wanted = [...new Set(ecosystems.flatMap((e) => NEEDS[e]))];
19299
19557
  if (wanted.length === 0)
19300
19558
  return { ok: true, missing: [], ecosystems };
@@ -19306,10 +19564,13 @@ async function probeImage(workspace, image, runtime) {
19306
19564
  `).map((l) => l.trim()).filter(Boolean);
19307
19565
  return { ok: missing.length === 0, missing, ecosystems };
19308
19566
  }
19567
+ function gatingEcosystems(manifests) {
19568
+ return [...new Set(manifests.filter((m) => m.dir === "").map((m) => m.ecosystem))];
19569
+ }
19309
19570
  function probeProblem(probe, image) {
19310
19571
  if (probe.ok)
19311
19572
  return null;
19312
- return `This is a ${probe.ecosystems.join(" and ")} project, and the sandbox image ${image} has no ` + `${probe.missing.join(", ")}. Build an image that does and set DEVSTATION_SANDBOX_IMAGE, ` + "or run with --no-sandbox.";
19573
+ return `The sandbox image ${image} has no ${probe.missing.join(", ")}, which this ${probe.ecosystems.join(" and ")} ` + "project uses, so commands that need it will fail inside the sandbox. Build an image that has it " + "and set DEVSTATION_SANDBOX_IMAGE, or run with --no-sandbox.";
19313
19574
  }
19314
19575
  function quote2(value) {
19315
19576
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -19415,16 +19676,16 @@ function sandboxExecutor(options) {
19415
19676
  };
19416
19677
  const verifyOwnership = async (name) => {
19417
19678
  const probe = `.devstation-uid-probe-${session}`;
19418
- const path4 = join10(options.workspace, probe);
19679
+ const path4 = join11(options.workspace, probe);
19419
19680
  const written = await runShell(execCommand(name, `touch ${quote2(probe)}`, 20), {
19420
19681
  cwd: options.workspace,
19421
19682
  timeoutMs: 30000
19422
19683
  });
19423
- if (!written.ok || !existsSync7(path4)) {
19684
+ if (!written.ok || !existsSync8(path4)) {
19424
19685
  return "The sandbox could not write to the workspace. Check the mount and try --no-sandbox.";
19425
19686
  }
19426
19687
  try {
19427
- const stat2 = statSync4(path4);
19688
+ const stat2 = statSync5(path4);
19428
19689
  const [uid, gid] = user.split(":").map(Number);
19429
19690
  if (stat2.uid !== uid || stat2.gid !== gid) {
19430
19691
  return `The sandbox writes files as ${stat2.uid}:${stat2.gid} but this account is ${user}. ` + "Files it creates would not be yours to edit, and git would stop trusting the " + "repository, so it was not started. Run with --no-sandbox, or set " + "DEVSTATION_SANDBOX_USER.";
@@ -20475,37 +20736,37 @@ function summarise(call, outcome) {
20475
20736
  // src/lib/agent/session-store.ts
20476
20737
  import {
20477
20738
  appendFileSync,
20478
- existsSync as existsSync8,
20479
- mkdirSync as mkdirSync5,
20480
- readFileSync as readFileSync7,
20739
+ existsSync as existsSync9,
20740
+ mkdirSync as mkdirSync6,
20741
+ readFileSync as readFileSync8,
20481
20742
  readdirSync as readdirSync6,
20482
- renameSync,
20483
- statSync as statSync5,
20484
- writeFileSync as writeFileSync4
20743
+ renameSync as renameSync2,
20744
+ statSync as statSync6,
20745
+ writeFileSync as writeFileSync5
20485
20746
  } from "fs";
20486
- import { join as join11 } from "path";
20747
+ import { join as join12 } from "path";
20487
20748
  import { randomUUID as randomUUID2 } from "crypto";
20488
- var SESSIONS_DIR = join11(".agent", "sessions");
20749
+ var SESSIONS_DIR = join12(".agent", "sessions");
20489
20750
 
20490
20751
  class SessionStore {
20491
20752
  root;
20492
20753
  dir;
20493
20754
  constructor(root) {
20494
20755
  this.root = root;
20495
- this.dir = join11(root, SESSIONS_DIR);
20756
+ this.dir = join12(root, SESSIONS_DIR);
20496
20757
  }
20497
20758
  ensure() {
20498
- mkdirSync5(this.dir, { recursive: true });
20499
- const ignore = join11(this.dir, "..", ".gitignore");
20500
- if (!existsSync8(ignore))
20501
- writeFileSync4(ignore, `*
20759
+ mkdirSync6(this.dir, { recursive: true });
20760
+ const ignore = join12(this.dir, "..", ".gitignore");
20761
+ if (!existsSync9(ignore))
20762
+ writeFileSync5(ignore, `*
20502
20763
  `);
20503
20764
  }
20504
20765
  jsonPath(id) {
20505
- return join11(this.dir, `${id}.json`);
20766
+ return join12(this.dir, `${id}.json`);
20506
20767
  }
20507
20768
  eventPath(id) {
20508
- return join11(this.dir, `${id}.jsonl`);
20769
+ return join12(this.dir, `${id}.jsonl`);
20509
20770
  }
20510
20771
  create(goal, meta) {
20511
20772
  this.ensure();
@@ -20535,21 +20796,21 @@ class SessionStore {
20535
20796
  record.updatedAt = new Date().toISOString();
20536
20797
  const target = this.jsonPath(record.id);
20537
20798
  const temporary = `${target}.tmp`;
20538
- writeFileSync4(temporary, JSON.stringify(record, null, 2));
20539
- renameSync(temporary, target);
20799
+ writeFileSync5(temporary, JSON.stringify(record, null, 2));
20800
+ renameSync2(temporary, target);
20540
20801
  }
20541
20802
  load(id) {
20542
20803
  const path4 = this.jsonPath(id);
20543
- if (!existsSync8(path4))
20804
+ if (!existsSync9(path4))
20544
20805
  return null;
20545
20806
  try {
20546
- return JSON.parse(readFileSync7(path4, "utf8"));
20807
+ return JSON.parse(readFileSync8(path4, "utf8"));
20547
20808
  } catch {
20548
20809
  return null;
20549
20810
  }
20550
20811
  }
20551
20812
  list() {
20552
- if (!existsSync8(this.dir))
20813
+ if (!existsSync9(this.dir))
20553
20814
  return [];
20554
20815
  return readdirSync6(this.dir).filter((name) => name.endsWith(".json")).map((name) => this.load(name.slice(0, -".json".length))).filter((record) => record !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
20555
20816
  }
@@ -20563,12 +20824,12 @@ class SessionStore {
20563
20824
  }
20564
20825
  readEvents(id, fromByte = 0) {
20565
20826
  const path4 = this.eventPath(id);
20566
- if (!existsSync8(path4))
20827
+ if (!existsSync9(path4))
20567
20828
  return { events: [], offset: 0 };
20568
- const size = statSync5(path4).size;
20829
+ const size = statSync6(path4).size;
20569
20830
  if (size <= fromByte)
20570
20831
  return { events: [], offset: size };
20571
- const text = readFileSync7(path4, "utf8").slice(fromByte);
20832
+ const text = readFileSync8(path4, "utf8").slice(fromByte);
20572
20833
  const events = [];
20573
20834
  let consumed = 0;
20574
20835
  for (const line of text.split(`
@@ -20663,7 +20924,8 @@ function renderSessions(records) {
20663
20924
  }
20664
20925
 
20665
20926
  // src/lib/agent/cli/commands.ts
20666
- async function buildExecutor(root, sandbox) {
20927
+ async function buildExecutor(root, sandbox, warn = (message) => process.stderr.write(`warning: ${message}
20928
+ `)) {
20667
20929
  if (!sandbox)
20668
20930
  return { executor: hostExecutor() };
20669
20931
  const readiness = await sandboxReadiness();
@@ -20673,7 +20935,7 @@ async function buildExecutor(root, sandbox) {
20673
20935
  const probe = await probeImage(root, readiness.imageName, readiness.runtimeName);
20674
20936
  const mismatch = probeProblem(probe, readiness.imageName);
20675
20937
  if (mismatch)
20676
- return { problem: mismatch };
20938
+ warn(mismatch);
20677
20939
  return { executor: sandboxExecutor({ workspace: root }) };
20678
20940
  }
20679
20941
  function isYes(answer) {
@@ -20962,7 +21224,17 @@ function configCommand(context) {
20962
21224
  context.terminal.out(JSON.stringify({
20963
21225
  workspace: context.root,
20964
21226
  git: isRepo(context.root),
20965
- provider: context.provider ? { name: context.provider.name, model: context.provider.model } : configuredProviderName() ?? null,
21227
+ provider: context.provider ? { name: context.provider.name, model: context.provider.model } : (() => {
21228
+ const r2 = resolveSettings({ root: context.root });
21229
+ return {
21230
+ name: r2.provider,
21231
+ model: r2.model,
21232
+ baseUrl: r2.baseUrl,
21233
+ key: maskKey(r2.apiKey),
21234
+ source: r2.source,
21235
+ problem: r2.problem
21236
+ };
21237
+ })(),
20966
21238
  autonomy: context.autonomy ?? "ask_sensitive",
20967
21239
  maxSteps: context.maxSteps ?? 40,
20968
21240
  budgetUsd: context.maxCostUsd ?? null,
@@ -20971,10 +21243,14 @@ function configCommand(context) {
20971
21243
  }, null, 2));
20972
21244
  return 0;
20973
21245
  }
21246
+ const r = resolveSettings({ root: context.root });
20974
21247
  const lines = [
20975
21248
  `workspace ${context.root}`,
20976
21249
  `git ${isRepo(context.root) ? "yes" : "no, so checkpoints are file snapshots under .devstation/"}`,
20977
- `provider ${context.provider ? `${context.provider.name}/${context.provider.model}` : configuredProviderName() ?? "none configured, set ANTHROPIC_API_KEY or OPENROUTER_API_KEY"}`,
21250
+ context.provider ? `provider ${context.provider.name}/${context.provider.model} (this session)` : `provider ${r.provider ?? "none"} (${r.source.provider})`,
21251
+ `model ${context.provider ? context.provider.model : r.model ?? "provider default"} (${context.provider ? "this session" : r.source.model})`,
21252
+ `endpoint ${r.baseUrl ?? (r.provider ? DEFAULT_BASE_URL[r.provider] : "none")} (${r.source.baseUrl})`,
21253
+ `api key ${maskKey(r.apiKey)} (${r.source.apiKey})`,
20978
21254
  `autonomy ${context.autonomy ?? "ask_sensitive"}`,
20979
21255
  `max steps ${context.maxSteps ?? 40}`,
20980
21256
  `budget ${context.maxCostUsd ? `$${context.maxCostUsd}` : "none set"}`,
@@ -20982,16 +21258,171 @@ function configCommand(context) {
20982
21258
  ];
20983
21259
  for (const line of lines)
20984
21260
  context.terminal.out(line);
21261
+ if (r.problem && !context.provider)
21262
+ context.terminal.err(`
21263
+ ${r.problem}`);
21264
+ for (const warning of r.warnings)
21265
+ context.terminal.err(`warning: ${warning}`);
21266
+ return 0;
21267
+ }
21268
+ function home() {
21269
+ return process.env.HOME ?? "";
21270
+ }
21271
+ function homeDirectoryWarning(root, homeDir = process.env.HOME ?? "") {
21272
+ if (!homeDir)
21273
+ return null;
21274
+ const strip2 = (p) => p.replace(/\/+$/, "");
21275
+ if (strip2(root) !== strip2(homeDir))
21276
+ return null;
21277
+ return "You are in your home directory, so the agent treats everything under it as one project, " + "every repository inside it included. cd into the project you mean first.";
21278
+ }
21279
+ function configEditCommand(context, rest, opts = {}) {
21280
+ const [action, key, ...valueWords] = rest.trim().split(/\s+/);
21281
+ const value = valueWords.join(" ").trim();
21282
+ const path4 = opts.project ? projectConfigPath(context.root) : globalConfigPath(home());
21283
+ if (action === "path") {
21284
+ context.terminal.out(`global ${globalConfigPath(home())}`);
21285
+ context.terminal.out(`project ${projectConfigPath(context.root)}`);
21286
+ context.terminal.out(`credentials ${credentialsPath(home())}`);
21287
+ return 0;
21288
+ }
21289
+ if (key === "apiKey" || key === "key") {
21290
+ context.terminal.err(`API keys are not stored in config. Run \`devstation login\` to store one in ${credentialsPath(home())}.`);
21291
+ return 2;
21292
+ }
21293
+ if (!key || !SETTING_KEYS.includes(key)) {
21294
+ context.terminal.err(`Unknown setting "${key ?? ""}". Settings: ${SETTING_KEYS.join(", ")}.`);
21295
+ return 2;
21296
+ }
21297
+ const name = key;
21298
+ const current = readSettingsFile(path4);
21299
+ if (action === "get") {
21300
+ context.terminal.out(current[name] ?? "");
21301
+ return 0;
21302
+ }
21303
+ if (action === "unset") {
21304
+ delete current[name];
21305
+ writeSettingsFile(path4, current);
21306
+ context.terminal.out(`Removed ${name} from ${path4}.`);
21307
+ return 0;
21308
+ }
21309
+ if (action === "set") {
21310
+ if (!value) {
21311
+ context.terminal.err(`Give it a value: devstation config set ${name} <value>`);
21312
+ return 2;
21313
+ }
21314
+ if (name === "provider" && !PROVIDER_IDS.includes(value.toLowerCase())) {
21315
+ context.terminal.err(`Unknown provider "${value}". Providers: ${PROVIDER_IDS.join(", ")}.`);
21316
+ return 2;
21317
+ }
21318
+ if (name === "baseUrl" && !/^https?:\/\//.test(value)) {
21319
+ context.terminal.err("baseUrl must start with http:// or https://.");
21320
+ return 2;
21321
+ }
21322
+ current[name] = name === "baseUrl" ? value.replace(/\/+$/, "") : name === "provider" ? value.toLowerCase() : value;
21323
+ writeSettingsFile(path4, current);
21324
+ context.terminal.out(`Set ${name} = ${current[name]} in ${path4}.`);
21325
+ return 0;
21326
+ }
21327
+ context.terminal.err("Use: devstation config set <key> <value> | get <key> | unset <key> | path (add --project for this workspace)");
21328
+ return 2;
21329
+ }
21330
+ async function loginCommand(context, rest) {
21331
+ const t = context.terminal;
21332
+ const secret = t.askSecret ? (q) => t.askSecret(q) : (q) => t.ask(q);
21333
+ let provider = (rest.trim().split(/\s+/)[0] ?? "").toLowerCase();
21334
+ if (!provider) {
21335
+ t.out("Which provider?");
21336
+ t.out(" anthropic Claude, directly (prompt caching, native tool use)");
21337
+ t.out(" openrouter one key for Claude, GPT, Gemini, DeepSeek and more");
21338
+ t.out(" openai OpenAI, or any compatible server: Ollama, LM Studio, Groq, Together");
21339
+ provider = (await t.ask("provider [anthropic]: ")).trim().toLowerCase() || "anthropic";
21340
+ }
21341
+ if (!PROVIDER_IDS.includes(provider)) {
21342
+ t.err(`Unknown provider "${provider}". Providers: ${PROVIDER_IDS.join(", ")}.`);
21343
+ return 2;
21344
+ }
21345
+ const id = provider;
21346
+ let baseUrl = "";
21347
+ if (id === "openai") {
21348
+ baseUrl = (await t.ask(`endpoint [${DEFAULT_BASE_URL.openai}]: `)).trim();
21349
+ if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
21350
+ t.err("The endpoint must start with http:// or https://.");
21351
+ return 2;
21352
+ }
21353
+ }
21354
+ const key = (await secret(id === "openai" ? "API key (blank for a local server): " : "API key: ")).trim();
21355
+ if (!key && id !== "openai") {
21356
+ t.err("No key entered, so nothing was saved.");
21357
+ return 2;
21358
+ }
21359
+ const modelHint = id === "anthropic" ? "claude-sonnet-5" : id === "openrouter" ? "anthropic/claude-sonnet-5" : "required";
21360
+ const model = (await t.ask(`model [${modelHint}]: `)).trim();
21361
+ if (id === "openai" && !model) {
21362
+ t.err("The openai provider needs a model name, because every compatible server names them differently.");
21363
+ return 2;
21364
+ }
21365
+ const h = home();
21366
+ if (!h) {
21367
+ t.err("HOME is not set, so there is nowhere to store settings.");
21368
+ return 2;
21369
+ }
21370
+ if (key) {
21371
+ const credentials = readCredentials(h);
21372
+ credentials[id] = key;
21373
+ writeCredentials(h, credentials);
21374
+ }
21375
+ const settings2 = readSettingsFile(globalConfigPath(h));
21376
+ settings2.provider = id;
21377
+ if (model)
21378
+ settings2.model = model;
21379
+ else
21380
+ delete settings2.model;
21381
+ if (baseUrl)
21382
+ settings2.baseUrl = baseUrl.replace(/\/+$/, "");
21383
+ else if (id !== "openai")
21384
+ delete settings2.baseUrl;
21385
+ writeSettingsFile(globalConfigPath(h), settings2);
21386
+ t.out("");
21387
+ t.out(`Saved. provider ${id}, model ${model || "provider default"}${baseUrl ? `, endpoint ${baseUrl}` : ""}`);
21388
+ if (key)
21389
+ t.out(`key ${maskKey(key)} in ${credentialsPath(h)} (readable only by you)`);
21390
+ t.out(`settings in ${globalConfigPath(h)}`);
21391
+ t.out("");
21392
+ t.out("Check it with: devstation doctor");
21393
+ return 0;
21394
+ }
21395
+ function logoutCommand(context, rest) {
21396
+ const h = home();
21397
+ const target = (rest.trim().split(/\s+/)[0] ?? "").toLowerCase();
21398
+ if (target && !PROVIDER_IDS.includes(target)) {
21399
+ context.terminal.err(`Unknown provider "${target}". Providers: ${PROVIDER_IDS.join(", ")}.`);
21400
+ return 2;
21401
+ }
21402
+ const credentials = readCredentials(h);
21403
+ const removed = target ? credentials[target] ? [target] : [] : Object.keys(credentials);
21404
+ if (removed.length === 0) {
21405
+ context.terminal.out(target ? `No stored key for ${target}.` : "No stored keys.");
21406
+ return 0;
21407
+ }
21408
+ if (target)
21409
+ delete credentials[target];
21410
+ writeCredentials(h, target ? credentials : {});
21411
+ context.terminal.out(`Removed the stored key for ${removed.join(", ")}.`);
21412
+ context.terminal.out("Keys exported in your shell (ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY) are not touched.");
20985
21413
  return 0;
20986
21414
  }
20987
21415
  async function runChecks(root, env2 = process.env) {
20988
21416
  const checks = [];
20989
- const provider = configuredProviderName(env2);
21417
+ const settings2 = resolveSettings({ root, env: env2 });
20990
21418
  checks.push({
20991
21419
  name: "model provider",
20992
- ok: provider !== null,
20993
- detail: provider ?? "set ANTHROPIC_API_KEY, or OPENROUTER_API_KEY"
21420
+ ok: settings2.problem === null,
21421
+ detail: settings2.problem ?? `${settings2.provider}${settings2.model ? `/${settings2.model}` : ""}, key from ${settings2.source.apiKey}`
20994
21422
  });
21423
+ for (const warning of settings2.warnings) {
21424
+ checks.push({ name: "settings", ok: false, detail: warning });
21425
+ }
20995
21426
  const git2 = await runShell("git --version", { cwd: root, timeoutMs: 15000 });
20996
21427
  checks.push({
20997
21428
  name: "git",
@@ -21000,7 +21431,7 @@ async function runChecks(root, env2 = process.env) {
21000
21431
  });
21001
21432
  checks.push({
21002
21433
  name: "workspace",
21003
- ok: existsSync9(root),
21434
+ ok: existsSync10(root),
21004
21435
  detail: isRepo(root) ? `${root} (a git repository)` : `${root} (not a git repository)`
21005
21436
  });
21006
21437
  let writable = false;
@@ -21133,11 +21564,11 @@ function wordmark(ink = "\u2588") {
21133
21564
  return rows;
21134
21565
  }
21135
21566
  var WORDMARK_WIDTH = wordmark()[0].length;
21136
- function shortPath(path4, home = process.env.HOME ?? "") {
21137
- if (home && path4 === home)
21567
+ function shortPath(path4, home2 = process.env.HOME ?? "") {
21568
+ if (home2 && path4 === home2)
21138
21569
  return "~";
21139
- if (home && path4.startsWith(`${home}/`))
21140
- return `~${path4.slice(home.length)}`;
21570
+ if (home2 && path4.startsWith(`${home2}/`))
21571
+ return `~${path4.slice(home2.length)}`;
21141
21572
  return path4;
21142
21573
  }
21143
21574
  function banner(facts, options = {}) {
@@ -21301,8 +21732,8 @@ async function chatCommand(context, opening = "") {
21301
21732
  }
21302
21733
 
21303
21734
  // src/lib/agent/cli/repo-command.ts
21304
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5, existsSync as existsSync10 } from "fs";
21305
- import { join as join12 } from "path";
21735
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6, existsSync as existsSync11 } from "fs";
21736
+ import { join as join13 } from "path";
21306
21737
 
21307
21738
  // src/lib/github-repos.ts
21308
21739
  var API = "https://api.github.com";
@@ -21546,11 +21977,11 @@ function filesFromArchive(entries, limits = {}) {
21546
21977
  // src/lib/agent/cli/repo-command.ts
21547
21978
  function workspaceFor(root, owner, name, now) {
21548
21979
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-");
21549
- const dir = join12(root, ".devstation", "repos", `${owner}-${name}-${stamp}`);
21550
- mkdirSync6(dir, { recursive: true });
21551
- const ignore = join12(root, ".devstation", ".gitignore");
21552
- if (!existsSync10(ignore))
21553
- writeFileSync5(ignore, `*
21980
+ const dir = join13(root, ".devstation", "repos", `${owner}-${name}-${stamp}`);
21981
+ mkdirSync7(dir, { recursive: true });
21982
+ const ignore = join13(root, ".devstation", ".gitignore");
21983
+ if (!existsSync11(ignore))
21984
+ writeFileSync6(ignore, `*
21554
21985
  `);
21555
21986
  return dir;
21556
21987
  }
@@ -21726,6 +22157,169 @@ function lineReader(rl, write) {
21726
22157
  };
21727
22158
  }
21728
22159
 
22160
+ // src/lib/agent/cli/upgrade.ts
22161
+ import { spawnSync } from "child_process";
22162
+ import { createHash as createHash2 } from "crypto";
22163
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync8, readFileSync as readFileSync9, renameSync as renameSync3, writeFileSync as writeFileSync7 } from "fs";
22164
+ import { dirname as dirname8, join as join14 } from "path";
22165
+ var PACKAGE = "@devstationlabs/cli";
22166
+ var REPO = "linoxbt/dev-shipyard";
22167
+ var DAY_MS = 24 * 60 * 60 * 1000;
22168
+ function installMethod(execPath = process.execPath, script = process.argv[1] ?? "") {
22169
+ const slash = (p) => p.replace(/\\/g, "/");
22170
+ const marker = `node_modules/${PACKAGE}/`;
22171
+ if (slash(execPath).includes(marker) || slash(script).includes(marker))
22172
+ return "npm";
22173
+ if (/\.(ts|tsx)$/.test(script) && /(^|\/)bun(\.exe)?$/.test(slash(execPath)))
22174
+ return "source";
22175
+ return "binary";
22176
+ }
22177
+ function compareVersions(a, b) {
22178
+ const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
22179
+ const [x, y] = [parts(a), parts(b)];
22180
+ for (let i = 0;i < Math.max(x.length, y.length); i++) {
22181
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
22182
+ if (d !== 0)
22183
+ return d > 0 ? 1 : -1;
22184
+ }
22185
+ return 0;
22186
+ }
22187
+ async function latestVersion(fetchImpl = fetch, timeoutMs = 4000) {
22188
+ try {
22189
+ const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}/latest`, {
22190
+ headers: { accept: "application/json" },
22191
+ signal: AbortSignal.timeout(timeoutMs)
22192
+ });
22193
+ if (!res.ok)
22194
+ return null;
22195
+ const body = await res.json();
22196
+ return typeof body.version === "string" ? body.version : null;
22197
+ } catch {
22198
+ return null;
22199
+ }
22200
+ }
22201
+ function targetFor(platform = process.platform, arch = process.arch) {
22202
+ const map = {
22203
+ "linux-x64": "devstation-linux-x64",
22204
+ "linux-arm64": "devstation-linux-arm64",
22205
+ "darwin-arm64": "devstation-darwin-arm64",
22206
+ "darwin-x64": "devstation-darwin-x64",
22207
+ "win32-x64": "devstation-windows-x64.exe"
22208
+ };
22209
+ return map[`${platform}-${arch}`] ?? null;
22210
+ }
22211
+ function runInherit(command, args) {
22212
+ const result = spawnSync(command, args, { stdio: "inherit" });
22213
+ if (result.error)
22214
+ return 127;
22215
+ return result.status ?? 1;
22216
+ }
22217
+ async function upgradeCommand(context, opts = {}) {
22218
+ const t = context.terminal;
22219
+ const fetchImpl = opts.fetchImpl ?? fetch;
22220
+ const latest = await latestVersion(fetchImpl);
22221
+ if (!latest) {
22222
+ t.err("Could not reach the npm registry to check for a newer version. Nothing was changed.");
22223
+ return 1;
22224
+ }
22225
+ if (compareVersions(latest, VERSION) <= 0) {
22226
+ t.out(`devstation ${VERSION} is the latest version.`);
22227
+ return 0;
22228
+ }
22229
+ t.out(`devstation ${latest} is available (you have ${VERSION}).`);
22230
+ const method = installMethod(opts.execPath, opts.script);
22231
+ if (opts.check) {
22232
+ t.out(method === "source" ? "This is a source checkout: update it with git pull." : "Upgrade with: devstation upgrade");
22233
+ return 0;
22234
+ }
22235
+ switch (method) {
22236
+ case "npm": {
22237
+ t.out(`Installed through npm, so upgrading through npm: npm install -g ${PACKAGE}@${latest}`);
22238
+ const code = (opts.run ?? runInherit)("npm", ["install", "-g", `${PACKAGE}@${latest}`]);
22239
+ if (code !== 0) {
22240
+ t.err(`npm exited with ${code}. Nothing else was changed. Run it yourself: npm install -g ${PACKAGE}@latest`);
22241
+ return code === 0 ? 1 : code;
22242
+ }
22243
+ t.out(`Upgraded to ${latest}. If your shell still runs the old one, run: hash -r`);
22244
+ return 0;
22245
+ }
22246
+ case "source":
22247
+ t.out("This is running from a source checkout. Update it with git pull, not upgrade.");
22248
+ return 0;
22249
+ case "binary":
22250
+ return replaceBinary(t, latest, opts.execPath ?? process.execPath, fetchImpl);
22251
+ }
22252
+ }
22253
+ async function replaceBinary(t, latest, execPath, fetchImpl) {
22254
+ const target = targetFor();
22255
+ if (!target) {
22256
+ t.err(`There is no prebuilt binary for ${process.platform}-${process.arch}.`);
22257
+ return 1;
22258
+ }
22259
+ if (process.platform === "win32") {
22260
+ t.err(`A running .exe cannot replace itself on Windows. Download ${target} from https://github.com/${REPO}/releases/latest and swap it in.`);
22261
+ return 1;
22262
+ }
22263
+ const base = `https://github.com/${REPO}/releases/download/v${latest}`;
22264
+ t.out(`Downloading ${target} ${latest}\u2026`);
22265
+ try {
22266
+ const sums = await fetchImpl(`${base}/SHA256SUMS`, { redirect: "follow" });
22267
+ if (!sums.ok)
22268
+ throw new Error(`${sums.status} fetching the checksums`);
22269
+ const expected = (await sums.text()).split(`
22270
+ `).map((line) => line.trim().split(/\s+/)).find(([, name]) => name === target)?.[0];
22271
+ if (!expected)
22272
+ throw new Error(`no checksum is published for ${target}`);
22273
+ const bin = await fetchImpl(`${base}/${target}`, { redirect: "follow" });
22274
+ if (!bin.ok)
22275
+ throw new Error(`${bin.status} downloading ${target}`);
22276
+ const body = Buffer.from(await bin.arrayBuffer());
22277
+ const actual = createHash2("sha256").update(body).digest("hex");
22278
+ if (actual !== expected) {
22279
+ throw new Error(`the download does not match its checksum (expected ${expected}, got ${actual})`);
22280
+ }
22281
+ const partial = `${execPath}.partial`;
22282
+ writeFileSync7(partial, body);
22283
+ chmodSync2(partial, 493);
22284
+ renameSync3(partial, execPath);
22285
+ } catch (error2) {
22286
+ const message2 = error2 instanceof Error ? error2.message : String(error2);
22287
+ const permission = /EACCES|EPERM/.test(message2) ? " Run it with sudo, since the binary is in a system directory." : "";
22288
+ t.err(`Upgrade failed and the installed version was left untouched: ${message2}.${permission}`);
22289
+ return 1;
22290
+ }
22291
+ t.out(`Upgraded ${execPath} to ${latest}.`);
22292
+ return 0;
22293
+ }
22294
+ function notifyIfOutdated(terminal, opts = {}) {
22295
+ const env2 = opts.env ?? process.env;
22296
+ if (env2.DEVSTATION_NO_UPDATE_CHECK === "1" || env2.CI)
22297
+ return Promise.resolve();
22298
+ const homeDir = opts.home ?? env2.HOME ?? "";
22299
+ if (!homeDir)
22300
+ return Promise.resolve();
22301
+ const path4 = join14(homeDir, ".devstation", "update-check.json");
22302
+ let cached = {};
22303
+ try {
22304
+ cached = JSON.parse(readFileSync9(path4, "utf8"));
22305
+ } catch {}
22306
+ if (cached.latest && compareVersions(cached.latest, VERSION) > 0) {
22307
+ terminal.err(`devstation ${cached.latest} is available (you have ${VERSION}). Run: devstation upgrade`);
22308
+ }
22309
+ const now = opts.now ?? Date.now();
22310
+ if (cached.checkedAt && now - cached.checkedAt < DAY_MS)
22311
+ return Promise.resolve();
22312
+ return latestVersion(opts.fetchImpl ?? fetch, 2500).then((latest) => {
22313
+ if (!latest)
22314
+ return;
22315
+ try {
22316
+ mkdirSync8(dirname8(path4), { recursive: true, mode: 448 });
22317
+ writeFileSync7(path4, `${JSON.stringify({ checkedAt: now, latest })}
22318
+ `);
22319
+ } catch {}
22320
+ });
22321
+ }
22322
+
21729
22323
  // src/lib/agent/cli/index.ts
21730
22324
  async function main(argv) {
21731
22325
  const parsed = parseArgs(argv);
@@ -21745,12 +22339,24 @@ ${HELP}`);
21745
22339
  return 0;
21746
22340
  }
21747
22341
  const root = resolve4(parsed.root);
21748
- if (!existsSync11(root)) {
22342
+ if (!existsSync12(root)) {
21749
22343
  process.stderr.write(`There is no directory at ${root}.
21750
22344
  `);
21751
22345
  return 2;
21752
22346
  }
21753
- const readline2 = createInterface2({ input: process.stdin, output: process.stdout });
22347
+ let muted = false;
22348
+ const output = new Writable({
22349
+ write(chunk, encoding, done) {
22350
+ if (!muted)
22351
+ process.stdout.write(chunk, encoding);
22352
+ done();
22353
+ }
22354
+ });
22355
+ const readline2 = createInterface2({
22356
+ input: process.stdin,
22357
+ output,
22358
+ terminal: Boolean(process.stdin.isTTY)
22359
+ });
21754
22360
  const input = lineReader(readline2, (text) => process.stdout.write(text));
21755
22361
  const terminal = {
21756
22362
  out: (text) => process.stdout.write(`${text}
@@ -21758,6 +22364,18 @@ ${HELP}`);
21758
22364
  err: (text) => process.stderr.write(`${text}
21759
22365
  `),
21760
22366
  ask: (question) => input.ask(question),
22367
+ askSecret: async (question) => {
22368
+ process.stdout.write(question);
22369
+ muted = true;
22370
+ try {
22371
+ return await input.ask("");
22372
+ } finally {
22373
+ muted = false;
22374
+ if (process.stdin.isTTY)
22375
+ process.stdout.write(`
22376
+ `);
22377
+ }
22378
+ },
21761
22379
  write: process.stdout.isTTY ? (text) => process.stdout.write(text) : undefined,
21762
22380
  colour: colourEnabled()
21763
22381
  };
@@ -21784,7 +22402,13 @@ Stopping. The session is saved; resume it with \`${CLI_NAME} resume\`.`);
21784
22402
  case "tools":
21785
22403
  return toolsCommand(offline);
21786
22404
  case "config":
21787
- return configCommand(offline);
22405
+ return parsed.rest.trim() ? configEditCommand(offline, parsed.rest, { project: parsed.project }) : configCommand(offline);
22406
+ case "login":
22407
+ return await loginCommand(offline, parsed.rest);
22408
+ case "logout":
22409
+ return logoutCommand(offline, parsed.rest);
22410
+ case "upgrade":
22411
+ return await upgradeCommand(offline, { check: parsed.check });
21788
22412
  case "doctor":
21789
22413
  return await doctorCommand(offline);
21790
22414
  case "index":
@@ -21796,12 +22420,19 @@ Stopping. The session is saved; resume it with \`${CLI_NAME} resume\`.`);
21796
22420
  default:
21797
22421
  break;
21798
22422
  }
21799
- const provider = providerFromEnv(process.env, parsed.model);
22423
+ const settings2 = resolveSettings({ root, model: parsed.model });
22424
+ for (const warning of settings2.warnings)
22425
+ terminal.err(`warning: ${warning}`);
22426
+ const provider = providerFromSettings(settings2);
21800
22427
  if (!provider) {
21801
- terminal.err("No model provider is configured. Set ANTHROPIC_API_KEY (or OPENROUTER_API_KEY) and try again.");
22428
+ terminal.err(settings2.problem ?? "No model provider is configured.");
21802
22429
  terminal.err(`Run \`${CLI_NAME} doctor\` to see what else is missing.`);
21803
22430
  return 2;
21804
22431
  }
22432
+ const atHome = homeDirectoryWarning(root);
22433
+ if (atHome)
22434
+ terminal.err(`warning: ${atHome}`);
22435
+ notifyIfOutdated(terminal);
21805
22436
  const ctx = context(root, terminal, parsed, provider);
21806
22437
  ctx.signal = controller.signal;
21807
22438
  if (parsed.command === "repo") {
@@ -21824,7 +22455,7 @@ Stopping. The session is saved; resume it with \`${CLI_NAME} resume\`.`);
21824
22455
  terminal.err(`Give it something to do: ${CLI_NAME} run "fix the failing test in src/lib"`);
21825
22456
  return 2;
21826
22457
  }
21827
- terminal.out(`using ${configuredProviderName()}`);
22458
+ terminal.out(`using ${provider.name}/${provider.model}`);
21828
22459
  const { code } = await runCommand(ctx, parsed.rest);
21829
22460
  return code;
21830
22461
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devstationlabs/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "The DevStation coding agent, in your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",