glotfile 0.1.1 → 0.3.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.
@@ -31,6 +31,32 @@ var init_format = __esm({
31
31
  }
32
32
  });
33
33
 
34
+ // src/server/atomic-write.ts
35
+ import { writeFileSync, renameSync, mkdirSync, rmSync } from "fs";
36
+ import { dirname, join } from "path";
37
+ function writeFileAtomic(path, data) {
38
+ const dir = dirname(path);
39
+ mkdirSync(dir, { recursive: true });
40
+ const tmp = join(dir, `.${process.pid}.${counter++}.tmp`);
41
+ try {
42
+ writeFileSync(tmp, data);
43
+ renameSync(tmp, path);
44
+ } catch (e) {
45
+ try {
46
+ rmSync(tmp, { force: true });
47
+ } catch {
48
+ }
49
+ throw e;
50
+ }
51
+ }
52
+ var counter;
53
+ var init_atomic_write = __esm({
54
+ "src/server/atomic-write.ts"() {
55
+ "use strict";
56
+ counter = 0;
57
+ }
58
+ });
59
+
34
60
  // src/server/lint/registry.ts
35
61
  var RULE_IDS, DEFAULT_SEVERITY;
36
62
  var init_registry = __esm({
@@ -117,17 +143,6 @@ function validate(raw) {
117
143
  }
118
144
  }
119
145
  }
120
- if (!isObject(config.ai)) fail("config.ai must be an object");
121
- const ai = config.ai;
122
- if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
123
- fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
124
- }
125
- if (typeof ai.model !== "string") fail("config.ai.model must be a string");
126
- if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
127
- if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
128
- fail("config.ai.region must be a string or null");
129
- }
130
- if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
131
146
  if (!isObject(config.format)) fail("config.format must be an object");
132
147
  const fmt = config.format;
133
148
  if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
@@ -258,7 +273,6 @@ function defaultState() {
258
273
  { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
259
274
  { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
260
275
  ],
261
- ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
262
276
  format: { indent: 2, sortKeys: true, finalNewline: true },
263
277
  spelling: { customWords: [] },
264
278
  autoExport: true
@@ -277,7 +291,7 @@ var init_schema = __esm({
277
291
  PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
278
292
  EXACT_SELECTOR_RE = /^=\d+$/;
279
293
  LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
280
- PROVIDERS = ["anthropic", "openai", "bedrock"];
294
+ PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
281
295
  GlotfileError = class extends Error {
282
296
  };
283
297
  }
@@ -402,13 +416,13 @@ var init_plurals = __esm({
402
416
  });
403
417
 
404
418
  // src/server/storage.ts
405
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
406
- import { join, dirname } from "path";
419
+ import { existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync as rmSync2 } from "fs";
420
+ import { join as join2 } from "path";
407
421
  function splitDirFor(statePath) {
408
422
  return statePath.replace(/\.json$/i, "");
409
423
  }
410
424
  function detectFormat(statePath) {
411
- if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
425
+ if (existsSync(join2(splitDirFor(statePath), "config.json"))) return "split";
412
426
  if (existsSync(statePath)) return "single";
413
427
  return "none";
414
428
  }
@@ -438,15 +452,15 @@ function assemble(parts) {
438
452
  return { ...parts.manifest, keys };
439
453
  }
440
454
  function loadSplit(splitDir) {
441
- const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
442
- const manifest = readJson(join(splitDir, "config.json"));
443
- const keysPath = join(splitDir, "keys.json");
444
- const keys = existsSync(keysPath) ? readJson(keysPath) : {};
445
- const localesDir = join(splitDir, "locales");
455
+ const readJson3 = (p) => JSON.parse(readFileSync(p, "utf8"));
456
+ const manifest = readJson3(join2(splitDir, "config.json"));
457
+ const keysPath = join2(splitDir, "keys.json");
458
+ const keys = existsSync(keysPath) ? readJson3(keysPath) : {};
459
+ const localesDir = join2(splitDir, "locales");
446
460
  const locales = {};
447
461
  if (existsSync(localesDir)) {
448
462
  for (const name of readdirSync(localesDir)) {
449
- if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
463
+ if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson3(join2(localesDir, name));
450
464
  }
451
465
  }
452
466
  return assemble({ manifest, keys, locales });
@@ -458,15 +472,14 @@ function writeIfChanged(path, contents) {
458
472
  } catch {
459
473
  }
460
474
  if (current === contents) return false;
461
- mkdirSync(dirname(path), { recursive: true });
462
- writeFileSync(path, contents, "utf8");
475
+ writeFileAtomic(path, contents);
463
476
  return true;
464
477
  }
465
478
  function saveSplit(splitDir, state) {
466
479
  const fmt = state.config.format;
467
480
  const parts = disassemble(state);
468
- const localesDir = join(splitDir, "locales");
469
- mkdirSync(localesDir, { recursive: true });
481
+ const localesDir = join2(splitDir, "locales");
482
+ mkdirSync2(localesDir, { recursive: true });
470
483
  let written = 0;
471
484
  let skipped = 0;
472
485
  let deleted = 0;
@@ -474,15 +487,15 @@ function saveSplit(splitDir, state) {
474
487
  if (changed) written++;
475
488
  else skipped++;
476
489
  };
477
- track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
478
- track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
490
+ track(writeIfChanged(join2(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
491
+ track(writeIfChanged(join2(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
479
492
  for (const [locale, entries] of Object.entries(parts.locales)) {
480
- track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
493
+ track(writeIfChanged(join2(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
481
494
  }
482
495
  const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
483
496
  for (const name of readdirSync(localesDir)) {
484
497
  if (name.endsWith(".json") && !live.has(name)) {
485
- rmSync(join(localesDir, name));
498
+ rmSync2(join2(localesDir, name));
486
499
  deleted++;
487
500
  }
488
501
  }
@@ -492,6 +505,7 @@ var init_storage = __esm({
492
505
  "src/server/storage.ts"() {
493
506
  "use strict";
494
507
  init_format();
508
+ init_atomic_write();
495
509
  }
496
510
  });
497
511
 
@@ -506,8 +520,7 @@ var init_normalize = __esm({
506
520
  });
507
521
 
508
522
  // src/server/state.ts
509
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
510
- import { dirname as dirname2 } from "path";
523
+ import { readFileSync as readFileSync2, existsSync as existsSync2, rmSync as rmSync3 } from "fs";
511
524
  import { randomUUID } from "crypto";
512
525
  function canonLocale(locale) {
513
526
  return locale.trim().toLowerCase().replace(/_/g, "-");
@@ -552,10 +565,9 @@ function saveState(path, state) {
552
565
  normalizeState(state);
553
566
  if (state.config.storage === "split") {
554
567
  saveSplit(splitDirFor(path), state);
555
- if (existsSync2(path)) rmSync2(path);
568
+ if (existsSync2(path)) rmSync3(path);
556
569
  } else {
557
- mkdirSync2(dirname2(path), { recursive: true });
558
- writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
570
+ writeFileAtomic(path, serializeJson(state, state.config.format));
559
571
  }
560
572
  }
561
573
  function requireKey(state, key) {
@@ -703,6 +715,9 @@ function setMetadata(state, key, partial) {
703
715
  delete entry.contextAt;
704
716
  }
705
717
  Object.assign(entry, safe);
718
+ if ("context" in safe && !entry.context) delete entry.context;
719
+ if ("tags" in safe && !entry.tags?.length) delete entry.tags;
720
+ if ("maxLength" in safe && !entry.maxLength) delete entry.maxLength;
706
721
  }
707
722
  function addNote(state, key, text, clock = systemClock) {
708
723
  const entry = requireKey(state, key);
@@ -761,6 +776,7 @@ var init_state = __esm({
761
776
  "src/server/state.ts"() {
762
777
  "use strict";
763
778
  init_format();
779
+ init_atomic_write();
764
780
  init_schema();
765
781
  init_plurals();
766
782
  init_storage();
@@ -1494,8 +1510,8 @@ var init_adapters = __esm({
1494
1510
  });
1495
1511
 
1496
1512
  // src/server/export-run.ts
1497
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3 } from "fs";
1498
- import { dirname as dirname3, resolve } from "path";
1513
+ import { readFileSync as readFileSync3 } from "fs";
1514
+ import { resolve } from "path";
1499
1515
  function effectiveLocales(config) {
1500
1516
  const limit = config.exportLocales;
1501
1517
  if (!limit || limit.length === 0) return config.locales;
@@ -1532,8 +1548,7 @@ function exportToDisk(state, projectRoot, opts) {
1532
1548
  skipped++;
1533
1549
  continue;
1534
1550
  }
1535
- mkdirSync3(dirname3(abs), { recursive: true });
1536
- writeFileSync3(abs, f.contents, "utf8");
1551
+ writeFileAtomic(abs, f.contents);
1537
1552
  written++;
1538
1553
  }
1539
1554
  }
@@ -1543,6 +1558,7 @@ var init_export_run = __esm({
1543
1558
  "src/server/export-run.ts"() {
1544
1559
  "use strict";
1545
1560
  init_adapters();
1561
+ init_atomic_write();
1546
1562
  }
1547
1563
  });
1548
1564
 
@@ -1636,7 +1652,7 @@ function validateTranslation(req, translation) {
1636
1652
  return { id: req.id, error: "Placeholder mismatch between source and translation." };
1637
1653
  }
1638
1654
  if (req.maxLength !== void 0 && translation.length > req.maxLength) {
1639
- return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1655
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1640
1656
  }
1641
1657
  return { id: req.id, translation };
1642
1658
  }
@@ -1768,6 +1784,17 @@ var init_anthropic = __esm({
1768
1784
 
1769
1785
  // src/server/ai/openai.ts
1770
1786
  import { createRequire } from "module";
1787
+ function loadOpenAIClient(opts) {
1788
+ const require2 = createRequire(import.meta.url);
1789
+ let OpenAICtor;
1790
+ try {
1791
+ const mod = require2("openai");
1792
+ OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
1793
+ } catch {
1794
+ throw new Error("The OpenAI SDK is required for this provider. Install it: npm i openai");
1795
+ }
1796
+ return new OpenAICtor(opts);
1797
+ }
1771
1798
  var OpenAIProvider;
1772
1799
  var init_openai = __esm({
1773
1800
  "src/server/ai/openai.ts"() {
@@ -1784,15 +1811,7 @@ var init_openai = __esm({
1784
1811
  if (!process.env.OPENAI_API_KEY) {
1785
1812
  throw new Error("OPENAI_API_KEY is not set. AI translation requires it; every other feature works offline.");
1786
1813
  }
1787
- const require2 = createRequire(import.meta.url);
1788
- let OpenAICtor;
1789
- try {
1790
- const mod = require2("openai");
1791
- OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
1792
- } catch {
1793
- throw new Error('Provider "openai" requires the OpenAI SDK. Install it: npm i openai');
1794
- }
1795
- this.client = new OpenAICtor({ baseURL: config.endpoint ?? void 0 });
1814
+ this.client = loadOpenAIClient({ baseURL: config.endpoint ?? void 0 });
1796
1815
  }
1797
1816
  config;
1798
1817
  client;
@@ -1885,7 +1904,7 @@ var init_bedrock = __esm({
1885
1904
  }
1886
1905
  const region = config.region ?? process.env.AWS_REGION;
1887
1906
  if (!region) {
1888
- throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
1907
+ throw new Error("AWS region is not set. Set the Region in your local AI settings (.glotfile/settings.json) or the AWS_REGION environment variable for the bedrock provider.");
1889
1908
  }
1890
1909
  const require2 = createRequire2(import.meta.url);
1891
1910
  let sdk;
@@ -1985,9 +2004,159 @@ var init_bedrock = __esm({
1985
2004
  }
1986
2005
  });
1987
2006
 
2007
+ // src/server/ai/openrouter.ts
2008
+ function openRouterClientOptions(config) {
2009
+ const apiKey = process.env.OPENROUTER_API_KEY;
2010
+ if (!apiKey) {
2011
+ throw new Error("OPENROUTER_API_KEY is not set. AI translation requires it; every other feature works offline.");
2012
+ }
2013
+ return {
2014
+ apiKey,
2015
+ baseURL: config.endpoint ?? OPENROUTER_BASE_URL,
2016
+ defaultHeaders: { "HTTP-Referer": OPENROUTER_REFERER, "X-Title": OPENROUTER_TITLE }
2017
+ };
2018
+ }
2019
+ var OPENROUTER_BASE_URL, OPENROUTER_REFERER, OPENROUTER_TITLE, OpenRouterProvider;
2020
+ var init_openrouter = __esm({
2021
+ "src/server/ai/openrouter.ts"() {
2022
+ "use strict";
2023
+ init_openai();
2024
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
2025
+ OPENROUTER_REFERER = "https://www.npmjs.com/package/glotfile";
2026
+ OPENROUTER_TITLE = "glotfile";
2027
+ OpenRouterProvider = class extends OpenAIProvider {
2028
+ constructor(config, client) {
2029
+ super(config, client ?? loadOpenAIClient(openRouterClientOptions(config)));
2030
+ }
2031
+ };
2032
+ }
2033
+ });
2034
+
2035
+ // src/server/ai/ollama.ts
2036
+ function ollamaClientOptions(config) {
2037
+ return {
2038
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
2039
+ baseURL: config.endpoint ?? OLLAMA_BASE_URL
2040
+ };
2041
+ }
2042
+ var OLLAMA_BASE_URL, OllamaProvider;
2043
+ var init_ollama = __esm({
2044
+ "src/server/ai/ollama.ts"() {
2045
+ "use strict";
2046
+ init_openai();
2047
+ OLLAMA_BASE_URL = "http://localhost:11434/v1";
2048
+ OllamaProvider = class extends OpenAIProvider {
2049
+ constructor(config, client) {
2050
+ super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2051
+ }
2052
+ supportsVision() {
2053
+ return false;
2054
+ }
2055
+ };
2056
+ }
2057
+ });
2058
+
2059
+ // src/server/ai/claudecode.ts
2060
+ import { spawn } from "child_process";
2061
+ function stripFences(s) {
2062
+ return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
2063
+ }
2064
+ function defaultSpawn(prompt, systemPrompt, model) {
2065
+ return new Promise((resolve12, reject) => {
2066
+ const args = [
2067
+ "--print",
2068
+ "--output-format",
2069
+ "json",
2070
+ "--system-prompt",
2071
+ systemPrompt,
2072
+ // Only pass --model when explicitly configured; otherwise use the session default.
2073
+ ...model ? ["--model", model] : []
2074
+ ];
2075
+ const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
2076
+ let stdout = "";
2077
+ let stderr = "";
2078
+ child.stdout.on("data", (chunk2) => {
2079
+ stdout += chunk2.toString();
2080
+ });
2081
+ child.stderr.on("data", (chunk2) => {
2082
+ stderr += chunk2.toString();
2083
+ });
2084
+ child.stdin.write(prompt);
2085
+ child.stdin.end();
2086
+ child.on("close", (code) => {
2087
+ if (code !== 0) {
2088
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
2089
+ return;
2090
+ }
2091
+ try {
2092
+ const envelope = JSON.parse(stdout.trim());
2093
+ if (envelope.is_error) {
2094
+ reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
2095
+ return;
2096
+ }
2097
+ resolve12(envelope.result ?? "");
2098
+ } catch {
2099
+ reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
2100
+ }
2101
+ });
2102
+ child.on("error", (err) => {
2103
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
2104
+ });
2105
+ });
2106
+ }
2107
+ var ClaudeCodeProvider;
2108
+ var init_claudecode = __esm({
2109
+ "src/server/ai/claudecode.ts"() {
2110
+ "use strict";
2111
+ init_provider();
2112
+ init_batch();
2113
+ ClaudeCodeProvider = class {
2114
+ constructor(config, spawnFn) {
2115
+ this.config = config;
2116
+ this.spawnFn = spawnFn ?? defaultSpawn;
2117
+ }
2118
+ config;
2119
+ spawnFn;
2120
+ supportsVision() {
2121
+ return false;
2122
+ }
2123
+ translate(reqs, onBatchComplete, signal) {
2124
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2125
+ }
2126
+ async complete(req) {
2127
+ const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
2128
+ const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
2129
+ const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
2130
+ try {
2131
+ return JSON.parse(stripFences(result));
2132
+ } catch {
2133
+ return {};
2134
+ }
2135
+ }
2136
+ async callBatch(batch, signal) {
2137
+ if (signal?.aborted) return [];
2138
+ const prompt = buildBatchPrompt(batch);
2139
+ let result;
2140
+ try {
2141
+ result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2142
+ } catch (err) {
2143
+ if (signal?.aborted) return [];
2144
+ throw err;
2145
+ }
2146
+ if (signal?.aborted) return [];
2147
+ try {
2148
+ const parsed = JSON.parse(stripFences(result));
2149
+ return parsed.items ?? [];
2150
+ } catch {
2151
+ return [];
2152
+ }
2153
+ }
2154
+ };
2155
+ }
2156
+ });
2157
+
1988
2158
  // src/server/ai/index.ts
1989
- function makeProvider(config) {
1990
- const ai = config.ai;
2159
+ function makeProvider(ai) {
1991
2160
  switch (ai.provider) {
1992
2161
  case "anthropic":
1993
2162
  return new AnthropicProvider(ai);
@@ -1995,8 +2164,14 @@ function makeProvider(config) {
1995
2164
  return new OpenAIProvider(ai);
1996
2165
  case "bedrock":
1997
2166
  return new BedrockProvider(ai);
2167
+ case "openrouter":
2168
+ return new OpenRouterProvider(ai);
2169
+ case "ollama":
2170
+ return new OllamaProvider(ai);
2171
+ case "claude-code":
2172
+ return new ClaudeCodeProvider(ai);
1998
2173
  default:
1999
- throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock.`);
2174
+ throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
2000
2175
  }
2001
2176
  }
2002
2177
  var init_ai = __esm({
@@ -2005,6 +2180,96 @@ var init_ai = __esm({
2005
2180
  init_anthropic();
2006
2181
  init_openai();
2007
2182
  init_bedrock();
2183
+ init_openrouter();
2184
+ init_ollama();
2185
+ init_claudecode();
2186
+ }
2187
+ });
2188
+
2189
+ // src/server/glotfile-dir.ts
2190
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2191
+ import { resolve as resolve2 } from "path";
2192
+ function ensureGlotfileDir(projectRoot) {
2193
+ const dir = resolve2(projectRoot, ".glotfile");
2194
+ mkdirSync3(dir, { recursive: true });
2195
+ const ignore = resolve2(dir, ".gitignore");
2196
+ if (!existsSync3(ignore)) {
2197
+ try {
2198
+ writeFileSync2(ignore, "*\n");
2199
+ } catch {
2200
+ }
2201
+ }
2202
+ return dir;
2203
+ }
2204
+ var init_glotfile_dir = __esm({
2205
+ "src/server/glotfile-dir.ts"() {
2206
+ "use strict";
2207
+ }
2208
+ });
2209
+
2210
+ // src/server/local-settings.ts
2211
+ import { readFileSync as readFileSync4 } from "fs";
2212
+ import { resolve as resolve3 } from "path";
2213
+ function readJson(path) {
2214
+ try {
2215
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
2216
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2217
+ } catch {
2218
+ return {};
2219
+ }
2220
+ }
2221
+ function coerceAi(raw) {
2222
+ const a = raw && typeof raw === "object" ? raw : {};
2223
+ return {
2224
+ provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
2225
+ model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
2226
+ endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
2227
+ region: typeof a.region === "string" ? a.region : null,
2228
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
2229
+ };
2230
+ }
2231
+ function loadLocalSettings(projectRoot) {
2232
+ const raw = readJson(settingsPath(projectRoot));
2233
+ return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
2234
+ }
2235
+ function saveLocalSettings(projectRoot, patch) {
2236
+ const path = settingsPath(projectRoot);
2237
+ const merged = { ...readJson(path) };
2238
+ if (patch.ai !== void 0) merged.ai = patch.ai;
2239
+ if (patch.editor !== void 0) merged.editor = patch.editor;
2240
+ ensureGlotfileDir(projectRoot);
2241
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
2242
+ }
2243
+ function aiConfigError(ai) {
2244
+ if (!ai || typeof ai !== "object") return "ai must be an object";
2245
+ const a = ai;
2246
+ if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
2247
+ return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
2248
+ }
2249
+ if (typeof a.model !== "string") return "ai.model must be a string";
2250
+ if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2251
+ if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2252
+ if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2253
+ return null;
2254
+ }
2255
+ var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
2256
+ var init_local_settings = __esm({
2257
+ "src/server/local-settings.ts"() {
2258
+ "use strict";
2259
+ init_atomic_write();
2260
+ init_glotfile_dir();
2261
+ init_schema();
2262
+ EDITOR_IDS = ["vscode", "zed", "phpstorm"];
2263
+ isEditorId = (v) => EDITOR_IDS.includes(v);
2264
+ DEFAULT_AI = {
2265
+ provider: "anthropic",
2266
+ model: "claude-haiku-4-5-20251001",
2267
+ endpoint: null,
2268
+ region: null,
2269
+ batchSize: 25
2270
+ };
2271
+ DEFAULT_EDITOR = "vscode";
2272
+ settingsPath = (projectRoot) => resolve3(projectRoot, ".glotfile", "settings.json");
2008
2273
  }
2009
2274
  });
2010
2275
 
@@ -2020,8 +2285,8 @@ var init_glob = __esm({
2020
2285
  });
2021
2286
 
2022
2287
  // src/server/ai/run.ts
2023
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2024
- import { resolve as resolve2, extname } from "path";
2288
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2289
+ import { resolve as resolve4, extname } from "path";
2025
2290
  function selectRequests(state, opts) {
2026
2291
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
2027
2292
  const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
@@ -2100,11 +2365,11 @@ function attachScreenshots(reqs, state, projectRoot) {
2100
2365
  const mediaType = MEDIA_TYPES[extname(screenshot).toLowerCase()];
2101
2366
  if (!mediaType) continue;
2102
2367
  if (!cache2.has(screenshot)) {
2103
- const abs = resolve2(projectRoot, screenshot);
2104
- if (!existsSync3(abs)) {
2368
+ const abs = resolve4(projectRoot, screenshot);
2369
+ if (!existsSync4(abs)) {
2105
2370
  cache2.set(screenshot, null);
2106
2371
  } else {
2107
- const buf = readFileSync4(abs);
2372
+ const buf = readFileSync5(abs);
2108
2373
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
2109
2374
  }
2110
2375
  }
@@ -2120,7 +2385,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
2120
2385
  const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
2121
2386
  return { skipped: keys.size };
2122
2387
  }
2123
- async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2388
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2124
2389
  if (!reqs.length) return [];
2125
2390
  const byLocale = /* @__PURE__ */ new Map();
2126
2391
  for (const req of reqs) {
@@ -2140,11 +2405,14 @@ async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency =
2140
2405
  while (next < groups.length) {
2141
2406
  if (signal?.aborted) break;
2142
2407
  const group = groups[next++];
2408
+ const locale = group[0].targetLocale;
2409
+ hooks.onLocaleStart?.(locale);
2143
2410
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
2144
2411
  done += batchResults.length;
2145
- onBatchComplete?.(done, total, batchResults);
2412
+ hooks.onBatchComplete?.(done, total, batchResults, locale);
2146
2413
  }, signal);
2147
2414
  allResults.push(...localeResults);
2415
+ if (!signal?.aborted) hooks.onLocaleDone?.(locale);
2148
2416
  }
2149
2417
  }
2150
2418
  const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
@@ -2166,10 +2434,11 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
2166
2434
  if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
2167
2435
  continue;
2168
2436
  }
2169
- if (res.error || res.translation === void 0) {
2437
+ if (res.translation === void 0) {
2170
2438
  errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
2171
2439
  continue;
2172
2440
  }
2441
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
2173
2442
  if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
2174
2443
  }
2175
2444
  return { written, errors };
@@ -2194,49 +2463,46 @@ var init_run = __esm({
2194
2463
  }
2195
2464
  });
2196
2465
 
2197
- // src/server/ai/log.ts
2198
- import { appendFileSync, readFileSync as readFileSync5, existsSync as existsSync4, mkdirSync as mkdirSync4 } from "fs";
2199
- import { resolve as resolve3 } from "path";
2466
+ // src/server/log.ts
2467
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2468
+ import { resolve as resolve5 } from "path";
2200
2469
  function logPath(projectRoot) {
2201
- return resolve3(projectRoot, ".glotfile", "ai-log.jsonl");
2470
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
2202
2471
  }
2203
- function appendAiLog(projectRoot, entry) {
2204
- mkdirSync4(resolve3(projectRoot, ".glotfile"), { recursive: true });
2472
+ function appendLog(projectRoot, entry) {
2473
+ ensureGlotfileDir(projectRoot);
2205
2474
  appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
2206
2475
  }
2207
- function readAiLog(projectRoot, limit = 100) {
2476
+ function readLog(projectRoot, limit = 100) {
2208
2477
  const path = logPath(projectRoot);
2209
- if (!existsSync4(path)) return [];
2210
- const lines = readFileSync5(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2211
- const entries = lines.map((l) => {
2212
- const e = JSON.parse(l);
2213
- e.kind ??= "translate";
2214
- return e;
2215
- });
2478
+ if (!existsSync5(path)) return [];
2479
+ const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2480
+ const entries = lines.map((l) => JSON.parse(l));
2216
2481
  return entries.reverse().slice(0, limit);
2217
2482
  }
2218
2483
  var init_log = __esm({
2219
- "src/server/ai/log.ts"() {
2484
+ "src/server/log.ts"() {
2220
2485
  "use strict";
2486
+ init_glotfile_dir();
2221
2487
  }
2222
2488
  });
2223
2489
 
2224
2490
  // src/server/scan.ts
2225
- import { existsSync as existsSync5, readFileSync as readFileSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2226
- import { resolve as resolve4, dirname as dirname4 } from "path";
2491
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2492
+ import { resolve as resolve6 } from "path";
2227
2493
  function loadUsageCache(projectRoot) {
2228
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2229
- if (!existsSync5(path)) return null;
2494
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2495
+ if (!existsSync6(path)) return null;
2230
2496
  try {
2231
- return JSON.parse(readFileSync6(path, "utf8"));
2497
+ return JSON.parse(readFileSync7(path, "utf8"));
2232
2498
  } catch {
2233
2499
  return null;
2234
2500
  }
2235
2501
  }
2236
2502
  function saveUsageCache(projectRoot, cache2) {
2237
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2238
- mkdirSync5(dirname4(path), { recursive: true });
2239
- writeFileSync4(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
2503
+ ensureGlotfileDir(projectRoot);
2504
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2505
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
2240
2506
  }
2241
2507
  function findMissing(state) {
2242
2508
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
@@ -2263,12 +2529,14 @@ function computeUsedKeys(state, cache2) {
2263
2529
  var init_scan = __esm({
2264
2530
  "src/server/scan.ts"() {
2265
2531
  "use strict";
2532
+ init_atomic_write();
2533
+ init_glotfile_dir();
2266
2534
  }
2267
2535
  });
2268
2536
 
2269
2537
  // src/server/scanner.ts
2270
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync7 } from "fs";
2271
- import { join as join2, extname as extname2, relative } from "path";
2538
+ import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
2539
+ import { join as join3, extname as extname2, relative } from "path";
2272
2540
  function scannerForExt(ext) {
2273
2541
  return EXT_SCANNER[ext] ?? null;
2274
2542
  }
@@ -2394,7 +2662,7 @@ function* walkFiles(dir, root, exclude) {
2394
2662
  }
2395
2663
  for (const name of entries) {
2396
2664
  if (ALWAYS_EXCLUDE.has(name)) continue;
2397
- const abs = join2(dir, name);
2665
+ const abs = join3(dir, name);
2398
2666
  const rel = relative(root, abs);
2399
2667
  let st;
2400
2668
  try {
@@ -2424,7 +2692,7 @@ function runScan(projectRoot, opts, existing) {
2424
2692
  const ext = extname2(relPath);
2425
2693
  const scanner = scannerForExt(ext);
2426
2694
  if (!scanner) continue;
2427
- const abs = join2(projectRoot, relPath);
2695
+ const abs = join3(projectRoot, relPath);
2428
2696
  let st;
2429
2697
  try {
2430
2698
  st = statSync(abs);
@@ -2440,7 +2708,7 @@ function runScan(projectRoot, opts, existing) {
2440
2708
  }
2441
2709
  let content;
2442
2710
  try {
2443
- content = readFileSync7(abs, "utf8");
2711
+ content = readFileSync8(abs, "utf8");
2444
2712
  } catch {
2445
2713
  continue;
2446
2714
  }
@@ -2545,8 +2813,8 @@ var init_scanner = __esm({
2545
2813
  });
2546
2814
 
2547
2815
  // src/server/ai/context.ts
2548
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
2549
- import { resolve as resolve5 } from "path";
2816
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
2817
+ import { resolve as resolve7 } from "path";
2550
2818
  function globToRegExp2(glob) {
2551
2819
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
2552
2820
  return new RegExp(`^${escaped}$`);
@@ -2558,10 +2826,10 @@ function extractSnippets(refs, projectRoot, fileCache) {
2558
2826
  const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
2559
2827
  const snippets = [];
2560
2828
  for (const ref of selected) {
2561
- const absPath = resolve5(projectRoot, ref.file);
2829
+ const absPath = resolve7(projectRoot, ref.file);
2562
2830
  if (!fileCache.has(ref.file)) {
2563
- if (!existsSync6(absPath)) continue;
2564
- const content = readFileSync8(absPath, "utf8");
2831
+ if (!existsSync7(absPath)) continue;
2832
+ const content = readFileSync9(absPath, "utf8");
2565
2833
  fileCache.set(ref.file, content.split("\n"));
2566
2834
  }
2567
2835
  const lines = fileCache.get(ref.file);
@@ -2706,8 +2974,8 @@ var init_context = __esm({
2706
2974
  });
2707
2975
 
2708
2976
  // src/server/import/detect.ts
2709
- import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2710
- import { join as join3 } from "path";
2977
+ import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2978
+ import { join as join4 } from "path";
2711
2979
  function safeIsDir(p) {
2712
2980
  try {
2713
2981
  return statSync2(p).isDirectory();
@@ -2716,7 +2984,7 @@ function safeIsDir(p) {
2716
2984
  }
2717
2985
  }
2718
2986
  function listDirs(dir) {
2719
- return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
2987
+ return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
2720
2988
  }
2721
2989
  function fileCount(dir) {
2722
2990
  try {
@@ -2730,22 +2998,22 @@ function pickSource(locales, sizeOf) {
2730
2998
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
2731
2999
  }
2732
3000
  function detectLaravel(root) {
2733
- const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
3001
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
2734
3002
  if (!localeRoot) return null;
2735
3003
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
2736
3004
  if (locales.length === 0) return null;
2737
- const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
3005
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
2738
3006
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
2739
3007
  }
2740
3008
  function detectVue(root) {
2741
3009
  for (const rel of VUE_DIR_CANDIDATES) {
2742
- const localeRoot = join3(root, rel);
3010
+ const localeRoot = join4(root, rel);
2743
3011
  if (!safeIsDir(localeRoot)) continue;
2744
3012
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
2745
3013
  if (locales.length >= 2) {
2746
3014
  const sourceLocale = pickSource(locales, (loc) => {
2747
3015
  try {
2748
- return statSync2(join3(localeRoot, `${loc}.json`)).size;
3016
+ return statSync2(join4(localeRoot, `${loc}.json`)).size;
2749
3017
  } catch {
2750
3018
  return 0;
2751
3019
  }
@@ -2757,7 +3025,7 @@ function detectVue(root) {
2757
3025
  }
2758
3026
  function detectArb(root) {
2759
3027
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
2760
- const localeRoot = join3(root, rel);
3028
+ const localeRoot = join4(root, rel);
2761
3029
  if (!safeIsDir(localeRoot)) continue;
2762
3030
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
2763
3031
  if (locales.length >= 1) {
@@ -2767,7 +3035,7 @@ function detectArb(root) {
2767
3035
  return null;
2768
3036
  }
2769
3037
  function detect(root, formatOverride) {
2770
- if (!existsSync8(root)) return null;
3038
+ if (!existsSync9(root)) return null;
2771
3039
  if (formatOverride) {
2772
3040
  const fn = BY_FORMAT[formatOverride];
2773
3041
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -2822,8 +3090,8 @@ var init_flatten = __esm({
2822
3090
  });
2823
3091
 
2824
3092
  // src/server/import/parsers/vue-i18n-json.ts
2825
- import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
2826
- import { join as join4 } from "path";
3093
+ import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3094
+ import { join as join5 } from "path";
2827
3095
  var LOCALE_RE2, vueI18nJson2;
2828
3096
  var init_vue_i18n_json2 = __esm({
2829
3097
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -2843,7 +3111,7 @@ var init_vue_i18n_json2 = __esm({
2843
3111
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2844
3112
  let data;
2845
3113
  try {
2846
- data = JSON.parse(readFileSync10(join4(localeRoot, file), "utf8"));
3114
+ data = JSON.parse(readFileSync11(join5(localeRoot, file), "utf8"));
2847
3115
  } catch (e) {
2848
3116
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
2849
3117
  continue;
@@ -2871,16 +3139,16 @@ var init_placeholders2 = __esm({
2871
3139
 
2872
3140
  // src/server/import/parsers/laravel-php.ts
2873
3141
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2874
- import { join as join5, relative as relative2 } from "path";
3142
+ import { join as join6, relative as relative2 } from "path";
2875
3143
  import { execFileSync } from "child_process";
2876
3144
  function listDirs2(dir) {
2877
- return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
3145
+ return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
2878
3146
  }
2879
3147
  function listPhpFiles(dir) {
2880
3148
  const out = [];
2881
3149
  const walk = (d) => {
2882
3150
  for (const e of readdirSync5(d)) {
2883
- const full = join5(d, e);
3151
+ const full = join6(d, e);
2884
3152
  if (statSync3(full).isDirectory()) walk(full);
2885
3153
  else if (e.endsWith(".php")) out.push(full);
2886
3154
  }
@@ -2923,7 +3191,7 @@ var init_laravel_php2 = __esm({
2923
3191
  for (const locale of listDirs2(localeRoot).sort()) {
2924
3192
  if (locale === "vendor") continue;
2925
3193
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2926
- const localeDir = join5(localeRoot, locale);
3194
+ const localeDir = join6(localeRoot, locale);
2927
3195
  locales.push(locale);
2928
3196
  for (const file of listPhpFiles(localeDir)) {
2929
3197
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -2948,8 +3216,8 @@ var init_laravel_php2 = __esm({
2948
3216
  });
2949
3217
 
2950
3218
  // src/server/import/parsers/flutter-arb.ts
2951
- import { readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
2952
- import { join as join6 } from "path";
3219
+ import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
3220
+ import { join as join7 } from "path";
2953
3221
  function localeFromArbName(file) {
2954
3222
  const m = file.match(/^(.+)\.arb$/);
2955
3223
  if (!m) return null;
@@ -2989,7 +3257,7 @@ var init_flutter_arb2 = __esm({
2989
3257
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2990
3258
  let data;
2991
3259
  try {
2992
- data = JSON.parse(readFileSync11(join6(localeRoot, file), "utf8"));
3260
+ data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
2993
3261
  } catch (e) {
2994
3262
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
2995
3263
  continue;
@@ -3093,7 +3361,6 @@ function assemble2(parsed, opts) {
3093
3361
  sourceLocale,
3094
3362
  locales,
3095
3363
  outputs: [output],
3096
- ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
3097
3364
  format: { indent: 2, sortKeys: true, finalNewline: true },
3098
3365
  spelling: { customWords: [] }
3099
3366
  },
@@ -3520,16 +3787,48 @@ var init_checks = __esm({
3520
3787
  }
3521
3788
  });
3522
3789
 
3790
+ // src/server/ui-prefs.ts
3791
+ import { readFileSync as readFileSync13 } from "fs";
3792
+ import { homedir } from "os";
3793
+ import { join as join8 } from "path";
3794
+ function readJson2(path) {
3795
+ try {
3796
+ const parsed = JSON.parse(readFileSync13(path, "utf8"));
3797
+ return parsed && typeof parsed === "object" ? parsed : {};
3798
+ } catch {
3799
+ return {};
3800
+ }
3801
+ }
3802
+ function loadUiPrefs(path) {
3803
+ const raw = readJson2(path);
3804
+ return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
3805
+ }
3806
+ function saveUiPrefs(path, prefs) {
3807
+ const merged = { ...readJson2(path), ...prefs };
3808
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3809
+ }
3810
+ var THEMES, isThemeMode, defaultUiPrefsPath, DEFAULTS;
3811
+ var init_ui_prefs = __esm({
3812
+ "src/server/ui-prefs.ts"() {
3813
+ "use strict";
3814
+ init_atomic_write();
3815
+ THEMES = ["system", "light", "dark"];
3816
+ isThemeMode = (v) => THEMES.includes(v);
3817
+ defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
3818
+ DEFAULTS = { theme: "system" };
3819
+ }
3820
+ });
3821
+
3523
3822
  // src/server/api.ts
3524
3823
  import { Hono } from "hono";
3525
3824
  import { streamSSE } from "hono/streaming";
3526
- import { writeFileSync as writeFileSync5, readFileSync as readFileSync12, mkdirSync as mkdirSync6, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
3527
- import { dirname as dirname5, resolve as resolve7, basename, relative as relative3, sep } from "path";
3825
+ import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
3826
+ import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
3528
3827
  function projectName(root) {
3529
- const nameFile = resolve7(root, ".idea", ".name");
3530
- if (existsSync9(nameFile)) {
3828
+ const nameFile = resolve9(root, ".idea", ".name");
3829
+ if (existsSync10(nameFile)) {
3531
3830
  try {
3532
- const name = readFileSync12(nameFile, "utf8").trim();
3831
+ const name = readFileSync14(nameFile, "utf8").trim();
3533
3832
  if (name) return name;
3534
3833
  } catch {
3535
3834
  }
@@ -3539,7 +3838,7 @@ function projectName(root) {
3539
3838
  function createApi(deps) {
3540
3839
  const app = new Hono();
3541
3840
  const load = () => loadState(deps.statePath);
3542
- const projectRoot = dirname5(resolve7(deps.statePath));
3841
+ const projectRoot = dirname2(resolve9(deps.statePath));
3543
3842
  let translateQueue = Promise.resolve();
3544
3843
  const withTranslateLock = (fn) => {
3545
3844
  const next = translateQueue.then(fn, fn);
@@ -3563,7 +3862,36 @@ function createApi(deps) {
3563
3862
  saveState(deps.statePath, s);
3564
3863
  scheduleAutoExport(s);
3565
3864
  };
3865
+ const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
3866
+ const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
3867
+ const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
3566
3868
  app.get("/state", (c) => c.json(load()));
3869
+ app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
3870
+ app.put("/ui-prefs", async (c) => {
3871
+ const { theme } = await c.req.json();
3872
+ if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
3873
+ saveUiPrefs(uiPrefsPath, { theme });
3874
+ return c.json({ ok: true });
3875
+ });
3876
+ app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
3877
+ app.put("/local-settings", async (c) => {
3878
+ const body = await c.req.json().catch(() => ({}));
3879
+ const patch = {};
3880
+ if (body.ai !== void 0) {
3881
+ const err = aiConfigError(body.ai);
3882
+ if (err) return c.json({ error: err }, 400);
3883
+ patch.ai = body.ai;
3884
+ }
3885
+ if (body.editor !== void 0) {
3886
+ if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
3887
+ patch.editor = body.editor;
3888
+ }
3889
+ if (patch.ai === void 0 && patch.editor === void 0) {
3890
+ return c.json({ error: "provide ai and/or editor" }, 400);
3891
+ }
3892
+ saveLocalSettings(projectRoot, patch);
3893
+ return c.json({ ok: true });
3894
+ });
3567
3895
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3568
3896
  app.get("/files", (c) => {
3569
3897
  const found = /* @__PURE__ */ new Map();
@@ -3575,10 +3903,10 @@ function createApi(deps) {
3575
3903
  }
3576
3904
  for (const name of entries) {
3577
3905
  let abs;
3578
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve7(projectRoot, name, "config.json"))) {
3579
- abs = resolve7(projectRoot, `${name}.json`);
3906
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(projectRoot, name, "config.json"))) {
3907
+ abs = resolve9(projectRoot, `${name}.json`);
3580
3908
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3581
- abs = resolve7(projectRoot, name);
3909
+ abs = resolve9(projectRoot, name);
3582
3910
  } else {
3583
3911
  continue;
3584
3912
  }
@@ -3595,10 +3923,10 @@ function createApi(deps) {
3595
3923
  app.post("/file", async (c) => {
3596
3924
  const { path } = await c.req.json();
3597
3925
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
3598
- const resolved = resolve7(projectRoot, path);
3926
+ const resolved = resolve9(projectRoot, path);
3599
3927
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
3600
3928
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
3601
- if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
3929
+ if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
3602
3930
  loadState(resolved);
3603
3931
  deps.statePath = resolved;
3604
3932
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -3613,6 +3941,7 @@ function createApi(deps) {
3613
3941
  const s = load();
3614
3942
  createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
3615
3943
  persist(s);
3944
+ logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
3616
3945
  console.log(`[key] created ${key}`);
3617
3946
  return c.json({ ok: true });
3618
3947
  });
@@ -3622,37 +3951,45 @@ function createApi(deps) {
3622
3951
  const s = load();
3623
3952
  addCustomWord(s, word);
3624
3953
  persist(s);
3954
+ logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
3625
3955
  return c.json({ ok: true });
3626
3956
  });
3627
3957
  app.delete("/dictionary/:word", (c) => {
3628
3958
  const s = load();
3629
- removeCustomWord(s, c.req.param("word"));
3959
+ const word = c.req.param("word");
3960
+ removeCustomWord(s, word);
3630
3961
  persist(s);
3962
+ logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
3631
3963
  return c.json({ ok: true });
3632
3964
  });
3633
3965
  app.patch("/keys/:key", async (c) => {
3634
3966
  const key = c.req.param("key");
3635
3967
  const body = await c.req.json();
3636
3968
  const s = load();
3969
+ const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
3637
3970
  if (typeof body.rename === "string") renameKey(s, key, body.rename);
3638
3971
  const target = typeof body.rename === "string" ? body.rename : key;
3639
3972
  if (body.metadata) setMetadata(s, target, body.metadata);
3640
3973
  if (typeof body.source === "string") setSourceValue(s, target, body.source);
3641
3974
  if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
3642
3975
  persist(s);
3976
+ if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
3977
+ if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
3978
+ if (typeof body.source === "string") logChange({ kind: "translation", summary: `Set source value of ${target}`, key: target, locale: s.config.sourceLocale, before: beforeSource, after: body.source });
3979
+ if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
3643
3980
  if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
3644
3981
  return c.json({ ok: true });
3645
3982
  });
3646
3983
  function removeOrphanScreenshot(s, screenshot) {
3647
3984
  if (!screenshot) return;
3648
3985
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
3649
- const root = dirname5(resolve7(deps.statePath));
3650
- const abs = resolve7(root, screenshot);
3986
+ const root = dirname2(resolve9(deps.statePath));
3987
+ const abs = resolve9(root, screenshot);
3651
3988
  const rel = relative3(root, abs);
3652
3989
  const seg0 = rel.split(sep)[0] ?? "";
3653
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
3990
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
3654
3991
  try {
3655
- rmSync3(abs);
3992
+ rmSync4(abs);
3656
3993
  } catch {
3657
3994
  }
3658
3995
  }
@@ -3661,9 +3998,11 @@ function createApi(deps) {
3661
3998
  const s = load();
3662
3999
  const key = c.req.param("key");
3663
4000
  const shot = s.keys[key]?.screenshot;
4001
+ const before = valueText(s, key, s.config.sourceLocale);
3664
4002
  deleteKey(s, key);
3665
4003
  removeOrphanScreenshot(s, shot);
3666
4004
  persist(s);
4005
+ logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
3667
4006
  console.log(`[key] deleted ${key}`);
3668
4007
  return c.json({ ok: true });
3669
4008
  });
@@ -3687,6 +4026,7 @@ function createApi(deps) {
3687
4026
  }
3688
4027
  }
3689
4028
  persist(s);
4029
+ if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
3690
4030
  console.log(`[bulk] cleared ${cleared} value(s)`);
3691
4031
  return c.json({ cleared });
3692
4032
  });
@@ -3704,6 +4044,7 @@ function createApi(deps) {
3704
4044
  }
3705
4045
  for (const shot of shots) removeOrphanScreenshot(s, shot);
3706
4046
  persist(s);
4047
+ if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
3707
4048
  console.log(`[bulk] deleted ${removed.length} key(s)`);
3708
4049
  return c.json({ removed });
3709
4050
  });
@@ -3729,6 +4070,7 @@ function createApi(deps) {
3729
4070
  updated++;
3730
4071
  }
3731
4072
  persist(s);
4073
+ if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
3732
4074
  console.log(`[bulk] updated metadata on ${updated} key(s)`);
3733
4075
  return c.json({ updated });
3734
4076
  });
@@ -3752,6 +4094,7 @@ function createApi(deps) {
3752
4094
  }
3753
4095
  }
3754
4096
  persist(s);
4097
+ if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
3755
4098
  console.log(`[bulk] set state ${next} on ${updated} value(s)`);
3756
4099
  return c.json({ updated });
3757
4100
  });
@@ -3761,15 +4104,21 @@ function createApi(deps) {
3761
4104
  const s = load();
3762
4105
  const key = c.req.param("key");
3763
4106
  const locale = c.req.param("locale");
4107
+ const before = valueText(s, key, locale);
3764
4108
  if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
3765
4109
  else setTargetValue(s, key, locale, value);
3766
4110
  persist(s);
4111
+ logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
3767
4112
  return c.json({ ok: true });
3768
4113
  });
3769
4114
  app.delete("/keys/:key/values/:locale", (c) => {
3770
4115
  const s = load();
3771
- clearValue(s, c.req.param("key"), c.req.param("locale"));
4116
+ const key = c.req.param("key");
4117
+ const locale = c.req.param("locale");
4118
+ const before = valueText(s, key, locale);
4119
+ clearValue(s, key, locale);
3772
4120
  persist(s);
4121
+ logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
3773
4122
  return c.json({ ok: true });
3774
4123
  });
3775
4124
  app.put("/keys/:key/plural/:locale", async (c) => {
@@ -3778,52 +4127,68 @@ function createApi(deps) {
3778
4127
  const s = load();
3779
4128
  const key = c.req.param("key");
3780
4129
  const locale = c.req.param("locale");
4130
+ const before = s.keys[key]?.values[locale]?.forms;
3781
4131
  if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
3782
4132
  else setPluralForms(s, key, locale, forms);
3783
4133
  persist(s);
4134
+ logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
3784
4135
  return c.json({ ok: true });
3785
4136
  });
3786
4137
  app.post("/keys/:key/plural", async (c) => {
3787
4138
  const { arg } = await c.req.json();
3788
4139
  if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
3789
4140
  const s = load();
3790
- convertToPlural(s, c.req.param("key"), arg);
4141
+ const key = c.req.param("key");
4142
+ convertToPlural(s, key, arg);
3791
4143
  persist(s);
4144
+ logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
3792
4145
  return c.json({ ok: true });
3793
4146
  });
3794
4147
  app.delete("/keys/:key/plural", (c) => {
3795
4148
  const s = load();
3796
- convertToScalar(s, c.req.param("key"));
4149
+ const key = c.req.param("key");
4150
+ convertToScalar(s, key);
3797
4151
  persist(s);
4152
+ logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
3798
4153
  return c.json({ ok: true });
3799
4154
  });
3800
4155
  app.put("/keys/:key/values/:locale/state", async (c) => {
3801
4156
  const { state } = await c.req.json();
3802
4157
  const s = load();
3803
- setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
4158
+ const key = c.req.param("key");
4159
+ const locale = c.req.param("locale");
4160
+ const before = s.keys[key]?.values[locale]?.state;
4161
+ setKeyState(s, key, locale, state);
3804
4162
  persist(s);
4163
+ logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
3805
4164
  return c.json({ ok: true });
3806
4165
  });
3807
4166
  app.post("/keys/:key/notes", async (c) => {
3808
4167
  const { text } = await c.req.json();
3809
4168
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3810
4169
  const s = load();
3811
- const note = addNote(s, c.req.param("key"), text);
4170
+ const key = c.req.param("key");
4171
+ const note = addNote(s, key, text);
3812
4172
  persist(s);
4173
+ logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
3813
4174
  return c.json(note);
3814
4175
  });
3815
4176
  app.put("/keys/:key/notes/:id", async (c) => {
3816
4177
  const { text } = await c.req.json();
3817
4178
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3818
4179
  const s = load();
3819
- editNote(s, c.req.param("key"), c.req.param("id"), text);
4180
+ const key = c.req.param("key");
4181
+ editNote(s, key, c.req.param("id"), text);
3820
4182
  persist(s);
4183
+ logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
3821
4184
  return c.json({ ok: true });
3822
4185
  });
3823
4186
  app.delete("/keys/:key/notes/:id", (c) => {
3824
4187
  const s = load();
3825
- deleteNote(s, c.req.param("key"), c.req.param("id"));
4188
+ const key = c.req.param("key");
4189
+ deleteNote(s, key, c.req.param("id"));
3826
4190
  persist(s);
4191
+ logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
3827
4192
  return c.json({ ok: true });
3828
4193
  });
3829
4194
  app.put("/config", async (c) => {
@@ -3832,6 +4197,7 @@ function createApi(deps) {
3832
4197
  return c.json({ error: "config.locales must be an array" }, 400);
3833
4198
  }
3834
4199
  const s = load();
4200
+ const beforeCfg = { locales: s.config.locales };
3835
4201
  const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
3836
4202
  for (const l of removed) {
3837
4203
  for (const e of Object.values(s.keys)) delete e.values[l];
@@ -3839,7 +4205,8 @@ function createApi(deps) {
3839
4205
  s.config = newConfig;
3840
4206
  validate(s);
3841
4207
  persist(s);
3842
- console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
4208
+ logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
4209
+ console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
3843
4210
  return c.json({ ok: true });
3844
4211
  });
3845
4212
  app.get("/glossary", (c) => c.json(load().glossary));
@@ -3847,14 +4214,19 @@ function createApi(deps) {
3847
4214
  const entry = await c.req.json();
3848
4215
  if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
3849
4216
  const s = load();
4217
+ const before = s.glossary.find((g) => g.term === entry.term);
3850
4218
  upsertGlossaryEntry(s, entry);
3851
4219
  persist(s);
4220
+ logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
3852
4221
  return c.json({ ok: true });
3853
4222
  });
3854
4223
  app.delete("/glossary/:term", (c) => {
3855
4224
  const s = load();
3856
- deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
4225
+ const term = decodeURIComponent(c.req.param("term"));
4226
+ const before = s.glossary.find((g) => g.term === term);
4227
+ deleteGlossaryEntry(s, term);
3857
4228
  persist(s);
4229
+ logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
3858
4230
  return c.json({ ok: true });
3859
4231
  });
3860
4232
  app.post("/keys/:key/screenshot", async (c) => {
@@ -3862,18 +4234,18 @@ function createApi(deps) {
3862
4234
  const body = await c.req.parseBody();
3863
4235
  const file = body["file"];
3864
4236
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
3865
- const root = dirname5(resolve7(deps.statePath));
4237
+ const root = dirname2(resolve9(deps.statePath));
3866
4238
  const dirName = screenshotDirName(deps.statePath);
3867
- const dir = resolve7(root, dirName);
3868
- mkdirSync6(dir, { recursive: true });
4239
+ const dir = resolve9(root, dirName);
3869
4240
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
3870
- writeFileSync5(resolve7(dir, filename), Buffer.from(await file.arrayBuffer()));
4241
+ writeFileAtomic(resolve9(dir, filename), Buffer.from(await file.arrayBuffer()));
3871
4242
  const path = `${dirName}/${filename}`;
3872
4243
  const s = load();
3873
4244
  const prev = s.keys[key]?.screenshot;
3874
4245
  setMetadata(s, key, { screenshot: path });
3875
4246
  if (prev && prev !== path) removeOrphanScreenshot(s, prev);
3876
4247
  persist(s);
4248
+ logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
3877
4249
  return c.json({ path });
3878
4250
  });
3879
4251
  app.delete("/keys/:key/screenshot", (c) => {
@@ -3883,6 +4255,7 @@ function createApi(deps) {
3883
4255
  setMetadata(s, key, { screenshot: void 0 });
3884
4256
  removeOrphanScreenshot(s, shot);
3885
4257
  persist(s);
4258
+ logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
3886
4259
  return c.json({ ok: true });
3887
4260
  });
3888
4261
  app.get("/export/preview", (c) => {
@@ -3927,12 +4300,13 @@ function createApi(deps) {
3927
4300
  return c.json({ error: e.message }, 400);
3928
4301
  }
3929
4302
  persist(result.state);
4303
+ logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
3930
4304
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
3931
4305
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
3932
4306
  });
3933
4307
  app.post("/export", (c) => {
3934
4308
  const s = narrowForExport(load());
3935
- const root = dirname5(resolve7(deps.statePath));
4309
+ const root = dirname2(resolve9(deps.statePath));
3936
4310
  const warnings = [];
3937
4311
  let count = 0;
3938
4312
  for (const output of s.config.outputs) {
@@ -3940,9 +4314,8 @@ function createApi(deps) {
3940
4314
  const result = adapter.export(s, output);
3941
4315
  warnings.push(...result.warnings);
3942
4316
  for (const f of result.files) {
3943
- const abs = resolve7(root, f.path);
3944
- mkdirSync6(dirname5(abs), { recursive: true });
3945
- writeFileSync5(abs, f.contents, "utf8");
4317
+ const abs = resolve9(root, f.path);
4318
+ writeFileAtomic(abs, f.contents);
3946
4319
  count++;
3947
4320
  }
3948
4321
  }
@@ -3961,41 +4334,62 @@ function createApi(deps) {
3961
4334
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
3962
4335
  return;
3963
4336
  }
4337
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3964
4338
  let provider;
3965
4339
  try {
3966
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4340
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3967
4341
  } catch (e) {
3968
4342
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
3969
4343
  return;
3970
4344
  }
3971
4345
  const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
3972
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3973
- console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
4346
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4347
+ console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
3974
4348
  let totalWritten = 0;
3975
4349
  const allErrors = [];
3976
4350
  const system = buildSystemPrompt();
3977
4351
  const reqById = new Map(reqs.map((r) => [r.id, r]));
3978
- await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
3979
- const { written, errors } = applyResults(s, reqs, batchResults);
3980
- persist(s);
3981
- totalWritten += written;
3982
- allErrors.push(...errors);
3983
- appendAiLog(projectRoot, {
3984
- at: (/* @__PURE__ */ new Date()).toISOString(),
3985
- kind: "translate",
3986
- model: s.config.ai.model,
3987
- system,
3988
- items: batchResults.map((r) => {
3989
- const req = reqById.get(r.id);
3990
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
3991
- }),
3992
- results: batchResults
3993
- });
3994
- console.log(`[translate] ${done}/${total}`);
3995
- void stream.writeSSE({
3996
- event: "progress",
3997
- data: JSON.stringify({ done, total, written: totalWritten, errors })
3998
- });
4352
+ const localeTotals = /* @__PURE__ */ new Map();
4353
+ for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
4354
+ const localeDone = /* @__PURE__ */ new Map();
4355
+ await stream.writeSSE({
4356
+ event: "start",
4357
+ data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
4358
+ });
4359
+ await runLocaleParallel(reqs, provider, {
4360
+ // Announce a language the moment a worker picks it up — this is the
4361
+ // signal that "something is happening" during the long first LLM call.
4362
+ onLocaleStart: (locale) => {
4363
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
4364
+ },
4365
+ onBatchComplete: (done, total, batchResults, locale) => {
4366
+ const { written, errors } = applyResults(s, reqs, batchResults);
4367
+ persist(s);
4368
+ totalWritten += written;
4369
+ allErrors.push(...errors);
4370
+ appendLog(projectRoot, {
4371
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4372
+ kind: "translate",
4373
+ summary: `Translated ${batchResults.length} item(s)`,
4374
+ model: aiCfg.model,
4375
+ system,
4376
+ items: batchResults.map((r) => {
4377
+ const req = reqById.get(r.id);
4378
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4379
+ }),
4380
+ results: batchResults
4381
+ });
4382
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
4383
+ localeDone.set(locale, ld);
4384
+ console.log(`[translate] ${done}/${total}`);
4385
+ void stream.writeSSE({
4386
+ event: "progress",
4387
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
4388
+ });
4389
+ },
4390
+ onLocaleDone: (locale) => {
4391
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4392
+ }
3999
4393
  }, void 0, signal);
4000
4394
  if (!signal?.aborted) {
4001
4395
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
@@ -4018,19 +4412,22 @@ function createApi(deps) {
4018
4412
  let written = 0;
4019
4413
  let errors = [];
4020
4414
  if (toTranslate.length) {
4415
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4021
4416
  let provider;
4022
4417
  try {
4023
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4418
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4024
4419
  } catch (e) {
4025
4420
  return c.json({ error: e.message }, 400);
4026
4421
  }
4027
4422
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4028
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4423
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4029
4424
  const results = await runLocaleParallel(toTranslate, provider);
4030
4425
  ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
4031
4426
  const entry = {
4032
4427
  at: (/* @__PURE__ */ new Date()).toISOString(),
4033
- model: s.config.ai.model,
4428
+ kind: "translate",
4429
+ summary: `Translated ${toTranslate.length} item(s)`,
4430
+ model: aiCfg.model,
4034
4431
  system: buildSystemPrompt(),
4035
4432
  // Log the screenshot PATH only — never the image bytes.
4036
4433
  items: toTranslate.map((r) => ({
@@ -4044,12 +4441,12 @@ function createApi(deps) {
4044
4441
  })),
4045
4442
  results
4046
4443
  };
4047
- appendAiLog(projectRoot, entry);
4444
+ appendLog(projectRoot, entry);
4048
4445
  }
4049
4446
  persist(s);
4050
4447
  return c.json({ requested: reqs.length, written, errors });
4051
4448
  }));
4052
- app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
4449
+ app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4053
4450
  app.post("/scan", async (c) => {
4054
4451
  const s = load();
4055
4452
  const existing = loadUsageCache(projectRoot);
@@ -4073,7 +4470,7 @@ function createApi(deps) {
4073
4470
  const refs = [];
4074
4471
  const prefixRefs = [];
4075
4472
  for (const [file, entry] of Object.entries(cache2.files)) {
4076
- const abs = resolve7(projectRoot, file);
4473
+ const abs = resolve9(projectRoot, file);
4077
4474
  for (const r of entry.refs) {
4078
4475
  if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
4079
4476
  }
@@ -4114,9 +4511,10 @@ function createApi(deps) {
4114
4511
  keys: body.keys
4115
4512
  }, cache2, body.lastRunAt);
4116
4513
  if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4514
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4117
4515
  let provider;
4118
4516
  try {
4119
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4517
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4120
4518
  } catch (e) {
4121
4519
  return c.json({ error: e.message }, 400);
4122
4520
  }
@@ -4138,10 +4536,11 @@ function createApi(deps) {
4138
4536
  const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4139
4537
  const batch = raw;
4140
4538
  const { written, errors } = applyContext(s, targets, batch.items ?? []);
4141
- appendAiLog(projectRoot, {
4539
+ appendLog(projectRoot, {
4142
4540
  at: (/* @__PURE__ */ new Date()).toISOString(),
4143
4541
  kind: "context",
4144
- model: s.config.ai.model,
4542
+ summary: `Generated context for ${targets.length} key(s)`,
4543
+ model: aiCfg.model,
4145
4544
  system,
4146
4545
  items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4147
4546
  results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
@@ -4173,6 +4572,9 @@ var init_api = __esm({
4173
4572
  init_schema();
4174
4573
  init_run2();
4175
4574
  init_export_run();
4575
+ init_ui_prefs();
4576
+ init_local_settings();
4577
+ init_atomic_write();
4176
4578
  sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
4177
4579
  screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
4178
4580
  }
@@ -4187,7 +4589,7 @@ __export(server_exports, {
4187
4589
  import { Hono as Hono2 } from "hono";
4188
4590
  import { serve } from "@hono/node-server";
4189
4591
  import { fileURLToPath } from "url";
4190
- import { dirname as dirname6, join as join7, resolve as resolve8, extname as extname3, sep as sep2 } from "path";
4592
+ import { dirname as dirname3, join as join9, resolve as resolve10, extname as extname3, sep as sep2 } from "path";
4191
4593
  import { readFile, stat } from "fs/promises";
4192
4594
  import { createServer } from "net";
4193
4595
  import open from "open";
@@ -4205,14 +4607,14 @@ async function readFileResponse(absPath) {
4205
4607
  function buildApp(opts) {
4206
4608
  const app = new Hono2();
4207
4609
  app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
4208
- const projectRoot = dirname6(resolve8(opts.statePath));
4610
+ const projectRoot = dirname3(resolve10(opts.statePath));
4209
4611
  app.get("/:dir/*", async (c, next) => {
4210
4612
  const dirSeg = c.req.param("dir");
4211
4613
  if (!dirSeg.endsWith("-screenshots")) return next();
4212
- const shotsRoot = resolve8(projectRoot, dirSeg);
4614
+ const shotsRoot = resolve10(projectRoot, dirSeg);
4213
4615
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4214
4616
  const rest = pathname.slice(`/${dirSeg}`.length);
4215
- const target = resolve8(shotsRoot, "." + rest);
4617
+ const target = resolve10(shotsRoot, "." + rest);
4216
4618
  const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
4217
4619
  if (inside) {
4218
4620
  const file = await readFileResponse(target);
@@ -4221,19 +4623,21 @@ function buildApp(opts) {
4221
4623
  return c.notFound();
4222
4624
  });
4223
4625
  if (!opts.dev) {
4224
- const root = resolve8(opts.uiDir ?? DEFAULT_UI_DIR);
4626
+ const root = resolve10(opts.uiDir ?? DEFAULT_UI_DIR);
4225
4627
  app.get("/*", async (c) => {
4226
4628
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4227
- const target = resolve8(root, "." + pathname);
4629
+ const target = resolve10(root, "." + pathname);
4228
4630
  const inside = target === root || target.startsWith(root + sep2);
4229
4631
  if (inside && pathname !== "/") {
4230
4632
  const file = await readFileResponse(target);
4231
4633
  if (file) return file;
4232
4634
  }
4233
- const index = await readFileResponse(join7(root, "index.html"));
4635
+ const index = await readFileResponse(join9(root, "index.html"));
4234
4636
  if (index) return index;
4235
4637
  return c.notFound();
4236
4638
  });
4639
+ } else {
4640
+ app.get("/", (c) => c.html(DEV_LANDING_PAGE));
4237
4641
  }
4238
4642
  return app;
4239
4643
  }
@@ -4265,7 +4669,7 @@ async function startServer(opts) {
4265
4669
  });
4266
4670
  }
4267
4671
  function backgroundScan(statePath) {
4268
- const projectRoot = dirname6(resolve8(statePath));
4672
+ const projectRoot = dirname3(resolve10(statePath));
4269
4673
  Promise.resolve().then(() => {
4270
4674
  const state = loadState(statePath);
4271
4675
  const existing = loadUsageCache(projectRoot);
@@ -4277,7 +4681,7 @@ function backgroundScan(statePath) {
4277
4681
  console.warn("[scan] failed:", err instanceof Error ? err.message : String(err));
4278
4682
  });
4279
4683
  }
4280
- var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT;
4684
+ var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT, DEV_UI_URL, DEV_LANDING_PAGE;
4281
4685
  var init_server = __esm({
4282
4686
  "src/server/server.ts"() {
4283
4687
  "use strict";
@@ -4285,8 +4689,8 @@ var init_server = __esm({
4285
4689
  init_state();
4286
4690
  init_scan();
4287
4691
  init_scanner();
4288
- here = dirname6(fileURLToPath(import.meta.url));
4289
- DEFAULT_UI_DIR = join7(here, "..", "ui");
4692
+ here = dirname3(fileURLToPath(import.meta.url));
4693
+ DEFAULT_UI_DIR = join9(here, "..", "ui");
4290
4694
  MIME = {
4291
4695
  ".html": "text/html; charset=utf-8",
4292
4696
  ".js": "text/javascript; charset=utf-8",
@@ -4303,6 +4707,15 @@ var init_server = __esm({
4303
4707
  };
4304
4708
  DEFAULT_PORT = 3e3;
4305
4709
  DEV_PORT = 8787;
4710
+ DEV_UI_URL = "http://localhost:5173";
4711
+ DEV_LANDING_PAGE = `<!doctype html>
4712
+ <html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
4713
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:16vh auto;padding:0 1.5rem;color:#1f2937}h1{font-size:1.4rem}a{color:#2563eb}code{background:#f3f4f6;padding:.1em .35em;border-radius:.3em}</style>
4714
+ </head><body>
4715
+ <h1>Glotfile \u2014 dev API server</h1>
4716
+ <p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
4717
+ <p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
4718
+ </body></html>`;
4306
4719
  }
4307
4720
  });
4308
4721
 
@@ -4311,14 +4724,15 @@ init_state();
4311
4724
  init_export_run();
4312
4725
  init_storage();
4313
4726
  init_ai();
4727
+ init_local_settings();
4314
4728
  init_run();
4315
4729
  init_provider();
4316
4730
  init_log();
4317
4731
  init_scan();
4318
4732
  init_scanner();
4319
4733
  init_context();
4320
- import { resolve as resolve9, dirname as dirname7 } from "path";
4321
- import { readFileSync as readFileSync13, existsSync as existsSync10 } from "fs";
4734
+ import { resolve as resolve11, dirname as dirname4 } from "path";
4735
+ import { readFileSync as readFileSync15, existsSync as existsSync11 } from "fs";
4322
4736
 
4323
4737
  // src/server/lint/run.ts
4324
4738
  init_glob();
@@ -4620,15 +5034,15 @@ async function runLint(state, options = {}) {
4620
5034
 
4621
5035
  // src/server/lint/outputs.ts
4622
5036
  init_adapters();
4623
- import { readFileSync as readFileSync9, existsSync as existsSync7 } from "fs";
4624
- import { resolve as resolve6 } from "path";
5037
+ import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5038
+ import { resolve as resolve8 } from "path";
4625
5039
  function checkOutputs(state, root) {
4626
5040
  const out = [];
4627
5041
  for (const output of state.config.outputs) {
4628
5042
  const result = getAdapter(output.adapter).export(state, output);
4629
5043
  for (const file of result.files) {
4630
- const abs = resolve6(root, file.path);
4631
- const current = existsSync7(abs) ? readFileSync9(abs, "utf8") : null;
5044
+ const abs = resolve8(root, file.path);
5045
+ const current = existsSync8(abs) ? readFileSync10(abs, "utf8") : null;
4632
5046
  if (current === null) {
4633
5047
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4634
5048
  } else if (current !== file.contents) {
@@ -4706,7 +5120,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
4706
5120
  var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split"];
4707
5121
  var isCommand = (s) => s != null && COMMANDS.includes(s);
4708
5122
  function parseArgs(argv) {
4709
- const statePath = resolve9(process.cwd(), "glotfile.json");
5123
+ const statePath = resolve11(process.cwd(), "glotfile.json");
4710
5124
  const first = argv[0];
4711
5125
  if (first === "help" || first === "--help" || first === "-h") {
4712
5126
  return isCommand(argv[1]) ? { command: argv[1], statePath, help: true } : { command: "help", statePath };
@@ -4724,7 +5138,7 @@ function parseArgs(argv) {
4724
5138
  if (flag === "--help" || flag === "-h") args.help = true;
4725
5139
  else if (flag === "--dev") args.dev = true;
4726
5140
  else if ((flag === "--file" || flag === "-f") && next) {
4727
- args.statePath = resolve9(process.cwd(), next);
5141
+ args.statePath = resolve11(process.cwd(), next);
4728
5142
  i++;
4729
5143
  } else if (flag === "--adapter" && next) {
4730
5144
  args.adapter = next;
@@ -4780,7 +5194,7 @@ function watchTargetFor(statePath) {
4780
5194
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
4781
5195
  }
4782
5196
  async function runExport(args) {
4783
- const root = dirname7(resolve9(args.statePath));
5197
+ const root = dirname4(resolve11(args.statePath));
4784
5198
  const runOnce = () => {
4785
5199
  const state = loadState(args.statePath);
4786
5200
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -4816,7 +5230,7 @@ async function runExport(args) {
4816
5230
  }
4817
5231
  async function runTranslate(args) {
4818
5232
  const state = loadState(args.statePath);
4819
- const projectRoot = dirname7(resolve9(args.statePath));
5233
+ const projectRoot = dirname4(resolve11(args.statePath));
4820
5234
  const reqs = selectRequests(state, {
4821
5235
  // Default to translating only empty values; --all forces a full re-translate
4822
5236
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -4828,33 +5242,38 @@ async function runTranslate(args) {
4828
5242
  let written = 0;
4829
5243
  let errors = [];
4830
5244
  if (toTranslate.length) {
5245
+ const ai = loadLocalSettings(projectRoot).ai;
4831
5246
  let provider;
4832
5247
  try {
4833
- provider = makeProvider(state.config);
5248
+ provider = makeProvider(ai);
4834
5249
  } catch (e) {
4835
5250
  console.error(e.message);
4836
5251
  process.exitCode = 1;
4837
5252
  return;
4838
5253
  }
4839
5254
  const { skipped } = attachScreenshotsForProvider(toTranslate, state, projectRoot, provider.supportsVision());
4840
- if (skipped) console.warn(`Model "${state.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
5255
+ if (skipped) console.warn(`Model "${ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4841
5256
  console.log(`Translating ${toTranslate.length} string(s)\u2026`);
4842
5257
  let batchCallbackFired = false;
4843
- const results = await runLocaleParallel(toTranslate, provider, (done, total, batchResults) => {
4844
- batchCallbackFired = true;
4845
- const batchApplied = applyResults(state, toTranslate, batchResults);
4846
- written += batchApplied.written;
4847
- errors.push(...batchApplied.errors);
4848
- saveState(args.statePath, state);
4849
- process.stdout.write(`\r ${done}/${total} translated`);
5258
+ const results = await runLocaleParallel(toTranslate, provider, {
5259
+ onBatchComplete: (done, total, batchResults) => {
5260
+ batchCallbackFired = true;
5261
+ const batchApplied = applyResults(state, toTranslate, batchResults);
5262
+ written += batchApplied.written;
5263
+ errors.push(...batchApplied.errors);
5264
+ saveState(args.statePath, state);
5265
+ process.stdout.write(`\r ${done}/${total} translated`);
5266
+ }
4850
5267
  });
4851
5268
  process.stdout.write("\n");
4852
5269
  if (!batchCallbackFired) {
4853
5270
  ({ written, errors } = applyResults(state, toTranslate, results));
4854
5271
  }
4855
- appendAiLog(projectRoot, {
5272
+ appendLog(projectRoot, {
4856
5273
  at: (/* @__PURE__ */ new Date()).toISOString(),
4857
- model: state.config.ai.model,
5274
+ kind: "translate",
5275
+ summary: `Translated ${toTranslate.length} item(s)`,
5276
+ model: ai.model,
4858
5277
  system: buildSystemPrompt(),
4859
5278
  items: toTranslate.map((r) => ({
4860
5279
  id: r.id,
@@ -4881,7 +5300,7 @@ function printReport(report, format, rawText) {
4881
5300
  }
4882
5301
  async function runLintCmd(args) {
4883
5302
  const state = loadState(args.statePath);
4884
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
5303
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
4885
5304
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
4886
5305
  printReport(report, args.format, rawText);
4887
5306
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -4901,8 +5320,8 @@ async function runCheck(args) {
4901
5320
  process.exitCode = 1;
4902
5321
  return;
4903
5322
  }
4904
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
4905
- const root = dirname7(resolve9(args.statePath));
5323
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5324
+ const root = dirname4(resolve11(args.statePath));
4906
5325
  const lint = await runLint(state, {});
4907
5326
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
4908
5327
  const counts = countSeverities(findings);
@@ -4912,9 +5331,9 @@ async function runCheck(args) {
4912
5331
  }
4913
5332
  async function runImportCmd(args) {
4914
5333
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
4915
- const projectRoot = args.importSource ? resolve9(args.importSource) : dirname7(resolve9(args.statePath));
4916
- const out = resolve9(projectRoot, "glotfile.json");
4917
- if (existsSync10(out) && !args.importForce) {
5334
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname4(resolve11(args.statePath));
5335
+ const out = resolve11(projectRoot, "glotfile.json");
5336
+ if (existsSync11(out) && !args.importForce) {
4918
5337
  console.error(`${out} already exists; pass --force to overwrite`);
4919
5338
  process.exitCode = 1;
4920
5339
  return;
@@ -4939,7 +5358,7 @@ async function runImportCmd(args) {
4939
5358
  }
4940
5359
  async function runBuildContext(args) {
4941
5360
  const state = loadState(args.statePath);
4942
- const projectRoot = dirname7(resolve9(args.statePath));
5361
+ const projectRoot = dirname4(resolve11(args.statePath));
4943
5362
  const cache2 = loadUsageCache(projectRoot);
4944
5363
  if (!cache2) {
4945
5364
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -4958,7 +5377,7 @@ async function runBuildContext(args) {
4958
5377
  }
4959
5378
  let provider;
4960
5379
  try {
4961
- provider = makeProvider(state.config);
5380
+ provider = makeProvider(loadLocalSettings(projectRoot).ai);
4962
5381
  } catch (e) {
4963
5382
  console.error(e.message);
4964
5383
  process.exitCode = 1;
@@ -4988,7 +5407,7 @@ async function runBuildContext(args) {
4988
5407
  }
4989
5408
  async function runScanCmd(args) {
4990
5409
  const state = loadState(args.statePath);
4991
- const projectRoot = dirname7(resolve9(args.statePath));
5410
+ const projectRoot = dirname4(resolve11(args.statePath));
4992
5411
  const existing = loadUsageCache(projectRoot);
4993
5412
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
4994
5413
  const fileCount2 = Object.keys(result.files).length;
@@ -5007,7 +5426,7 @@ async function runPrune(args) {
5007
5426
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5008
5427
  }
5009
5428
  if (args.unused) {
5010
- const projectRoot = dirname7(resolve9(args.statePath));
5429
+ const projectRoot = dirname4(resolve11(args.statePath));
5011
5430
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5012
5431
  const used = new Set(computeUsedKeys(state, cache2));
5013
5432
  for (const k of Object.keys(state.keys)) {
@@ -5174,9 +5593,10 @@ async function main(argv) {
5174
5593
  if (args.command === "split") return runSplit(args);
5175
5594
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
5176
5595
  const { url } = await startServer2({ statePath: args.statePath, dev: args.dev });
5177
- console.log(`Glotfile running at ${url}`);
5596
+ if (args.dev) console.log(`Glotfile dev API on ${url} \u2014 open the UI at the Vite "Local:" URL above`);
5597
+ else console.log(`Glotfile running at ${url}`);
5178
5598
  }
5179
- if (resolve9(process.argv[1] ?? "") === resolve9(fileURLToPath2(import.meta.url))) {
5599
+ if (resolve11(process.argv[1] ?? "") === resolve11(fileURLToPath2(import.meta.url))) {
5180
5600
  main(process.argv.slice(2)).catch((err) => {
5181
5601
  console.error(err instanceof Error ? err.message : String(err));
5182
5602
  process.exit(1);