@goodea/olimpyx 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.
package/src/i18n.js ADDED
@@ -0,0 +1,250 @@
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
+
139
+ 'agent.unknownCharacter': 'No character “{id}”. See olimpyx skill / the catalogue in ~/.olimpyx/characters/INDEX.md',
140
+ 'agent.alreadyAdded': 'Agent {name} is already added'
141
+ },
142
+
143
+ ru: {
144
+ 'init.intro': 'Olimpyx · подключение к городу',
145
+ 'init.cancelled': 'Ничего не записано.',
146
+ 'init.needsTty': 'olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.',
147
+
148
+ 'init.server': 'Сервер',
149
+ 'init.server.protocol': 'Нужен http или https',
150
+ 'init.server.invalid': 'Это не похоже на URL',
151
+
152
+ 'init.account': 'Аккаунт владельца',
153
+ 'init.account.register': 'Создать новый',
154
+ 'init.account.register.hint': 'email + пароль + имя',
155
+ 'init.account.login': 'Войти',
156
+ 'init.account.login.hint': 'уже регистрировались на сайте',
157
+
158
+ 'init.email': 'Email',
159
+ 'init.email.invalid': 'Нужен обычный email',
160
+
161
+ 'init.displayName': 'Отображаемое имя',
162
+ 'init.displayName.hint': 'Как вас видно в городе',
163
+ 'init.displayName.empty': 'Имя не должно быть пустым',
164
+
165
+ 'init.password': 'Пароль',
166
+ 'init.password.short': 'Не короче 12 символов',
167
+ 'init.password.again': 'Пароль ещё раз',
168
+ 'init.password.mismatch': 'Пароли не совпали',
169
+
170
+ 'init.skillScope': 'Куда поставить стартер-скилл',
171
+ 'init.skillScope.global': 'Глобально',
172
+ 'init.skillScope.global.hint': '~/.claude/skills и ~/.agents/skills',
173
+ 'init.skillScope.local': 'В проект',
174
+ 'init.skillScope.local.hint': 'текущая папка, путь можно поправить',
175
+ 'init.projectPath': 'Путь проекта',
176
+ 'init.projectPath.hint': 'Enter — оставить текущий',
177
+
178
+ 'init.hosts': 'Хосты для скилла',
179
+ 'init.characters': 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
180
+ 'init.characters.it': 'IT',
181
+ 'init.characters.industry': 'Отрасли',
182
+
183
+ 'init.summary': 'Сводка',
184
+ 'init.confirm': 'Записать vault, скилл и выбранных агентов?',
185
+
186
+ 'init.progress.connecting': 'Подключаемся…',
187
+ 'init.progress.register': 'Регистрируем владельца',
188
+ 'init.progress.login': 'Входим',
189
+ 'init.progress.vault': 'Шифруем vault',
190
+ 'init.progress.catalog': 'Копируем каталог персонажей',
191
+ 'init.progress.playbook': 'Пишем playbook',
192
+ 'init.progress.skills': 'Ставим стартер-скилл',
193
+ 'init.progress.agent': 'Регистрируем {name}',
194
+ 'init.progress.done': 'Готово',
195
+ 'init.progress.failed': 'Не вышло',
196
+
197
+ 'init.written': 'Что записано',
198
+ 'init.written.home': 'Дом владельца: {path}',
199
+ 'init.written.skill': 'Скилл:',
200
+ 'init.written.agents': 'Агенты:',
201
+ 'init.written.noAgents': '(никого — добавите позже: olimpyx agent add prometheus)',
202
+ 'init.written.next': 'Дальше: olimpyx status · olimpyx skill',
203
+ 'init.outro': 'Пароль больше не нужно класть в файлы проекта.',
204
+
205
+ 'plan.server': 'Сервер: {url}',
206
+ 'plan.account': 'Аккаунт: {mode} · {email}',
207
+ 'plan.account.register': 'новая регистрация',
208
+ 'plan.account.login': 'вход',
209
+ 'plan.name': 'Имя: {name}',
210
+ 'plan.skill': 'Скилл: {where}',
211
+ 'plan.skill.global': 'глобально (~/.claude и ~/.agents)',
212
+ 'plan.skill.local': 'в проекте {path}',
213
+ 'plan.hosts': 'Хосты: {hosts}',
214
+ 'plan.agents': 'Агенты: {agents}',
215
+ 'plan.agents.none': 'пока никого',
216
+ 'plan.none': '—',
217
+
218
+ 'error.emailTaken': 'Этот email уже зарегистрирован. Выберите вход.',
219
+ 'error.badCredentials': 'Неверный email или пароль.',
220
+ 'error.validation': 'Проверьте поля: пароль не короче 12 символов, корректный email, имя не пустое.',
221
+ 'error.rateLimited': 'Слишком много попыток. Подождите немного и повторите.',
222
+ 'error.unreachable': 'Не удалось связаться с {url}. Проверьте сеть и адрес сервера.',
223
+ 'error.unknown': 'Неизвестная ошибка',
224
+
225
+ 'status.notInitialized': 'Запустите olimpyx init',
226
+ 'status.configUnreadable': 'Vault есть, config.json не прочитан',
227
+
228
+ 'agent.unknownCharacter': 'Нет персонажа «{id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md',
229
+ 'agent.alreadyAdded': 'Агент {name} уже добавлен'
230
+ }
231
+ };
232
+
233
+ /**
234
+ * A missing key returns the key itself rather than an empty string or a thrown error: a
235
+ * visible `init.server` in the terminal is a bug report, while silence hides the gap and a
236
+ * throw turns a cosmetic omission into a failed install.
237
+ */
238
+ export function translate(lang, key, vars = {}) {
239
+ const table = MESSAGES[lang] ?? MESSAGES[FALLBACK];
240
+ const template = table[key] ?? MESSAGES[FALLBACK][key] ?? key;
241
+ return template.replace(/\{(\w+)\}/g, (whole, name) =>
242
+ Object.hasOwn(vars, name) ? String(vars[name]) : whole);
243
+ }
244
+
245
+ export function createT(lang) {
246
+ return (key, vars) => translate(lang, key, vars);
247
+ }
248
+
249
+ /** The process-wide translator, bound once from the environment this process was started in. */
250
+ export const t = createT(detectLanguage());
package/src/init-apply.js CHANGED
@@ -6,6 +6,7 @@ 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';
11
12
 
@@ -16,15 +17,15 @@ export function isTransientNetworkError(error) {
16
17
  return Boolean(code && ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(code));
17
18
  }
18
19
 
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 'Слишком много попыток. Подождите немного и повторите.';
20
+ export function friendlyInitError(error, serverUrl = DEFAULT_SERVER, t = defaultT) {
21
+ if (error?.status === 409) return t('error.emailTaken');
22
+ if (error?.status === 401) return t('error.badCredentials');
23
+ if (error?.status === 422) return t('error.validation');
24
+ if (error?.status === 429) return t('error.rateLimited');
24
25
  if (isTransientNetworkError(error) || error instanceof TypeError) {
25
- return `Не удалось связаться с ${serverUrl}. Проверьте сеть и адрес сервера.`;
26
+ return t('error.unreachable', { url: serverUrl });
26
27
  }
27
- return error?.message || 'Неизвестная ошибка';
28
+ return error?.message || t('error.unknown');
28
29
  }
29
30
 
30
31
  export function agentHomeFor(id, plan, env = process.env) {
@@ -32,18 +33,21 @@ export function agentHomeFor(id, plan, env = process.env) {
32
33
  return join(root, 'agents', id);
33
34
  }
34
35
 
35
- export function summarizePlan(plan) {
36
+ export function summarizePlan(plan, t = defaultT) {
36
37
  const characters = (plan.characterIds || []).map((id) => characterById(id)?.name || id);
37
38
  const skillWhere = plan.skillScope === 'global'
38
- ? 'глобально (~/.claude и ~/.agents)'
39
- : проекте ${plan.projectPath}`;
39
+ ? t('plan.skill.global')
40
+ : t('plan.skill.local', { path: plan.projectPath });
40
41
  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(', ') : 'пока никого'}`
42
+ t('plan.server', { url: plan.serverUrl }),
43
+ t('plan.account', {
44
+ mode: t(plan.mode === 'register' ? 'plan.account.register' : 'plan.account.login'),
45
+ email: plan.email
46
+ }),
47
+ plan.displayName ? t('plan.name', { name: plan.displayName }) : null,
48
+ t('plan.skill', { where: skillWhere }),
49
+ t('plan.hosts', { hosts: (plan.hosts || []).join(', ') || t('plan.none') }),
50
+ t('plan.agents', { agents: characters.length ? characters.join(', ') : t('plan.agents.none') })
47
51
  ].filter(Boolean).join('\n');
48
52
  }
49
53
 
@@ -63,10 +67,11 @@ async function authenticate(plan, client) {
63
67
 
64
68
  async function enrollOne(plan, ownerClient, character, env) {
65
69
  const profile = publicProfile(character);
70
+ const installationId = crypto.randomUUID();
66
71
  const enrollment = await ownerClient.request('POST', '/v1/owners/me/enrollment-tokens', { label: character.id });
67
72
  const result = await ownerClient.request('POST', '/v1/agents/enroll', {
68
73
  enrollment_token: enrollment.data.enrollment_token,
69
- installation_id: crypto.randomUUID(),
74
+ installation_id: installationId,
70
75
  profile
71
76
  }, { token: null });
72
77
  const home = agentHomeFor(character.id, plan, env);
@@ -74,12 +79,23 @@ async function enrollOne(plan, ownerClient, character, env) {
74
79
  await state.saveCredential(result.data.agent_token);
75
80
  await state.saveConfig({
76
81
  serverUrl: plan.serverUrl,
82
+ installationId,
77
83
  agentId: result.data.agent.agent_id,
78
84
  profileRevision: result.data.agent.profile_revision,
79
85
  characterId: character.id,
80
86
  ownerId: plan.ownerId ?? null
81
87
  });
82
88
  await state.savePersona(profile, 'init catalog');
89
+ if (character.id === 'archi') {
90
+ for (const [source, destination] of [['archi-citizen.md', 'CITIZEN.md'], ['archi-decide.md', 'DECIDE.md']]) {
91
+ const template = await readFile(new URL(`../data/skill/${source}`, import.meta.url), 'utf8');
92
+ try {
93
+ await writeFile(join(home, destination), template, { mode: 0o600, flag: 'wx' });
94
+ } catch (error) {
95
+ if (error.code !== 'EEXIST') throw error;
96
+ }
97
+ }
98
+ }
83
99
  return {
84
100
  id: character.id,
85
101
  agent_id: result.data.agent.agent_id,
@@ -163,10 +179,10 @@ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, on
163
179
  return { home, config, enrolled, installedSkills };
164
180
  }
165
181
 
166
- export async function readOwnerStatus(env = process.env) {
182
+ export async function readOwnerStatus(env = process.env, t = defaultT) {
167
183
  const initialized = await vaultExists(env);
168
184
  if (!initialized) {
169
- return { initialized: false, hint: 'Запустите olimpyx init' };
185
+ return { initialized: false, hint: t('status.notInitialized') };
170
186
  }
171
187
  try {
172
188
  const config = JSON.parse(await readFile(configPath(env), 'utf8'));
@@ -180,16 +196,16 @@ export async function readOwnerStatus(env = process.env) {
180
196
  agents: config.agents || []
181
197
  };
182
198
  } catch {
183
- return { initialized: true, hint: 'Vault есть, config.json не прочитан' };
199
+ return { initialized: true, hint: t('status.configUnreadable') };
184
200
  }
185
201
  }
186
202
 
187
- export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch } = {}) {
203
+ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch, t = defaultT } = {}) {
188
204
  const character = characterById(id);
189
- if (!character) throw new Error(`Нет персонажа «${id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md`);
205
+ if (!character) throw new Error(t('agent.unknownCharacter', { id }));
190
206
  const vault = await readVault(env);
191
207
  const config = JSON.parse(await readFile(configPath(env), 'utf8'));
192
- if (vault.agents?.[id]) throw new Error(`Агент ${character.name} уже добавлен`);
208
+ if (vault.agents?.[id]) throw new Error(t('agent.alreadyAdded', { name: character.name }));
193
209
  const plan = {
194
210
  serverUrl: config.serverUrl,
195
211
  skillScope: config.skillScope || 'global',
@@ -204,4 +220,3 @@ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = f
204
220
  await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
205
221
  return enrolled;
206
222
  }
207
-
package/src/init.js CHANGED
@@ -2,89 +2,90 @@ import * as p from '@clack/prompts';
2
2
  import { resolve } from 'node:path';
3
3
  import { CHARACTERS } from './characters.js';
4
4
  import { applyInit, DEFAULT_SERVER, summarizePlan } from './init-apply.js';
5
+ import { t } from './i18n.js';
5
6
 
6
7
  function stopped(value) {
7
8
  if (p.isCancel(value)) {
8
- p.cancel('Ничего не записано.');
9
+ p.cancel(t('init.cancelled'));
9
10
  process.exit(0);
10
11
  }
11
12
  return value;
12
13
  }
13
14
 
14
15
  export async function collectPlan({ cwd = process.cwd() } = {}) {
15
- p.intro('Olimpyx · подключение к городу');
16
+ p.intro(t('init.intro'));
16
17
 
17
18
  const serverUrl = String(stopped(await p.text({
18
- message: 'Сервер',
19
+ message: t('init.server'),
19
20
  initialValue: DEFAULT_SERVER,
20
21
  placeholder: DEFAULT_SERVER,
21
22
  validate: (value) => {
22
23
  try {
23
24
  const url = new URL(value);
24
- if (!/^https?:$/.test(url.protocol)) return 'Нужен http или https';
25
+ if (!/^https?:$/.test(url.protocol)) return t('init.server.protocol');
25
26
  } catch {
26
- return 'Это не похоже на URL';
27
+ return t('init.server.invalid');
27
28
  }
28
29
  }
29
30
  }))).replace(/\/$/, '');
30
31
 
31
32
  const mode = stopped(await p.select({
32
- message: 'Аккаунт владельца',
33
+ message: t('init.account'),
33
34
  options: [
34
- { value: 'register', label: 'Создать новый', hint: 'email + пароль + имя' },
35
- { value: 'login', label: 'Войти', hint: 'уже регистрировались на сайте' }
35
+ { value: 'register', label: t('init.account.register'), hint: t('init.account.register.hint') },
36
+ { value: 'login', label: t('init.account.login'), hint: t('init.account.login.hint') }
36
37
  ]
37
38
  }));
38
39
 
39
40
  const email = String(stopped(await p.text({
40
- message: 'Email',
41
+ message: t('init.email'),
41
42
  placeholder: 'you@example.com',
42
- validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : 'Нужен обычный email'
43
+ validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : t('init.email.invalid')
43
44
  }))).trim().toLowerCase();
44
45
 
45
46
  let displayName;
46
47
  if (mode === 'register') {
47
48
  displayName = String(stopped(await p.text({
48
- message: 'Отображаемое имя',
49
- placeholder: 'Как вас видно в городе',
50
- validate: (value) => value.trim() ? undefined : 'Имя не должно быть пустым'
49
+ message: t('init.displayName'),
50
+ placeholder: t('init.displayName.hint'),
51
+ validate: (value) => value.trim() ? undefined : t('init.displayName.empty')
51
52
  }))).trim();
52
53
  }
53
54
 
54
55
  const password = String(stopped(await p.password({
55
- message: 'Пароль',
56
- validate: (value) => value.length >= 12 ? undefined : 'Не короче 12 символов'
56
+ message: t('init.password'),
57
+ validate: (value) => value.length >= 12 ? undefined : t('init.password.short')
57
58
  })));
58
59
  if (mode === 'register') {
59
60
  const again = String(stopped(await p.password({
60
- message: 'Пароль ещё раз',
61
- validate: (value) => value === password ? undefined : 'Пароли не совпали'
61
+ message: t('init.password.again'),
62
+ validate: (value) => value === password ? undefined : t('init.password.mismatch')
62
63
  })));
63
64
  if (again !== password) {
64
- p.cancel('Пароли не совпали.');
65
+ p.cancel(t('init.password.mismatch'));
65
66
  process.exit(0);
66
67
  }
67
68
  }
68
69
 
69
70
  const skillScope = stopped(await p.select({
70
- message: 'Куда поставить стартер-скилл',
71
+ message: t('init.skillScope'),
71
72
  options: [
72
- { value: 'global', label: 'Глобально', hint: '~/.claude/skills и ~/.agents/skills' },
73
- { value: 'local', label: 'В проект', hint: 'текущая папка, путь можно поправить' }
73
+ { value: 'global', label: t('init.skillScope.global'), hint: t('init.skillScope.global.hint') },
74
+ { value: 'local', label: t('init.skillScope.local'), hint: t('init.skillScope.local.hint') }
74
75
  ]
75
76
  }));
76
77
 
77
78
  let projectPath = cwd;
78
79
  if (skillScope === 'local') {
79
80
  projectPath = resolve(String(stopped(await p.text({
80
- message: 'Путь проекта',
81
+ message: t('init.projectPath'),
81
82
  initialValue: cwd,
82
- hint: 'Enter — оставить текущий'
83
+ hint: t('init.projectPath.hint')
83
84
  }))));
84
85
  }
85
86
 
86
87
  const hosts = stopped(await p.multiselect({
87
- message: 'Хосты для скилла',
88
+ message: t('init.hosts'),
88
89
  options: [
89
90
  { value: 'claude', label: 'Claude Code', hint: '.claude/skills' },
90
91
  { value: 'codex', label: 'Codex', hint: '.agents/skills' }
@@ -94,13 +95,13 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
94
95
  }));
95
96
 
96
97
  const characterIds = stopped(await p.groupMultiselect({
97
- message: 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
98
+ message: t('init.characters'),
98
99
  options: {
99
- IT: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
100
+ [t('init.characters.it')]: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
100
101
  value: item.id,
101
102
  label: `${item.name} — ${item.role}`
102
103
  })),
103
- 'Отрасли': CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
104
+ [t('init.characters.industry')]: CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
104
105
  value: item.id,
105
106
  label: `${item.name} — ${item.role}`
106
107
  }))
@@ -110,13 +111,13 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
110
111
  })) || [];
111
112
 
112
113
  const plan = { serverUrl, mode, email, password, displayName, skillScope, projectPath, hosts, characterIds };
113
- p.note(summarizePlan(plan), 'Сводка');
114
+ p.note(summarizePlan(plan, t), t('init.summary'));
114
115
  const ok = stopped(await p.confirm({
115
- message: 'Записать vault, скилл и выбранных агентов?',
116
+ message: t('init.confirm'),
116
117
  initialValue: true
117
118
  }));
118
119
  if (!ok) {
119
- p.cancel('Ничего не записано.');
120
+ p.cancel(t('init.cancelled'));
120
121
  process.exit(0);
121
122
  }
122
123
  return plan;
@@ -124,44 +125,44 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
124
125
 
125
126
  export async function runInit() {
126
127
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
127
- throw new Error('olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.');
128
+ throw new Error(t('init.needsTty'));
128
129
  }
129
130
  const plan = await collectPlan();
130
131
  const spin = p.spinner();
131
132
  const labels = {
132
- account: plan.mode === 'register' ? 'Регистрируем владельца' : 'Входим',
133
- vault: 'Шифруем vault',
134
- catalog: 'Копируем каталог персонажей',
135
- playbook: 'Пишем playbook',
136
- skills: 'Ставим стартер-скилл'
133
+ account: t(plan.mode === 'register' ? 'init.progress.register' : 'init.progress.login'),
134
+ vault: t('init.progress.vault'),
135
+ catalog: t('init.progress.catalog'),
136
+ playbook: t('init.progress.playbook'),
137
+ skills: t('init.progress.skills')
137
138
  };
138
- spin.start('Подключаемся…');
139
+ spin.start(t('init.progress.connecting'));
139
140
  try {
140
141
  const result = await applyInit(plan, {
141
142
  onProgress: (step) => {
142
- if (step.startsWith('agent:')) spin.message(`Регистрируем ${step.slice(6)}`);
143
+ if (step.startsWith('agent:')) spin.message(t('init.progress.agent', { name: step.slice(6) }));
143
144
  else spin.message(labels[step] || step);
144
145
  }
145
146
  });
146
- spin.stop('Готово');
147
+ spin.stop(t('init.progress.done'));
147
148
  const agentLines = result.enrolled.length
148
149
  ? result.enrolled.map((item) => ` ${item.id} → ${item.home}`).join('\n')
149
- : ' (никого — добавите позже: olimpyx agent add prometheus)';
150
+ : ` ${t('init.written.noAgents')}`;
150
151
  p.note(
151
152
  [
152
- `Дом владельца: ${result.home}`,
153
- `Скилл:`,
153
+ t('init.written.home', { path: result.home }),
154
+ t('init.written.skill'),
154
155
  ...result.installedSkills.map((path) => ` ${path}`),
155
- 'Агенты:',
156
+ t('init.written.agents'),
156
157
  agentLines,
157
158
  '',
158
- 'Дальше: olimpyx status · olimpyx skill'
159
+ t('init.written.next')
159
160
  ].join('\n'),
160
- 'Что записано'
161
+ t('init.written')
161
162
  );
162
- p.outro('Пароль больше не нужно класть в файлы проекта.');
163
+ p.outro(t('init.outro'));
163
164
  } catch (error) {
164
- spin.stop('Не вышло');
165
+ spin.stop(t('init.progress.failed'));
165
166
  throw error;
166
167
  }
167
168
  }