zigmini 0.8.0-beta.6 → 0.9.0-beta.1

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 (62) hide show
  1. package/README.ko.md +90 -19
  2. package/README.md +91 -19
  3. package/assets/agent/gateway-request-rules.md +3 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +69 -1
  9. package/docs/manual.md +67 -1
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  20. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  21. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  22. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  23. package/package.json +4 -1
  24. package/scripts/secret-rules.ts +101 -0
  25. package/scripts/secret-scan.allow.json +155 -0
  26. package/scripts/secret-scan.rules.json +30 -0
  27. package/src/agent-request-rules.ts +82 -0
  28. package/src/cli/access.ts +31 -34
  29. package/src/cli/launchd.ts +30 -4
  30. package/src/cli/main.ts +3 -43
  31. package/src/cli/messages.ts +160 -0
  32. package/src/cli/pair.ts +32 -25
  33. package/src/cli/setup.ts +164 -75
  34. package/src/decisions.ts +48 -10
  35. package/src/engine/compat.json +3 -3
  36. package/src/hub.ts +183 -47
  37. package/src/main.ts +60 -4
  38. package/src/notification-history.ts +56 -0
  39. package/src/omo/client.ts +28 -0
  40. package/src/protocol.ts +32 -2
  41. package/src/reply-notifications.ts +144 -0
  42. package/src/runtime/paths.ts +2 -0
  43. package/src/security-allow.ts +126 -0
  44. package/src/security-check.ts +559 -0
  45. package/src/store.ts +236 -6
  46. package/src/turn-stats.ts +49 -0
  47. package/src/work-watch.ts +36 -10
  48. package/src/work.ts +45 -6
  49. package/web/DESIGN.md +39 -0
  50. package/web/app.js +289 -2
  51. package/web/decisions.d.ts +1 -1
  52. package/web/decisions.js +6 -0
  53. package/web/diagram.d.ts +28 -0
  54. package/web/diagram.js +228 -0
  55. package/web/index.html +29 -0
  56. package/web/notifications.d.ts +25 -0
  57. package/web/notifications.js +118 -0
  58. package/web/security-inbox.d.ts +35 -0
  59. package/web/security-inbox.js +237 -0
  60. package/web/style.css +43 -1
  61. package/web/turn-stats.d.ts +26 -0
  62. package/web/turn-stats.js +58 -0
package/src/cli/pair.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
2
+ import { type Language, message } from "./messages.ts";
2
3
 
3
4
  /**
4
- * `zigmini pair` and the last step of `zigmini setup` (plan Todo 15): the phone enrollment QR drawn in the terminal with
5
+ * `zigmini pair` (plan Todo 15): the phone enrollment QR drawn in the terminal with
5
6
  * the same vendored qrcode-generator the web settings use. The token stays inside the QR; no text here carries it.
6
7
  * The vendored library is loaded on demand so that `src/` keeps importing without `web/` (the mutation runs copy only `src/`).
7
8
  */
@@ -9,12 +10,12 @@ import type { QRCodeFactory } from "../../web/vendor/qrcode.js";
9
10
  export const PAIR_CREATED_BY = "cli";
10
11
  export const QR_MARGIN = 4;
11
12
  export const PAIR_ERRORS = {
12
- gateway_down: "게이트웨이에 연결하지 못했습니다. `zigmini start`로 시작한 뒤 다시 하세요.",
13
- access_url_unknown: "접속 주소를 알 수 없습니다. 맥이 Wi-Fi에 연결되어 있는지 확인하세요.",
14
- bad_response: "게이트웨이가 알 수 없는 응답을 보냈습니다.",
15
- apk_unavailable: "이 설치에는 내려받을 앱(APK)이 없습니다.",
13
+ gateway_down: message("ko", "gatewayDown"),
14
+ access_url_unknown: message("ko", "accessGuidance"),
15
+ bad_response: message("ko", "badResponse"),
16
+ apk_unavailable: message("ko", "apkUnavailable"),
16
17
  } as const;
17
- export const APK_INSTALL_HINT = "출처를 알 수 없는 앱 설치 허가가 필요합니다";
18
+ export const APK_INSTALL_HINT = message("ko", "apkPermission");
18
19
  export type PairErrorReason = keyof typeof PAIR_ERRORS | `http_${number}`;
19
20
 
20
21
  export class PairError extends Error {
@@ -32,6 +33,7 @@ export interface Enrollment {
32
33
  export type FetchLike = (url: string, init: RequestInit) => Promise<{ readonly status: number; json(): Promise<unknown> }>;
33
34
 
34
35
  export interface PairDeps {
36
+ readonly lang?: Language;
35
37
  readonly fetch: FetchLike;
36
38
  readonly webPort: number;
37
39
  readonly now: () => number;
@@ -78,22 +80,27 @@ export function minutesLeft(expiresAt: number, now: number): number {
78
80
  }
79
81
 
80
82
  /** The terminal text: caption, QR and the address. It never contains the token or the payload. */
81
- export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number): string {
82
- return `폰 등록 QR (${minutesLeft(enrollment.expiresAt, now)}분 안에 한 번만 쓸 수 있음)\n${qrAnsi(qrcode, enrollment.payload)}`
83
- + `접속 주소: ${enrollment.url}\n폰의 지그미니 앱에서 QR을 찍으세요. 만료되면 \`zigmini pair\`로 새 QR을 만듭니다.\n`;
83
+ export function enrollmentText(qrcode: QRCodeFactory, enrollment: Enrollment, now: number, lang: Language = "ko"): string {
84
+ return message(lang, "enrollmentText", { minutes: minutesLeft(enrollment.expiresAt, now), qr: qrAnsi(qrcode, enrollment.payload), url: enrollment.url });
84
85
  }
85
86
 
86
- function failureReason(error: unknown): string {
87
+ function failureReason(error: unknown, lang: Language): string {
87
88
  const reason = error instanceof PairError ? error.reason : error instanceof Error ? error.message : String(error);
88
- return (PAIR_ERRORS as Record<string, string>)[reason] ?? `게이트웨이 응답 ${reason}`;
89
+ switch (reason) {
90
+ case "gateway_down": return message(lang, "gatewayDown");
91
+ case "access_url_unknown": return message(lang, "accessGuidance");
92
+ case "bad_response": return message(lang, "badResponse");
93
+ case "apk_unavailable": return message(lang, "apkUnavailable");
94
+ default: return message(lang, "gatewayResponse", { reason });
95
+ }
89
96
  }
90
97
 
91
- export function pairFailureLine(error: unknown): string {
92
- return `폰 등록 QR을 만들지 못했습니다: ${failureReason(error)}`;
98
+ export function pairFailureLine(error: unknown, lang: Language = "ko"): string {
99
+ return message(lang, "pairFailed", { reason: failureReason(error, lang) });
93
100
  }
94
101
 
95
- export function apkFailureLine(error: unknown): string {
96
- return `앱 내려받기 QR을 만들지 못했습니다: ${failureReason(error)}`;
102
+ export function apkFailureLine(error: unknown, lang: Language = "ko"): string {
103
+ return message(lang, "apkFailed", { reason: failureReason(error, lang) });
97
104
  }
98
105
 
99
106
  export interface ApkLink {
@@ -122,33 +129,33 @@ export async function mintApkLink(fetchFn: FetchLike, webPort: number): Promise<
122
129
  }
123
130
 
124
131
  /** The terminal text for the APK QR: caption, QR, the address without the link token, and the install permission hint. */
125
- export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number): string {
126
- return `앱 내려받기 QR (지그미니 ${link.versionName}, ${minutesLeft(link.expiresAt, now)}분 동안 쓸 수 있음)\n${qrAnsi(qrcode, link.url)}`
127
- + `접속 주소: ${new URL(link.url).origin}\n폰 카메라로 QR을 찍어 APK를 받으세요. ${APK_INSTALL_HINT}. 만료되면 \`zigmini pair --apk\`로 새 QR을 만듭니다.\n`;
132
+ export function apkLinkText(qrcode: QRCodeFactory, link: ApkLink, now: number, lang: Language = "ko"): string {
133
+ return message(lang, "apkText", { version: link.versionName, minutes: minutesLeft(link.expiresAt, now), qr: qrAnsi(qrcode, link.url), url: new URL(link.url).origin, permission: message(lang, "apkPermission") });
128
134
  }
129
135
 
130
- export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
136
+ export async function apkQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
131
137
  const link = await mintApkLink(fetchFn, webPort);
132
- return apkLinkText(await loadQrcode(), link, now());
138
+ return apkLinkText(await loadQrcode(), link, now(), lang);
133
139
  }
134
140
 
135
141
  async function loadQrcode(): Promise<QRCodeFactory> {
136
142
  return (await import("../../web/vendor/qrcode.js")).default;
137
143
  }
138
144
 
139
- /** The QR text for setup's last step and `zigmini pair`; throws PairError when the gateway cannot give a token. */
140
- export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number): Promise<string> {
145
+ /** The QR text for `zigmini pair`; throws PairError when the gateway cannot give a token. */
146
+ export async function enrollmentQr(fetchFn: FetchLike, webPort: number, now: () => number, lang: Language = "ko"): Promise<string> {
141
147
  const enrollment = await mintEnrollment(fetchFn, webPort);
142
- return enrollmentText(await loadQrcode(), enrollment, now());
148
+ return enrollmentText(await loadQrcode(), enrollment, now(), lang);
143
149
  }
144
150
 
145
151
  /** `zigmini pair` draws the enrollment QR; `zigmini pair --apk` draws the APK download QR instead. */
146
152
  export async function pairCommand(deps: PairDeps, apk = false): Promise<number> {
153
+ const lang = deps.lang ?? "ko";
147
154
  try {
148
- deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now) : await enrollmentQr(deps.fetch, deps.webPort, deps.now));
155
+ deps.out(apk ? await apkQr(deps.fetch, deps.webPort, deps.now, lang) : await enrollmentQr(deps.fetch, deps.webPort, deps.now, lang));
149
156
  return 0;
150
157
  } catch (error) {
151
- deps.err(`${apk ? apkFailureLine(error) : pairFailureLine(error)}\n`);
158
+ deps.err(`${apk ? apkFailureLine(error, lang) : pairFailureLine(error, lang)}\n`);
152
159
  return 1;
153
160
  }
154
161
  }
package/src/cli/setup.ts CHANGED
@@ -2,16 +2,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { engine } from "../engine/index.ts";
5
+ import { engine, ENGINE_PACKAGE } from "../engine/index.ts";
6
6
  import { installEngine } from "../engine/install.ts";
7
7
  import { ensureInstallRules, INSTALL_RULES_FILE } from "../agent-rules.ts";
8
- import { DECLINED_MESSAGE, ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
8
+ import { ensureConsent, EXIT_DECLINED, loadNotice, type PermissionNotice } from "../consent/consent.ts";
9
9
  import { type InstallKind, installKind } from "../runtime/install-kind.ts";
10
10
  import { agentDir as defaultAgentDir, providerCount } from "../model-accounts.ts";
11
11
  import type { Env, RuntimePaths } from "../runtime/paths.ts";
12
12
  import { writeVoiceSetting } from "../voice-setting.ts";
13
13
  import { parseConfigEnv, startCommand, systemContext, systemDeps } from "./launchd.ts";
14
- import { apkFailureLine, apkQr, enrollmentQr, pairFailureLine } from "./pair.ts";
14
+ import { isLanguage, LANGUAGE_QUESTION, type Language, localize, message, resolveLanguage, saveLanguage } from "./messages.ts";
15
15
  import { SYSTEM_NETWORK, type Network } from "../enroll.ts";
16
16
  import {
17
17
  ACCESS_CHOICES, type AccessChoice, type AccessRecord, appPortEnv, isAccessChoice, parseDomain, pingAccess, type PingResult, proxyGuide, readAccess,
@@ -38,21 +38,24 @@ export interface SetupOptions {
38
38
  readonly domain?: string;
39
39
  readonly notionTokenFile?: string;
40
40
  readonly agentDir?: string;
41
+ readonly lang?: Language;
41
42
  }
42
43
 
43
44
  export interface Platform { readonly platform: string; readonly arch: string; readonly bunVersion: string }
44
45
 
45
46
  export interface SetupDeps {
47
+ readonly lang?: Language;
46
48
  readonly platform: Platform;
47
49
  readonly env: Env;
48
50
  readonly paths: RuntimePaths;
49
51
  readonly bunPath: string;
50
52
  readonly installEngine: (options: { home: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
53
+ readonly globalOmoVersion: () => Promise<string | null>;
54
+ readonly installGlobalOmo: (version: string) => Promise<void>;
55
+ /** The executable checked or installed above; injected installers may keep the plain omo command. */
56
+ readonly globalOmoCommand?: () => string;
57
+ readonly providerCount: (agent: string) => number;
51
58
  readonly start: () => Promise<number>;
52
- /** The phone enrollment QR text after the services are up (plan Todo 15); rejects when the gateway gives no token. */
53
- readonly enrollQr: () => Promise<string>;
54
- /** The app download QR shown before the enrollment QR (plan Todo 20); rejects when the gateway serves no APK. */
55
- readonly apkQr: () => Promise<string>;
56
59
  readonly prompt: (question: string) => Promise<string | null>;
57
60
  readonly network: Network;
58
61
  readonly tailscale: () => TailscaleRun;
@@ -67,10 +70,10 @@ export interface SetupDeps {
67
70
  readonly err: (text: string) => void;
68
71
  }
69
72
 
70
- const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir" } as const;
73
+ const VALUE_FLAGS = { "--access": "access", "--domain": "domain", "--notion-token-file": "notionTokenFile", "--agent-dir": "agentDir", "--lang": "lang" } as const;
71
74
  const BOOLEAN_FLAGS = { "--non-interactive": "nonInteractive", "--accept-license": "acceptLicense", "--engine-only": "engineOnly" } as const;
72
75
 
73
- export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
76
+ export function parseSetupArgs(argv: readonly string[], lang: Language = "en"): SetupOptions | string {
74
77
  const options: Record<string, string | boolean> = { nonInteractive: false, acceptLicense: false, engineOnly: false };
75
78
  for (let i = 0; i < argv.length; i++) {
76
79
  const flag = argv[i]!;
@@ -78,18 +81,19 @@ export function parseSetupArgs(argv: readonly string[]): SetupOptions | string {
78
81
  options[BOOLEAN_FLAGS[flag as keyof typeof BOOLEAN_FLAGS]] = true;
79
82
  continue;
80
83
  }
81
- if (!(flag in VALUE_FLAGS)) return `알 수 없는 옵션: ${flag}`;
84
+ if (!(flag in VALUE_FLAGS)) return message(lang, "unknownOption", { flag });
82
85
  const value = argv[i + 1];
83
- if (value === undefined || value === "" || value.startsWith("--")) return `${flag} 에 값이 필요합니다`;
84
- if (/[\0\n\r]/.test(value)) return `${flag} 값에 줄바꿈이나 NUL 문자를 쓸 수 없습니다`;
86
+ if (value === undefined || value === "" || value.startsWith("--")) return message(lang, "missingValue", { flag });
87
+ if (/[\0\n\r]/.test(value)) return message(lang, "badValue", { flag });
85
88
  options[VALUE_FLAGS[flag as keyof typeof VALUE_FLAGS]] = value;
86
89
  i++;
87
90
  }
88
- if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return `--access 는 ${ACCESS_CHOICES.join(", ")} 중 하나입니다`;
89
- if (options.domain !== undefined && options.access !== "domain") return "--domain 은 --access domain 과 함께 씁니다";
90
- if (options.access === "domain" && options.domain === undefined) return "--access domain 에는 --domain <https://주소> 가 필요합니다";
91
+ if (options.lang !== undefined && !isLanguage(options.lang)) return message(lang, "badLanguage");
92
+ if (options.access !== undefined && !(ACCESS_CHOICES as readonly unknown[]).includes(options.access)) return message(lang, "badAccess", { choices: ACCESS_CHOICES.join(", ") });
93
+ if (options.domain !== undefined && options.access !== "domain") return message(lang, "domainTogether");
94
+ if (options.access === "domain" && options.domain === undefined) return message(lang, "domainRequired");
91
95
  if (typeof options.domain === "string") {
92
- const domain = parseDomain(options.domain);
96
+ const domain = parseDomain(options.domain, lang);
93
97
  if ("error" in domain) return domain.error;
94
98
  }
95
99
  return options as unknown as SetupOptions;
@@ -103,11 +107,11 @@ export function bunAtLeast(version: string, min: readonly [number, number] = MIN
103
107
  return major > min[0] || (major === min[0] && minor >= min[1]);
104
108
  }
105
109
 
106
- export function platformProblem(platform: Platform): string | null {
110
+ export function platformProblem(platform: Platform, lang: Language = "en"): string | null {
107
111
  if (platform.platform !== "darwin" || platform.arch !== "arm64") {
108
- return `zigmini는 Apple Silicon(arm64) 맥에서만 동작합니다. (이 기기: ${platform.platform} ${platform.arch})`;
112
+ return message(lang, "platform", { ...platform });
109
113
  }
110
- if (!bunAtLeast(platform.bunVersion)) return `zigmini에는 Bun ${MIN_BUN.join(".")} 이상이 필요합니다. (지금: ${platform.bunVersion}) \`bun upgrade\`로 올리세요.`;
114
+ if (!bunAtLeast(platform.bunVersion)) return message(lang, "bunVersion", { min: MIN_BUN.join("."), version: platform.bunVersion });
111
115
  return null;
112
116
  }
113
117
 
@@ -142,10 +146,61 @@ function quote(text: string): string {
142
146
  }
143
147
 
144
148
  /** Never throws: the engine's pinned launcher path (or the ZGW_OMO_BIN test candidate), whether or not it exists yet. */
145
- export function loginHint(agent: string, home: string, bunPath: string, env: Env): string {
149
+ export function loginHint(agent: string, home: string, bunPath: string, env: Env, lang: Language = "en"): string {
146
150
  const assignments = Object.entries(engine.env(agent)).map(([key, value]) => `${key}=${quote(value)}`).join(" ");
147
151
  const bin = env.ZGW_OMO_BIN || join(home, "omo", "current", "node_modules", ".bin", "omo");
148
- return `모델 계정에 로그인하세요: 터미널에서 \`env ${assignments} ${quote(bunPath)} ${quote(bin)}\`를 실행한 뒤 \`/login\`을 입력합니다.`;
152
+ return message(lang, "loginHint", { command: `env ${assignments} ${quote(bunPath)} ${quote(bin)}` });
153
+ }
154
+
155
+ /** Login uses stock OMO; the gateway continues to use its separate, patched engine. */
156
+ export function stockOmoVersion(version = engine.pinnedVersion()): string {
157
+ return version.replace(/-zgw(?:\.\d+)?$/, "");
158
+ }
159
+
160
+ async function loginStep(options: SetupOptions, deps: SetupDeps, agent: string, dedicated: boolean): Promise<void> {
161
+ const lang = deps.lang ?? "en";
162
+ const version = stockOmoVersion();
163
+ let global = true;
164
+ try {
165
+ const current = await deps.globalOmoVersion();
166
+ let install = current === null;
167
+ if (current !== null && current !== version) {
168
+ if (!options.nonInteractive) {
169
+ const answer = await deps.prompt(message(lang, "globalOverwrite", { current, version }));
170
+ install = answer?.trim().toLowerCase() === "y";
171
+ }
172
+ if (!install) deps.out(message(lang, "globalKeep", { current, version }));
173
+ }
174
+ if (install) await deps.installGlobalOmo(version);
175
+ if (current === version || install) deps.out(message(lang, "globalDone", { version }));
176
+ } catch (error) {
177
+ global = false;
178
+ deps.err(message(lang, "globalFailed", { error: error instanceof Error ? error.message : String(error) }));
179
+ }
180
+ let providers = deps.providerCount(agent);
181
+ if (providers >= 1) {
182
+ deps.out(message(lang, "providers", { count: providers }));
183
+ return;
184
+ }
185
+ const command = quote(deps.globalOmoCommand?.() ?? "omo");
186
+ const hint = global
187
+ ? message(lang, "loginHint", { command: dedicated ? `env OMO_CODING_AGENT_DIR=${quote(agent)} ${command}` : command })
188
+ : loginHint(agent, deps.paths.home, deps.bunPath, deps.env, lang);
189
+ deps.out(message(lang, "loginBlock", { hint }));
190
+ if (options.nonInteractive) return;
191
+ for (;;) {
192
+ const answer = await deps.prompt(message(lang, "loginWait"));
193
+ if (answer === null || answer.trim().toLowerCase() === "s") {
194
+ deps.out(message(lang, "loginWarning"));
195
+ return;
196
+ }
197
+ providers = deps.providerCount(agent);
198
+ if (providers >= 1) {
199
+ deps.out(message(lang, "providers", { count: providers }));
200
+ return;
201
+ }
202
+ deps.out(message(lang, "loginPending"));
203
+ }
149
204
  }
150
205
 
151
206
  function ensureDir(path: string, mode?: number): void {
@@ -159,116 +214,134 @@ function writeSecret(path: string, value: string): void {
159
214
  }
160
215
 
161
216
  async function engineStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
217
+ const lang = deps.lang ?? "en";
162
218
  const confirmLicense = options.nonInteractive ? undefined : async (summary: string) => {
163
- deps.out(`${summary}\n`);
164
- const answer = await deps.prompt("엔진 라이선스에 동의하면 y를 입력하세요: ");
219
+ deps.out(`${localize(summary, lang)}\n`);
220
+ const answer = await deps.prompt(message(lang, "engineLicense"));
165
221
  return answer?.trim().toLowerCase() === "y";
166
222
  };
167
223
  try {
168
224
  const target = await deps.installEngine({ home: deps.paths.home, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
169
- deps.out(`엔진: ${target} 설치와 기본 동작 확인을 마쳤습니다.\n`);
225
+ deps.out(message(lang, "engineDone", { target }));
170
226
  return 0;
171
227
  } catch (error) {
172
- const message = error instanceof Error ? error.message : String(error);
173
- deps.err(message === "engine_license_declined"
174
- ? "엔진 라이선스에 동의하지 않아 설치를 멈췄습니다. 비대화 설치는 --accept-license 가 필요합니다.\n"
175
- : `엔진 설치 실패: ${message}\n`);
228
+ const detail = localize(error instanceof Error ? error.message : String(error), lang);
229
+ deps.err(detail === "engine_license_declined"
230
+ ? message(lang, "engineDeclined")
231
+ : message(lang, "engineFailed", { error: detail }));
176
232
  return EXIT_FAILED;
177
233
  }
178
234
  }
179
235
 
180
236
  async function notionStep(options: SetupOptions, deps: SetupDeps): Promise<number> {
237
+ const lang = deps.lang ?? "en";
181
238
  const file = deps.paths.notionTokenFile;
182
239
  let token: string | null = null;
183
240
  if (options.notionTokenFile !== undefined) {
184
241
  try {
185
242
  token = readFileSync(options.notionTokenFile, "utf8").trim();
186
243
  } catch {
187
- deps.err(`Notion 토큰 파일을 읽지 못했습니다: ${options.notionTokenFile}\n`);
244
+ deps.err(message(lang, "notionFile", { path: options.notionTokenFile }));
188
245
  return EXIT_FAILED;
189
246
  }
190
247
  } else if (!options.nonInteractive && !existsSync(file)) {
191
- token = (await deps.prompt("Notion 토큰(비우면 일정 기능 꺼짐): "))?.trim() ?? "";
248
+ token = (await deps.prompt(message(lang, "notionPrompt")))?.trim() ?? "";
192
249
  }
193
250
  if (token) {
194
251
  ensureDir(dirname(file), 0o700);
195
252
  writeSecret(file, token);
196
253
  }
197
- deps.out(`Notion 일정: ${existsSync(file) ? "켜짐(토큰 있음)" : "꺼짐(토큰 없음)"}\n`);
254
+ deps.out(message(lang, "notionState", { state: message(lang, existsSync(file) ? "tokenOn" : "tokenOff") }));
198
255
  return 0;
199
256
  }
200
257
 
201
258
  /** The "외부 접속" step: a new choice is resolved and saved; without one, the saved choice is kept (or asked for interactively). */
202
259
  async function accessStep(options: SetupOptions, deps: SetupDeps): Promise<AccessRecord | null | number> {
260
+ const lang = deps.lang ?? "en";
203
261
  let choice = options.access;
204
262
  let domain = options.domain;
205
263
  if (choice === undefined) {
206
264
  const saved = readAccess(deps.paths.home);
207
265
  if (saved !== null && saved !== "invalid") {
208
- deps.out(`외부 접속: ${saved.choice} ${saved.url} (바꾸려면 --access)\n`);
266
+ deps.out(message(lang, "accessSaved", { ...saved }));
209
267
  return saved;
210
268
  }
211
269
  if (options.nonInteractive) return null;
212
- const answer = (await deps.prompt(`외부 접속을 고르세요 [${ACCESS_CHOICES.join("/")}, 비우면 맥 안에서만]: `))?.trim() ?? "";
270
+ const answer = (await deps.prompt(message(lang, "accessPrompt", { choices: ACCESS_CHOICES.join("/") })))?.trim() ?? "";
213
271
  if (answer === "") {
214
- deps.out("외부 접속: 고르지 않음(웹은 맥 안에서만 엽니다. 폰은 나중에 `zigmini setup --access`로 엽니다)\n");
272
+ deps.out(message(lang, "accessNone"));
215
273
  return null;
216
274
  }
217
275
  if (!isAccessChoice(answer)) {
218
- deps.err(`외부 접속은 ${ACCESS_CHOICES.join(", ")} 중 하나입니다: ${answer}\n`);
276
+ deps.err(message(lang, "accessInvalid", { choices: ACCESS_CHOICES.join(", "), answer }));
219
277
  return EXIT_USAGE;
220
278
  }
221
279
  choice = answer;
222
- if (choice === "domain") domain = (await deps.prompt("자기 도메인 주소(https://...): "))?.trim() ?? "";
280
+ if (choice === "domain") domain = (await deps.prompt(message(lang, "domainPrompt")))?.trim() ?? "";
223
281
  }
224
- const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps);
282
+ const resolved = resolveAccess(choice, domain, deps.paths.ports.app, deps, lang);
225
283
  if (!resolved.ok) {
226
- deps.err(`외부 접속을 정하지 못했습니다.\n${resolved.message}\n`);
284
+ deps.err(message(lang, "accessFailed", { error: resolved.message }));
227
285
  return EXIT_FAILED;
228
286
  }
229
287
  writeAccess(deps.paths.home, resolved.record);
230
- deps.out(`외부 접속: ${resolved.record.choice} ${resolved.record.url} (앱 포트 ${deps.paths.ports.app}, 폰 등록 QR이 이 주소를 씁니다)\n`);
288
+ deps.out(message(lang, "accessDone", { ...resolved.record, port: deps.paths.ports.app }));
231
289
  return resolved.record;
232
290
  }
233
291
 
234
292
  /** A domain is checked only once the services are up; a failure keeps the install and points at the proxy and doctor. */
235
293
  async function domainCheckStep(access: AccessRecord | null, deps: SetupDeps): Promise<void> {
294
+ const lang = deps.lang ?? "en";
236
295
  if (access?.choice !== "domain") return;
237
296
  const result = await deps.ping(access.url);
238
297
  if (result.ok) {
239
- deps.out(`외부 접속 확인: ${access.url}/api/enroll/ping 응답 정상\n`);
298
+ deps.out(message(lang, "domainOk", { url: access.url }));
240
299
  return;
241
300
  }
242
- deps.err(`경고: ${access.url} 에서 게이트웨이에 닿지 못했습니다(${result.detail}). 설치는 그대로 두었습니다.\n${proxyGuide(access.url, deps.paths.ports.app)}\n`);
301
+ deps.err(message(lang, "domainWarning", { url: access.url, detail: result.detail, guide: proxyGuide(access.url, deps.paths.ports.app, lang) }));
243
302
  }
244
303
 
245
304
  export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<number> {
246
- const problem = platformProblem(deps.platform);
305
+ let lang = options.lang ?? resolveLanguage(deps.paths.home, deps.env);
306
+ if (!options.nonInteractive && options.lang === undefined) {
307
+ for (;;) {
308
+ const answer = await deps.prompt(LANGUAGE_QUESTION);
309
+ if (answer === null || answer.trim() === "") break;
310
+ if (answer.trim() === "1" || answer.trim() === "en") { lang = "en"; break; }
311
+ if (answer.trim() === "2" || answer.trim() === "ko") { lang = "ko"; break; }
312
+ deps.err(message(lang, "chooseLanguage"));
313
+ }
314
+ }
315
+ deps = { ...deps, lang };
316
+ const problem = platformProblem(deps.platform, lang);
247
317
  if (problem !== null) {
248
318
  deps.err(`${problem}\n`);
249
319
  return EXIT_USAGE;
250
320
  }
251
321
  const { paths } = deps;
252
322
  const consent = { home: paths.home, kind: deps.installKind, notice: deps.notice, zigminiVersion: deps.zigminiVersion, now: deps.now };
253
- if (!await ensureConsent(consent, deps)) {
254
- deps.err(`${DECLINED_MESSAGE}\n`);
323
+ if (!await ensureConsent(consent, { ...deps, out: (text) => deps.out(localize(text, lang)), prompt: (question) => deps.prompt(localize(question, lang)) })) {
324
+ deps.err(`${message(lang, "consentDeclined")}\n`);
255
325
  return EXIT_DECLINED;
256
326
  }
257
327
  if (options.engineOnly) {
258
328
  ensureDir(paths.omo);
259
- return await engineStep(options, deps);
329
+ const code = await engineStep(options, deps);
330
+ if (code === 0) saveLanguage(paths.home, lang);
331
+ return code;
260
332
  }
261
333
 
262
334
  for (const dir of [paths.home, paths.data, paths.logs, paths.omo, paths.sessions]) ensureDir(dir);
263
335
  for (const dir of [paths.secrets, paths.run]) ensureDir(dir, 0o700);
264
- deps.out(`홈 폴더: ${paths.home}\n`);
265
- if (ensureInstallRules(paths.home)) deps.out(`이 설치의 규칙 파일: ${join(paths.home, INSTALL_RULES_FILE)} (비어 있음)\n`);
336
+ deps.out(message(lang, "home", { path: paths.home }));
337
+ if (ensureInstallRules(paths.home)) deps.out(message(lang, "rules", { path: join(paths.home, INSTALL_RULES_FILE) }));
266
338
 
267
339
  if (!existsSync(paths.omoTokenFile)) writeSecret(paths.omoTokenFile, deps.randomToken());
268
340
  chmodSync(paths.omoTokenFile, 0o600);
269
341
 
270
342
  const engineCode = await engineStep(options, deps);
271
343
  if (engineCode !== 0) return engineCode;
344
+ saveLanguage(paths.home, lang);
272
345
 
273
346
  const configFile = join(paths.home, "config.env");
274
347
  const text = existsSync(configFile) ? readFileSync(configFile, "utf8") : "";
@@ -276,16 +349,15 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
276
349
  try {
277
350
  config = parseConfigEnv(text);
278
351
  } catch (error) {
279
- deps.err(`${(error as Error).message}\n`);
352
+ deps.err(`${localize((error as Error).message, lang)}\n`);
280
353
  return EXIT_FAILED;
281
354
  }
282
355
 
283
356
  const dedicated = options.agentDir ?? config.ZGW_OMO_AGENT_DIR;
284
357
  const agent = dedicated ? resolve(dedicated) : defaultAgentDir(deps.env);
285
358
  if (options.agentDir !== undefined) ensureDir(agent, 0o700);
286
- deps.out(`에이전트 폴더: ${agent}${dedicated ? " (전용)" : " (공유)"}\n`);
287
- const providers = providerCount(agent);
288
- deps.out(providers > 0 ? `모델 계정: 로그인됨(제공자 ${providers}개)\n` : `${loginHint(agent, paths.home, deps.bunPath, deps.env)}\n`);
359
+ deps.out(message(lang, "agent", { path: agent, kind: message(lang, dedicated ? "dedicated" : "shared") }));
360
+ await loginStep(options, deps, agent, Boolean(dedicated));
289
361
 
290
362
  const notionCode = await notionStep(options, deps);
291
363
  if (notionCode !== 0) return notionCode;
@@ -302,34 +374,26 @@ export async function runSetup(options: SetupOptions, deps: SetupDeps): Promise<
302
374
  chmodSync(configFile, 0o600);
303
375
 
304
376
  if (!existsSync(paths.voiceSettingFile)) writeVoiceSetting(paths.voiceSettingFile, false);
305
- deps.out(`음성: ${readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "꺼짐" : "켜짐"} (웹·앱 설정에서 바꿉니다)\n`);
377
+ deps.out(message(lang, "voice", { state: message(lang, readFileSync(paths.voiceSettingFile, "utf8").trim() === "off" ? "off" : "on") }));
306
378
 
307
379
  const started = await deps.start();
308
380
  if (started !== 0) {
309
- deps.err(`서비스 시작에 실패했습니다. \`zigmini status\`와 로그 폴더 ${paths.logs}를 확인하세요.\n`);
381
+ deps.err(message(lang, "startFailed", { path: paths.logs }));
310
382
  return started;
311
383
  }
312
- deps.out(`설치를 마쳤습니다.\n 웹: http://127.0.0.1:${paths.ports.web}\n 상태 확인: zigmini status\n 멈추기: zigmini stop\n`);
384
+ deps.out(message(lang, "setupDone", { port: paths.ports.web }));
313
385
  await domainCheckStep(access, deps);
314
- await enrollStep(deps);
386
+ phoneStep(access, deps);
315
387
  return 0;
316
388
  }
317
389
 
318
390
  /**
319
- * The last step: the app download QR, then the phone enrollment QR. The services are already up, so a failure here is a
320
- * hint, not a failed setup.
391
+ * Terminal font metrics can break QR scanning. Direct the user to the real web settings sections instead.
321
392
  */
322
- async function enrollStep(deps: SetupDeps): Promise<void> {
323
- try {
324
- deps.out(`\n${await deps.apkQr()}`);
325
- } catch (error) {
326
- deps.out(`${apkFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair --apk\`로 다시 만듭니다.\n`);
327
- }
328
- try {
329
- deps.out(`\n${await deps.enrollQr()}`);
330
- } catch (error) {
331
- deps.out(`${pairFailureLine(error)} 게이트웨이가 준비되면 \`zigmini pair\`로 다시 만듭니다.\n`);
332
- }
393
+ function phoneStep(access: AccessRecord | null, deps: SetupDeps): void {
394
+ const lang = deps.lang ?? "en";
395
+ if (access === null) deps.out(`${message(lang, "setupAccessGuidance")}\n`);
396
+ deps.out(message(lang, "webQrGuide", { port: deps.paths.ports.web }));
333
397
  }
334
398
 
335
399
  async function* stdinLines(): AsyncGenerator<string> {
@@ -356,19 +420,40 @@ export function stdinPrompt(): (question: string) => Promise<string | null> {
356
420
 
357
421
  export function systemSetupDeps(zigminiVersion: string, env: Env = process.env): SetupDeps {
358
422
  const ctx = systemContext(env);
423
+ let globalBin: string | null = null;
359
424
  return {
360
425
  platform: { platform: process.platform, arch: process.arch, bunVersion: Bun.version },
361
426
  env,
362
427
  paths: ctx.paths,
363
428
  bunPath: ctx.bunPath,
364
429
  installEngine: (options) => installEngine(options),
365
- start: () => startCommand(systemDeps(), ctx),
366
- enrollQr: () => enrollmentQr((url, init) => fetch(url, init), ctx.paths.ports.web, Date.now),
367
- apkQr: () => apkQr((url, init) => fetch(url, init), ctx.paths.ports.web, Date.now),
430
+ globalOmoVersion: async () => {
431
+ const bin = env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin/omo") : Bun.which("omo", { PATH: env.PATH });
432
+ if (!bin || !existsSync(bin)) return null;
433
+ const proc = Bun.spawn([ctx.bunPath, bin, "--version"], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(10_000) });
434
+ const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
435
+ const version = /^omo\s+(\S+)/.exec(stdout.trim())?.[1];
436
+ if (code !== 0 || version === undefined) throw new Error(stderr.trim() || "global_omo_version_unknown");
437
+ globalBin = bin;
438
+ return version;
439
+ },
440
+ installGlobalOmo: async (version) => {
441
+ const proc = Bun.spawn([ctx.bunPath, "add", "-g", `${ENGINE_PACKAGE}@${version}`], { env: { ...env }, stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(180_000) });
442
+ const [code, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
443
+ if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `bun_add_exit_${code}`);
444
+ globalBin = join(env.BUN_INSTALL || join(env.HOME || homedir(), ".bun"), "bin/omo");
445
+ },
446
+ globalOmoCommand: () => globalBin === null || Bun.which("omo", { PATH: env.PATH }) === globalBin ? "omo" : globalBin,
447
+ providerCount,
448
+ start: () => {
449
+ const deps = systemDeps();
450
+ const lang = resolveLanguage(ctx.paths.home, env);
451
+ return startCommand({ ...deps, out: (text) => deps.out(localize(text, lang)), err: (text) => deps.err(localize(text, lang)) }, ctx);
452
+ },
368
453
  prompt: stdinPrompt(),
369
454
  network: SYSTEM_NETWORK,
370
455
  tailscale: systemTailscale,
371
- ping: (url) => pingAccess((target, init) => fetch(target, init), url),
456
+ ping: (url) => pingAccess((target, init) => fetch(target, init), url, undefined, resolveLanguage(ctx.paths.home, env)),
372
457
  isDir: (path) => {
373
458
  try {
374
459
  return statSync(path).isDirectory();
@@ -387,10 +472,14 @@ export function systemSetupDeps(zigminiVersion: string, env: Env = process.env):
387
472
  }
388
473
 
389
474
  export async function setupCommand(argv: readonly string[], deps: () => SetupDeps, usage: string): Promise<number> {
390
- const options = parseSetupArgs(argv);
475
+ const runtime = deps();
476
+ const at = argv.indexOf("--lang");
477
+ const requested = at < 0 ? undefined : argv[at + 1];
478
+ const lang = isLanguage(requested) ? requested : resolveLanguage(runtime.paths.home, runtime.env);
479
+ const options = parseSetupArgs(argv, lang);
391
480
  if (typeof options === "string") {
392
- process.stderr.write(`${options}\n\n${usage}`);
481
+ process.stderr.write(`${options}\n\n${localize(usage, lang)}`);
393
482
  return EXIT_USAGE;
394
483
  }
395
- return await runSetup(options, deps());
484
+ return await runSetup(options, runtime);
396
485
  }