@goodea/olimpyx 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Interface language for the owner-facing CLI.
3
+ *
4
+ * Only the owner's own surfaces are localised: the `init` wizard, its summary and errors,
5
+ * and the hints `status` returns. Protocol values, command names, flags and JSON keys stay
6
+ * as they are -- they are an interface for machines and scripts, and translating them would
7
+ * break every playbook that quotes them.
8
+ *
9
+ * Detection order, most explicit first:
10
+ * 1. `--lang xx` on the command line
11
+ * 2. `OLIMPYX_LANG`
12
+ * 3. `LC_ALL` / `LC_MESSAGES` / `LANG`
13
+ * 4. `Intl.DateTimeFormat().resolvedOptions().locale`
14
+ * 5. English
15
+ *
16
+ * The POSIX variables come before `Intl`, and that order was measured rather than assumed.
17
+ * `Intl.DateTimeFormat().resolvedOptions().locale` does NOT track those variables in Node:
18
+ * on a box with `LANG=ru_RU.UTF-8` it still answers `en-US`, because it reports ICU's
19
+ * default locale. Consulting it first therefore overrode a setting the user had made on
20
+ * purpose with one nobody chose. `Intl` still earns its place behind them: a macOS GUI
21
+ * terminal can start with none of `LC_ALL`/`LC_MESSAGES`/`LANG` set, and there it is the
22
+ * only reading of the OS language available.
23
+ */
24
+
25
+ export const LANGUAGES = ['en', 'ru'];
26
+ const FALLBACK = 'en';
27
+
28
+ /** Anything that is not recognisably Russian is English -- there are two languages, not a registry. */
29
+ function normalize(value) {
30
+ if (!value) return null;
31
+ const tag = String(value).trim().toLowerCase();
32
+ if (!tag || tag === 'c' || tag === 'posix') return null;
33
+ return tag.startsWith('ru') ? 'ru' : 'en';
34
+ }
35
+
36
+ export function detectLanguage(env = process.env, argv = process.argv) {
37
+ const flag = argv.indexOf('--lang');
38
+ if (flag >= 0 && argv[flag + 1]) {
39
+ const explicit = normalize(argv[flag + 1]);
40
+ if (explicit) return explicit;
41
+ }
42
+ const fromEnv = normalize(env.OLIMPYX_LANG);
43
+ if (fromEnv) return fromEnv;
44
+ const fromPosix = normalize(env.LC_ALL || env.LC_MESSAGES || env.LANG);
45
+ if (fromPosix) return fromPosix;
46
+ try {
47
+ const fromIntl = normalize(Intl.DateTimeFormat().resolvedOptions().locale);
48
+ if (fromIntl) return fromIntl;
49
+ } catch { /* no ICU data: English it is */ }
50
+ return FALLBACK;
51
+ }
52
+
53
+ const MESSAGES = {
54
+ en: {
55
+ 'init.intro': 'Olimpyx · joining the city',
56
+ 'init.cancelled': 'Nothing was written.',
57
+ 'init.needsTty': 'olimpyx init needs an interactive terminal. Run it in a real terminal, not from a pipe.',
58
+
59
+ 'init.server': 'Server',
60
+ 'init.server.protocol': 'Needs http or https',
61
+ 'init.server.invalid': 'That does not look like a URL',
62
+
63
+ 'init.account': 'Owner account',
64
+ 'init.account.register': 'Create a new one',
65
+ 'init.account.register.hint': 'email + password + name',
66
+ 'init.account.login': 'Sign in',
67
+ 'init.account.login.hint': 'already registered on the site',
68
+
69
+ 'init.email': 'Email',
70
+ 'init.email.invalid': 'Needs an ordinary email',
71
+
72
+ 'init.displayName': 'Display name',
73
+ 'init.displayName.hint': 'how the city sees you',
74
+ 'init.displayName.empty': 'The name cannot be empty',
75
+
76
+ 'init.password': 'Password',
77
+ 'init.password.short': 'At least 12 characters',
78
+ 'init.password.again': 'Password again',
79
+ 'init.password.mismatch': 'The passwords do not match',
80
+
81
+ 'init.skillScope': 'Where to put the starter skill',
82
+ 'init.skillScope.global': 'Globally',
83
+ 'init.skillScope.global.hint': '~/.claude/skills and ~/.agents/skills',
84
+ 'init.skillScope.local': 'In a project',
85
+ 'init.skillScope.local.hint': 'the current folder; the path can be changed',
86
+ 'init.projectPath': 'Project path',
87
+ 'init.projectPath.hint': 'Enter — keep the current one',
88
+
89
+ 'init.hosts': 'Hosts for the skill',
90
+ 'init.characters': 'Starting characters (space — select, Enter — continue)',
91
+ 'init.characters.it': 'IT',
92
+ 'init.characters.industry': 'Industries',
93
+
94
+ 'init.summary': 'Summary',
95
+ 'init.confirm': 'Write the vault, the skill and the selected agents?',
96
+
97
+ 'init.progress.connecting': 'Connecting…',
98
+ 'init.progress.register': 'Registering the owner',
99
+ 'init.progress.login': 'Signing in',
100
+ 'init.progress.vault': 'Encrypting the vault',
101
+ 'init.progress.catalog': 'Copying the character catalogue',
102
+ 'init.progress.playbook': 'Writing the playbook',
103
+ 'init.progress.skills': 'Installing the starter skill',
104
+ 'init.progress.agent': 'Registering {name}',
105
+ 'init.progress.done': 'Done',
106
+ 'init.progress.failed': 'Did not work',
107
+
108
+ 'init.written': 'What was written',
109
+ 'init.written.home': 'Owner home: {path}',
110
+ 'init.written.skill': 'Skill:',
111
+ 'init.written.agents': 'Agents:',
112
+ 'init.written.noAgents': '(nobody — add them later: olimpyx agent add prometheus)',
113
+ 'init.written.next': 'Next: olimpyx status · olimpyx skill',
114
+ 'init.outro': 'The password no longer needs to live in project files.',
115
+
116
+ 'plan.server': 'Server: {url}',
117
+ 'plan.account': 'Account: {mode} · {email}',
118
+ 'plan.account.register': 'new registration',
119
+ 'plan.account.login': 'sign-in',
120
+ 'plan.name': 'Name: {name}',
121
+ 'plan.skill': 'Skill: {where}',
122
+ 'plan.skill.global': 'globally (~/.claude and ~/.agents)',
123
+ 'plan.skill.local': 'in the project {path}',
124
+ 'plan.hosts': 'Hosts: {hosts}',
125
+ 'plan.agents': 'Agents: {agents}',
126
+ 'plan.agents.none': 'nobody yet',
127
+ 'plan.none': '—',
128
+
129
+ 'error.emailTaken': 'That email is already registered. Choose sign-in.',
130
+ 'error.badCredentials': 'Wrong email or password.',
131
+ 'error.validation': 'Check the fields: password at least 12 characters, a valid email, a non-empty name.',
132
+ 'error.rateLimited': 'Too many attempts. Wait a moment and try again.',
133
+ 'error.unreachable': 'Could not reach {url}. Check the network and the server address.',
134
+ 'error.unknown': 'Unknown error',
135
+
136
+ 'status.notInitialized': 'Run olimpyx init',
137
+ 'status.configUnreadable': 'The vault exists, config.json could not be read',
138
+ 'status.configIsParticipant': 'A participant config sits where the owner config belongs. Move it to a participant home and run olimpyx init.',
139
+
140
+ 'init.alreadyInitialized': 'This machine is already initialised. Use olimpyx init --force to register or log in again, or olimpyx agent add <id> for another agent.',
141
+
142
+ 'agent.unknownCharacter': 'No character “{id}”. See olimpyx skill / the catalogue in ~/.olimpyx/characters/INDEX.md',
143
+ 'agent.alreadyAdded': 'Agent {name} is already added'
144
+ },
145
+
146
+ ru: {
147
+ 'init.intro': 'Olimpyx · подключение к городу',
148
+ 'init.cancelled': 'Ничего не записано.',
149
+ 'init.needsTty': 'olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.',
150
+
151
+ 'init.server': 'Сервер',
152
+ 'init.server.protocol': 'Нужен http или https',
153
+ 'init.server.invalid': 'Это не похоже на URL',
154
+
155
+ 'init.account': 'Аккаунт владельца',
156
+ 'init.account.register': 'Создать новый',
157
+ 'init.account.register.hint': 'email + пароль + имя',
158
+ 'init.account.login': 'Войти',
159
+ 'init.account.login.hint': 'уже регистрировались на сайте',
160
+
161
+ 'init.email': 'Email',
162
+ 'init.email.invalid': 'Нужен обычный email',
163
+
164
+ 'init.displayName': 'Отображаемое имя',
165
+ 'init.displayName.hint': 'Как вас видно в городе',
166
+ 'init.displayName.empty': 'Имя не должно быть пустым',
167
+
168
+ 'init.password': 'Пароль',
169
+ 'init.password.short': 'Не короче 12 символов',
170
+ 'init.password.again': 'Пароль ещё раз',
171
+ 'init.password.mismatch': 'Пароли не совпали',
172
+
173
+ 'init.skillScope': 'Куда поставить стартер-скилл',
174
+ 'init.skillScope.global': 'Глобально',
175
+ 'init.skillScope.global.hint': '~/.claude/skills и ~/.agents/skills',
176
+ 'init.skillScope.local': 'В проект',
177
+ 'init.skillScope.local.hint': 'текущая папка, путь можно поправить',
178
+ 'init.projectPath': 'Путь проекта',
179
+ 'init.projectPath.hint': 'Enter — оставить текущий',
180
+
181
+ 'init.hosts': 'Хосты для скилла',
182
+ 'init.characters': 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
183
+ 'init.characters.it': 'IT',
184
+ 'init.characters.industry': 'Отрасли',
185
+
186
+ 'init.summary': 'Сводка',
187
+ 'init.confirm': 'Записать vault, скилл и выбранных агентов?',
188
+
189
+ 'init.progress.connecting': 'Подключаемся…',
190
+ 'init.progress.register': 'Регистрируем владельца',
191
+ 'init.progress.login': 'Входим',
192
+ 'init.progress.vault': 'Шифруем vault',
193
+ 'init.progress.catalog': 'Копируем каталог персонажей',
194
+ 'init.progress.playbook': 'Пишем playbook',
195
+ 'init.progress.skills': 'Ставим стартер-скилл',
196
+ 'init.progress.agent': 'Регистрируем {name}',
197
+ 'init.progress.done': 'Готово',
198
+ 'init.progress.failed': 'Не вышло',
199
+
200
+ 'init.written': 'Что записано',
201
+ 'init.written.home': 'Дом владельца: {path}',
202
+ 'init.written.skill': 'Скилл:',
203
+ 'init.written.agents': 'Агенты:',
204
+ 'init.written.noAgents': '(никого — добавите позже: olimpyx agent add prometheus)',
205
+ 'init.written.next': 'Дальше: olimpyx status · olimpyx skill',
206
+ 'init.outro': 'Пароль больше не нужно класть в файлы проекта.',
207
+
208
+ 'plan.server': 'Сервер: {url}',
209
+ 'plan.account': 'Аккаунт: {mode} · {email}',
210
+ 'plan.account.register': 'новая регистрация',
211
+ 'plan.account.login': 'вход',
212
+ 'plan.name': 'Имя: {name}',
213
+ 'plan.skill': 'Скилл: {where}',
214
+ 'plan.skill.global': 'глобально (~/.claude и ~/.agents)',
215
+ 'plan.skill.local': 'в проекте {path}',
216
+ 'plan.hosts': 'Хосты: {hosts}',
217
+ 'plan.agents': 'Агенты: {agents}',
218
+ 'plan.agents.none': 'пока никого',
219
+ 'plan.none': '—',
220
+
221
+ 'error.emailTaken': 'Этот email уже зарегистрирован. Выберите вход.',
222
+ 'error.badCredentials': 'Неверный email или пароль.',
223
+ 'error.validation': 'Проверьте поля: пароль не короче 12 символов, корректный email, имя не пустое.',
224
+ 'error.rateLimited': 'Слишком много попыток. Подождите немного и повторите.',
225
+ 'error.unreachable': 'Не удалось связаться с {url}. Проверьте сеть и адрес сервера.',
226
+ 'error.unknown': 'Неизвестная ошибка',
227
+
228
+ 'status.notInitialized': 'Запустите olimpyx init',
229
+ 'status.configUnreadable': 'Vault есть, config.json не прочитан',
230
+ 'status.configIsParticipant': 'На месте конфига владельца лежит конфиг участника. Перенесите его в дом участника и запустите olimpyx init.',
231
+
232
+ 'init.alreadyInitialized': 'Машина уже инициализирована. Для повторной регистрации или входа: olimpyx init --force. Для ещё одного агента: olimpyx agent add <id>.',
233
+
234
+ 'agent.unknownCharacter': 'Нет персонажа «{id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md',
235
+ 'agent.alreadyAdded': 'Агент {name} уже добавлен'
236
+ }
237
+ };
238
+
239
+ /**
240
+ * A missing key returns the key itself rather than an empty string or a thrown error: a
241
+ * visible `init.server` in the terminal is a bug report, while silence hides the gap and a
242
+ * throw turns a cosmetic omission into a failed install.
243
+ */
244
+ export function translate(lang, key, vars = {}) {
245
+ const table = MESSAGES[lang] ?? MESSAGES[FALLBACK];
246
+ const template = table[key] ?? MESSAGES[FALLBACK][key] ?? key;
247
+ return template.replace(/\{(\w+)\}/g, (whole, name) =>
248
+ Object.hasOwn(vars, name) ? String(vars[name]) : whole);
249
+ }
250
+
251
+ export function createT(lang) {
252
+ return (key, vars) => translate(lang, key, vars);
253
+ }
254
+
255
+ /** The process-wide translator, bound once from the environment this process was started in. */
256
+ export const t = createT(detectLanguage());
package/src/init-apply.js CHANGED
@@ -6,8 +6,10 @@ import { LocalState } from './state.js';
6
6
  import { CHARACTERS, characterById, publicProfile, writeCatalog } from './characters.js';
7
7
  import { configPath, ownerHome, readVault, vaultExists, writeVault } from './vault.js';
8
8
  import { installStarterSkill, loadPlaybookSource, toGlobalPlaybook } from './skill-install.js';
9
+ import { t as defaultT } from './i18n.js';
9
10
 
10
11
  export const DEFAULT_SERVER = 'https://olimpyx.mrciphersmith.com';
12
+ export const OWNER_CONFIG_KIND = 'olimpyx.owner-config/1';
11
13
 
12
14
  export function isTransientNetworkError(error) {
13
15
  if (!error) return false;
@@ -16,15 +18,15 @@ export function isTransientNetworkError(error) {
16
18
  return Boolean(code && ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(code));
17
19
  }
18
20
 
19
- export function friendlyInitError(error, serverUrl = DEFAULT_SERVER) {
20
- if (error?.status === 409) return 'Этот email уже зарегистрирован. Выберите вход.';
21
- if (error?.status === 401) return 'Неверный email или пароль.';
22
- if (error?.status === 422) return 'Проверьте поля: пароль не короче 12 символов, корректный email, имя не пустое.';
23
- if (error?.status === 429) return 'Слишком много попыток. Подождите немного и повторите.';
21
+ export function friendlyInitError(error, serverUrl = DEFAULT_SERVER, t = defaultT) {
22
+ if (error?.status === 409) return t('error.emailTaken');
23
+ if (error?.status === 401) return t('error.badCredentials');
24
+ if (error?.status === 422) return t('error.validation');
25
+ if (error?.status === 429) return t('error.rateLimited');
24
26
  if (isTransientNetworkError(error) || error instanceof TypeError) {
25
- return `Не удалось связаться с ${serverUrl}. Проверьте сеть и адрес сервера.`;
27
+ return t('error.unreachable', { url: serverUrl });
26
28
  }
27
- return error?.message || 'Неизвестная ошибка';
29
+ return error?.message || t('error.unknown');
28
30
  }
29
31
 
30
32
  export function agentHomeFor(id, plan, env = process.env) {
@@ -32,18 +34,21 @@ export function agentHomeFor(id, plan, env = process.env) {
32
34
  return join(root, 'agents', id);
33
35
  }
34
36
 
35
- export function summarizePlan(plan) {
37
+ export function summarizePlan(plan, t = defaultT) {
36
38
  const characters = (plan.characterIds || []).map((id) => characterById(id)?.name || id);
37
39
  const skillWhere = plan.skillScope === 'global'
38
- ? 'глобально (~/.claude и ~/.agents)'
39
- : проекте ${plan.projectPath}`;
40
+ ? t('plan.skill.global')
41
+ : t('plan.skill.local', { path: plan.projectPath });
40
42
  return [
41
- `Сервер: ${plan.serverUrl}`,
42
- `Аккаунт: ${plan.mode === 'register' ? 'новая регистрация' : 'вход'} · ${plan.email}`,
43
- plan.displayName ? `Имя: ${plan.displayName}` : null,
44
- `Скилл: ${skillWhere}`,
45
- `Хосты: ${(plan.hosts || []).join(', ') || '—'}`,
46
- `Агенты: ${characters.length ? characters.join(', ') : 'пока никого'}`
43
+ t('plan.server', { url: plan.serverUrl }),
44
+ t('plan.account', {
45
+ mode: t(plan.mode === 'register' ? 'plan.account.register' : 'plan.account.login'),
46
+ email: plan.email
47
+ }),
48
+ plan.displayName ? t('plan.name', { name: plan.displayName }) : null,
49
+ t('plan.skill', { where: skillWhere }),
50
+ t('plan.hosts', { hosts: (plan.hosts || []).join(', ') || t('plan.none') }),
51
+ t('plan.agents', { agents: characters.length ? characters.join(', ') : t('plan.agents.none') })
47
52
  ].filter(Boolean).join('\n');
48
53
  }
49
54
 
@@ -63,10 +68,11 @@ async function authenticate(plan, client) {
63
68
 
64
69
  async function enrollOne(plan, ownerClient, character, env) {
65
70
  const profile = publicProfile(character);
71
+ const installationId = crypto.randomUUID();
66
72
  const enrollment = await ownerClient.request('POST', '/v1/owners/me/enrollment-tokens', { label: character.id });
67
73
  const result = await ownerClient.request('POST', '/v1/agents/enroll', {
68
74
  enrollment_token: enrollment.data.enrollment_token,
69
- installation_id: crypto.randomUUID(),
75
+ installation_id: installationId,
70
76
  profile
71
77
  }, { token: null });
72
78
  const home = agentHomeFor(character.id, plan, env);
@@ -74,12 +80,23 @@ async function enrollOne(plan, ownerClient, character, env) {
74
80
  await state.saveCredential(result.data.agent_token);
75
81
  await state.saveConfig({
76
82
  serverUrl: plan.serverUrl,
83
+ installationId,
77
84
  agentId: result.data.agent.agent_id,
78
85
  profileRevision: result.data.agent.profile_revision,
79
86
  characterId: character.id,
80
87
  ownerId: plan.ownerId ?? null
81
88
  });
82
89
  await state.savePersona(profile, 'init catalog');
90
+ if (character.id === 'archi') {
91
+ for (const [source, destination] of [['archi-citizen.md', 'CITIZEN.md'], ['archi-decide.md', 'DECIDE.md']]) {
92
+ const template = await readFile(new URL(`../data/skill/${source}`, import.meta.url), 'utf8');
93
+ try {
94
+ await writeFile(join(home, destination), template, { mode: 0o600, flag: 'wx' });
95
+ } catch (error) {
96
+ if (error.code !== 'EEXIST') throw error;
97
+ }
98
+ }
99
+ }
83
100
  return {
84
101
  id: character.id,
85
102
  agent_id: result.data.agent.agent_id,
@@ -129,6 +146,10 @@ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, on
129
146
  await writeVault(vault, env);
130
147
 
131
148
  const config = {
149
+ // A participant config ({agentId, installationId}) used to be able to land on this exact
150
+ // path. `readOwnerStatus` parsed whatever was there as owner config and reported an owner
151
+ // with no email and no agents. The marker makes the two tellable apart.
152
+ kind: OWNER_CONFIG_KIND,
132
153
  serverUrl: plan.serverUrl,
133
154
  email: plan.email,
134
155
  displayName: owner.display_name,
@@ -163,13 +184,16 @@ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, on
163
184
  return { home, config, enrolled, installedSkills };
164
185
  }
165
186
 
166
- export async function readOwnerStatus(env = process.env) {
187
+ export async function readOwnerStatus(env = process.env, t = defaultT) {
167
188
  const initialized = await vaultExists(env);
168
189
  if (!initialized) {
169
- return { initialized: false, hint: 'Запустите olimpyx init' };
190
+ return { initialized: false, hint: t('status.notInitialized') };
170
191
  }
171
192
  try {
172
193
  const config = JSON.parse(await readFile(configPath(env), 'utf8'));
194
+ if (config.kind !== OWNER_CONFIG_KIND && (config.agentId || config.installationId)) {
195
+ return { initialized: true, hint: t('status.configIsParticipant') };
196
+ }
173
197
  return {
174
198
  initialized: true,
175
199
  serverUrl: config.serverUrl,
@@ -180,16 +204,16 @@ export async function readOwnerStatus(env = process.env) {
180
204
  agents: config.agents || []
181
205
  };
182
206
  } catch {
183
- return { initialized: true, hint: 'Vault есть, config.json не прочитан' };
207
+ return { initialized: true, hint: t('status.configUnreadable') };
184
208
  }
185
209
  }
186
210
 
187
- export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch } = {}) {
211
+ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch, t = defaultT } = {}) {
188
212
  const character = characterById(id);
189
- if (!character) throw new Error(`Нет персонажа «${id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md`);
213
+ if (!character) throw new Error(t('agent.unknownCharacter', { id }));
190
214
  const vault = await readVault(env);
191
215
  const config = JSON.parse(await readFile(configPath(env), 'utf8'));
192
- if (vault.agents?.[id]) throw new Error(`Агент ${character.name} уже добавлен`);
216
+ if (vault.agents?.[id]) throw new Error(t('agent.alreadyAdded', { name: character.name }));
193
217
  const plan = {
194
218
  serverUrl: config.serverUrl,
195
219
  skillScope: config.skillScope || 'global',
@@ -204,4 +228,3 @@ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = f
204
228
  await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
205
229
  return enrolled;
206
230
  }
207
-
package/src/init.js CHANGED
@@ -1,90 +1,92 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import { resolve } from 'node:path';
3
3
  import { CHARACTERS } from './characters.js';
4
- import { applyInit, DEFAULT_SERVER, summarizePlan } from './init-apply.js';
4
+ import { applyInit, DEFAULT_SERVER, readOwnerStatus, summarizePlan } from './init-apply.js';
5
+ import { vaultExists } from './vault.js';
6
+ import { t } from './i18n.js';
5
7
 
6
8
  function stopped(value) {
7
9
  if (p.isCancel(value)) {
8
- p.cancel('Ничего не записано.');
10
+ p.cancel(t('init.cancelled'));
9
11
  process.exit(0);
10
12
  }
11
13
  return value;
12
14
  }
13
15
 
14
16
  export async function collectPlan({ cwd = process.cwd() } = {}) {
15
- p.intro('Olimpyx · подключение к городу');
17
+ p.intro(t('init.intro'));
16
18
 
17
19
  const serverUrl = String(stopped(await p.text({
18
- message: 'Сервер',
20
+ message: t('init.server'),
19
21
  initialValue: DEFAULT_SERVER,
20
22
  placeholder: DEFAULT_SERVER,
21
23
  validate: (value) => {
22
24
  try {
23
25
  const url = new URL(value);
24
- if (!/^https?:$/.test(url.protocol)) return 'Нужен http или https';
26
+ if (!/^https?:$/.test(url.protocol)) return t('init.server.protocol');
25
27
  } catch {
26
- return 'Это не похоже на URL';
28
+ return t('init.server.invalid');
27
29
  }
28
30
  }
29
31
  }))).replace(/\/$/, '');
30
32
 
31
33
  const mode = stopped(await p.select({
32
- message: 'Аккаунт владельца',
34
+ message: t('init.account'),
33
35
  options: [
34
- { value: 'register', label: 'Создать новый', hint: 'email + пароль + имя' },
35
- { value: 'login', label: 'Войти', hint: 'уже регистрировались на сайте' }
36
+ { value: 'register', label: t('init.account.register'), hint: t('init.account.register.hint') },
37
+ { value: 'login', label: t('init.account.login'), hint: t('init.account.login.hint') }
36
38
  ]
37
39
  }));
38
40
 
39
41
  const email = String(stopped(await p.text({
40
- message: 'Email',
42
+ message: t('init.email'),
41
43
  placeholder: 'you@example.com',
42
- validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : 'Нужен обычный email'
44
+ validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : t('init.email.invalid')
43
45
  }))).trim().toLowerCase();
44
46
 
45
47
  let displayName;
46
48
  if (mode === 'register') {
47
49
  displayName = String(stopped(await p.text({
48
- message: 'Отображаемое имя',
49
- placeholder: 'Как вас видно в городе',
50
- validate: (value) => value.trim() ? undefined : 'Имя не должно быть пустым'
50
+ message: t('init.displayName'),
51
+ placeholder: t('init.displayName.hint'),
52
+ validate: (value) => value.trim() ? undefined : t('init.displayName.empty')
51
53
  }))).trim();
52
54
  }
53
55
 
54
56
  const password = String(stopped(await p.password({
55
- message: 'Пароль',
56
- validate: (value) => value.length >= 12 ? undefined : 'Не короче 12 символов'
57
+ message: t('init.password'),
58
+ validate: (value) => value.length >= 12 ? undefined : t('init.password.short')
57
59
  })));
58
60
  if (mode === 'register') {
59
61
  const again = String(stopped(await p.password({
60
- message: 'Пароль ещё раз',
61
- validate: (value) => value === password ? undefined : 'Пароли не совпали'
62
+ message: t('init.password.again'),
63
+ validate: (value) => value === password ? undefined : t('init.password.mismatch')
62
64
  })));
63
65
  if (again !== password) {
64
- p.cancel('Пароли не совпали.');
66
+ p.cancel(t('init.password.mismatch'));
65
67
  process.exit(0);
66
68
  }
67
69
  }
68
70
 
69
71
  const skillScope = stopped(await p.select({
70
- message: 'Куда поставить стартер-скилл',
72
+ message: t('init.skillScope'),
71
73
  options: [
72
- { value: 'global', label: 'Глобально', hint: '~/.claude/skills и ~/.agents/skills' },
73
- { value: 'local', label: 'В проект', hint: 'текущая папка, путь можно поправить' }
74
+ { value: 'global', label: t('init.skillScope.global'), hint: t('init.skillScope.global.hint') },
75
+ { value: 'local', label: t('init.skillScope.local'), hint: t('init.skillScope.local.hint') }
74
76
  ]
75
77
  }));
76
78
 
77
79
  let projectPath = cwd;
78
80
  if (skillScope === 'local') {
79
81
  projectPath = resolve(String(stopped(await p.text({
80
- message: 'Путь проекта',
82
+ message: t('init.projectPath'),
81
83
  initialValue: cwd,
82
- hint: 'Enter — оставить текущий'
84
+ hint: t('init.projectPath.hint')
83
85
  }))));
84
86
  }
85
87
 
86
88
  const hosts = stopped(await p.multiselect({
87
- message: 'Хосты для скилла',
89
+ message: t('init.hosts'),
88
90
  options: [
89
91
  { value: 'claude', label: 'Claude Code', hint: '.claude/skills' },
90
92
  { value: 'codex', label: 'Codex', hint: '.agents/skills' }
@@ -94,13 +96,13 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
94
96
  }));
95
97
 
96
98
  const characterIds = stopped(await p.groupMultiselect({
97
- message: 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
99
+ message: t('init.characters'),
98
100
  options: {
99
- IT: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
101
+ [t('init.characters.it')]: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
100
102
  value: item.id,
101
103
  label: `${item.name} — ${item.role}`
102
104
  })),
103
- 'Отрасли': CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
105
+ [t('init.characters.industry')]: CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
104
106
  value: item.id,
105
107
  label: `${item.name} — ${item.role}`
106
108
  }))
@@ -110,58 +112,73 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
110
112
  })) || [];
111
113
 
112
114
  const plan = { serverUrl, mode, email, password, displayName, skillScope, projectPath, hosts, characterIds };
113
- p.note(summarizePlan(plan), 'Сводка');
115
+ p.note(summarizePlan(plan, t), t('init.summary'));
114
116
  const ok = stopped(await p.confirm({
115
- message: 'Записать vault, скилл и выбранных агентов?',
117
+ message: t('init.confirm'),
116
118
  initialValue: true
117
119
  }));
118
120
  if (!ok) {
119
- p.cancel('Ничего не записано.');
121
+ p.cancel(t('init.cancelled'));
120
122
  process.exit(0);
121
123
  }
122
124
  return plan;
123
125
  }
124
126
 
125
- export async function runInit() {
127
+ // `applyInit` overwrites vault.enc and the owner config outright -- it never merges. Running
128
+ // the wizard on a healthy install therefore re-registered or re-logged the owner and replaced
129
+ // both files, while docs/operations/upgrading.md told operators a re-run was a safe no-op that
130
+ // returns `already_initialized`. The guard the documentation always described now exists.
131
+ export async function runInit({ force = false, env = process.env } = {}) {
132
+ if (!force && await vaultExists(env)) {
133
+ const status = await readOwnerStatus(env);
134
+ process.stdout.write(`${JSON.stringify({
135
+ result: 'already_initialized',
136
+ serverUrl: status.serverUrl ?? null,
137
+ email: status.email ?? null,
138
+ agents: (status.agents ?? []).map((agent) => agent.id),
139
+ hint: t('init.alreadyInitialized')
140
+ }, null, 2)}\n`);
141
+ return;
142
+ }
126
143
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
127
- throw new Error('olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.');
144
+ throw new Error(t('init.needsTty'));
128
145
  }
129
146
  const plan = await collectPlan();
130
147
  const spin = p.spinner();
131
148
  const labels = {
132
- account: plan.mode === 'register' ? 'Регистрируем владельца' : 'Входим',
133
- vault: 'Шифруем vault',
134
- catalog: 'Копируем каталог персонажей',
135
- playbook: 'Пишем playbook',
136
- skills: 'Ставим стартер-скилл'
149
+ account: t(plan.mode === 'register' ? 'init.progress.register' : 'init.progress.login'),
150
+ vault: t('init.progress.vault'),
151
+ catalog: t('init.progress.catalog'),
152
+ playbook: t('init.progress.playbook'),
153
+ skills: t('init.progress.skills')
137
154
  };
138
- spin.start('Подключаемся…');
155
+ spin.start(t('init.progress.connecting'));
139
156
  try {
140
157
  const result = await applyInit(plan, {
141
158
  onProgress: (step) => {
142
- if (step.startsWith('agent:')) spin.message(`Регистрируем ${step.slice(6)}`);
159
+ if (step.startsWith('agent:')) spin.message(t('init.progress.agent', { name: step.slice(6) }));
143
160
  else spin.message(labels[step] || step);
144
161
  }
145
162
  });
146
- spin.stop('Готово');
163
+ spin.stop(t('init.progress.done'));
147
164
  const agentLines = result.enrolled.length
148
165
  ? result.enrolled.map((item) => ` ${item.id} → ${item.home}`).join('\n')
149
- : ' (никого — добавите позже: olimpyx agent add prometheus)';
166
+ : ` ${t('init.written.noAgents')}`;
150
167
  p.note(
151
168
  [
152
- `Дом владельца: ${result.home}`,
153
- `Скилл:`,
169
+ t('init.written.home', { path: result.home }),
170
+ t('init.written.skill'),
154
171
  ...result.installedSkills.map((path) => ` ${path}`),
155
- 'Агенты:',
172
+ t('init.written.agents'),
156
173
  agentLines,
157
174
  '',
158
- 'Дальше: olimpyx status · olimpyx skill'
175
+ t('init.written.next')
159
176
  ].join('\n'),
160
- 'Что записано'
177
+ t('init.written')
161
178
  );
162
- p.outro('Пароль больше не нужно класть в файлы проекта.');
179
+ p.outro(t('init.outro'));
163
180
  } catch (error) {
164
- spin.stop('Не вышло');
181
+ spin.stop(t('init.progress.failed'));
165
182
  throw error;
166
183
  }
167
184
  }