From 2c1adccdaa054fede9b5132c6d6603e4ac7288e6 Mon Sep 17 00:00:00 2001 From: leemour Date: Sat, 17 Oct 2026 13:41:19 +0200 Subject: [PATCH] =?UTF-8?q?refactor!:=20cli-core=200.319.0=20=E2=80=94=20n?= =?UTF-8?q?o=20default=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli-core 0.319.0 stops reading default_profile: a config that still has it stops a command naming no profile, with the profile to type and `max config unset default_profile`. A profile called `default` is an ordinary name, so `mcp config` names its server `max-default`. - `max config set default_profile` is refused; `unset` still removes a leftover one (max's own changeSetting mirrors cli-core's). - SessionStore no longer falls back to profile "default": callers pass the profile. Only tests relied on the fallback. - Dropped the unused DEFAULT_PROFILE constant. - Docs, skill and the commands.md intro no longer offer default_profile. - CHANGELOG: what to do instead (`max …`, unset), plus the 0.317.0 task-rule and edit-merge fixes users will notice. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ README.md | 3 +-- docs/bot.md | 2 +- docs/commands.md | 4 ++-- docs/configuration-reference.md | 8 +++----- docs/configuration.md | 4 ---- docs/dev/ARCHITECTURE.md | 4 ++-- docs/profiles.md | 3 +-- docs/sessions.md | 7 +++---- docs/usage.md | 5 ++--- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- scripts/commands.ts | 2 +- skills/max-cli/SKILL.md | 2 +- src/adapter/contract.test.ts | 8 +++++++- src/client.test.ts | 27 +++++++++++++++++++++++---- src/config.test.ts | 28 ++++++++++++---------------- src/config.ts | 19 +++++++++++-------- src/messenger.test.ts | 5 ++--- src/output.test.ts | 8 +++++++- src/profile.ts | 4 +--- src/program.test.ts | 9 +++++---- src/protocol/parity.test.ts | 8 +++++++- src/record.test.ts | 8 +++++++- src/run-log.test.ts | 16 ++++++++++++++-- src/session/adopt.test.ts | 1 + src/session/store.ts | 4 ++-- 27 files changed, 136 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da00b8c..b4ac590e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,22 @@ ## Не выпущено +### Изменено — может сломать скрипты + +- **Профиль по умолчанию больше не выбирается** (cli-core 0.319.0). Настройка `default_profile` не читается: + если она осталась в файле настроек, команда без имени профиля останавливается и подсказывает, что набрать. + Называйте профиль первым словом — `max <профиль> …` — или задайте `MAX_PROFILE`, а старую настройку уберите: + `max config unset default_profile`. `max config set default_profile` теперь отказывает. +- **Профиль с именем `default` — обычное имя.** `max default mcp config` называет MCP-сервер `max-default`, а не + `max`; запись, сделанная раньше, работает, пока `mcp setup` не перепишет её под новым именем. + ### Исправлено +- **Задачи открываются только там, где что-то спросили у вас** (cli-core 0.317.0). В личном чате, как и раньше, + — каждый вопрос без ответа; в группе — только ответ на ваше сообщение и упоминание вас; в канале и «Избранном» + — ничего. Раньше любой вопрос без ответа в любом чате становился вашей задачей. +- **Правка сообщения не стирает то, чего в ней нет** (cli-core 0.317.0): время отправки, реакции и служебные поля + сохранённого сообщения остаются прежними. - **Инструменты `max bot mcp` учитывают окружение сервера.** Раньше вызов шёл мимо переменных, с которыми запущен сервер: например, при `MAX_READ_ONLY=1` бот всё равно отправлял сообщение. Теперь вызов через MCP ведёт себя так же, как та же команда в терминале. diff --git a/README.md b/README.md index b112d089..1b59ec86 100644 --- a/README.md +++ b/README.md @@ -278,8 +278,7 @@ max personal session start qr # повторный вход, когда ``` Профиль называется **первым словом** в каждой команде — `max personal chats list`, — или -`export MAX_PROFILE=personal` на всю сессию оболочки, или `max config set default_profile personal` -насовсем. `max` никогда не выбирает профиль сам, даже единственный: без имени команда останавливается +`export MAX_PROFILE=personal` на всю сессию оболочки. `max` никогда не выбирает профиль сам, даже единственный: без имени команда останавливается и перечисляет профили этого компьютера. Имя `personal` — пример: любое подойдёт. Подробнее: [вход и сессии](https://github.com/WireCatLabs/max-cli/blob/main/docs/sessions.md) ([wirecat.dev](https://wirecat.dev/ru/docs/max/sessions)), [профили](https://github.com/WireCatLabs/max-cli/blob/main/docs/profiles.md) ([wirecat.dev](https://wirecat.dev/ru/docs/max/profiles)). diff --git a/docs/bot.md b/docs/bot.md index e3f2e299..7cb4774e 100644 --- a/docs/bot.md +++ b/docs/bot.md @@ -83,7 +83,7 @@ max support bot messages send user:4815162342 "Ваша заявка приня max sales bot list --check # все имена с токеном бота и какой бот за каждым ``` -Имя профиля обязательно: первое слово, `MAX_PROFILE` на всю сессию оболочки или настройка `default_profile`. Без него команда бота останавливается и перечисляет профили. +Имя профиля обязательно: первое слово или `MAX_PROFILE` на всю сессию оболочки. Без него команда бота останавливается и перечисляет профили. ## Токен diff --git a/docs/commands.md b/docs/commands.md index 0b76e5aa..34f5a929 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,7 +15,7 @@ max [профиль] [опции] <команда> <действие> [аргу **Первое слово — профиль**, если оно не совпадает с именем команды: `max personal chats list` читает чаты профиля `personal`. То же самое говорит переменная `MAX_PROFILE` на весь сеанс -оболочки, а `defaultProfile` в файле настроек — насовсем (`max config set defaultProfile personal`). +оболочки. **Профиль никогда не подразумевается**: если его не назвать, команда, которой нужен аккаунт, останавливается и перечисляет профили этого компьютера — даже если профиль один. Первый вход тоже с именем: `max personal setup`. Без профиля работают только команды, которым аккаунт не нужен: @@ -2540,7 +2540,7 @@ max tasks categories ### `max tasks apply` -the task rules over a chat's newest stored messages: a question nobody answered and a mention of you open a task, and your answer closes one — for a chat the first sync left alone (config task_first_pass_per_chat). Lists what would open and changes nothing unless --yes; with --yes the duplicate check runs as on a sync. Reads only the local store +the task rules over a chat's newest stored messages: in a one-to-one chat a question you did not answer, in a group only a question in reply to you and a mention of you, open a task, and your answer closes one; a channel opens none — for a chat the first sync left alone (config task_first_pass_per_chat). Lists what would open and changes nothing unless --yes; with --yes the duplicate check runs as on a sync. Reads only the local store **Меняет что-то только на этом компьютере.** diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index c4050eda..0a340dd6 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -60,7 +60,7 @@ max config show --json # то же одним объектом Против каждой настройки — откуда она: `flag`, `default` или `config file:` с ключом, из которого она прочитана, например `config file: bot.profiles.test`. Против профиля — `first word`, -`MAX_PROFILE`, `MAX_PROFILE_LOCK`, `config file: default_profile` или `none named` — профиль не назван, +`MAX_PROFILE`, `MAX_PROFILE_LOCK` или `none named` — профиль не назван, и `profile` тогда `null`. С `--json` в ответе есть и `store_settings` — настройки общего архива (`search_stemmers.*`, `task_agent_close*`) и @@ -94,7 +94,6 @@ max config show --json # то же одним объектом ```json { - "default_profile": "personal", "defaults": { "keep_runs_for_days": 14 }, "profiles": { "personal": { "limit": 50, "timeout_ms": 20000, "color": true }, @@ -117,7 +116,6 @@ max config show --json # то же одним объектом | Поле | Что делает | Что перекрывает на один запуск | По умолчанию | |---|---|---|---| -| `default_profile` | какой профиль, если первым словом ничего не названо и `MAX_PROFILE` не задан | первое слово (`max work …`), `MAX_PROFILE` | нет: без имени команда перечисляет профили | | `limit` | сколько записей показывать, когда `--limit` не передан | `--limit` | `20` | | `timeout_ms` | сколько ждать ответа на **один запрос** | — (`--timeout` — другое, см. ниже) | берётся из транспорта | | `color` | цвет в терминале; без поля решается по тому, терминал ли это | —; без поля цвет выключает `NO_COLOR` | по терминалу | @@ -248,7 +246,7 @@ max work config unset limit # убрать; снова решает max agent config set permissions.messages readonly # чтение сообщений без записи max shop config set --bot sends_per_hour 200 # только боту shop max config set --personal --defaults limit 30 # всем личным аккаунтам -max config set default_profile work # какой профиль без первого слова +max config unset default_profile # убрать старую настройку: профиль больше не подразумевается ``` Значение проверяется той же схемой, что и при чтении, **до записи**: `max personal config set limit 0` @@ -333,7 +331,7 @@ max personal chats list | Ключ | Допустимый тип или значение | Область | |---|---|---| -| `default_profile` | строка с именем профиля | корень файла | +| `default_profile` | не читается: команда без имени профиля останавливается — `max config unset default_profile` | корень файла | | `limit`, `timeout_ms`, `keep_runs_for_days`, `sends_per_hour` | целое число ≥ 1 | профиль | | `requests_per_minute` | целое число ≥ 0 | профиль | | `color`, `record` | boolean | профиль | diff --git a/docs/configuration.md b/docs/configuration.md index 65d73e46..79a9b1f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,7 +36,6 @@ | `serve` | запускать фоновый сервер `max personal serve`, когда команде нужен MAX ([один вход на всё](limits.md#один-вход-на-всё)) | `true` | | `transcribe_model` | какой моделью распознавать голосовые сообщения | `gigaam-v3` | | `update_check` | раз в сутки сообщать, что вышла новая версия `max` | `true` | -| `default_profile` | какой профиль берёт команда, если профиль не назван | нет: без имени команда перечисляет профили | Секрета в файле не бывает. В нём нет места для токена, номера телефона или идентификатора чата: токен хранится в ключнице системы, а где её нет — в файле, который можете читать только вы ([где живёт токен](security.md#где-живёт-токен)). @@ -67,7 +66,6 @@ ```json { - "default_profile": "personal", "defaults": { "limit": 20, "keep_runs_for_days": 14, @@ -104,8 +102,6 @@ Что делает каждая часть: -- `default_profile`: `max chats list` без имени профиля работает с `personal`. Без этой настройки и - без `MAX_PROFILE` такая команда остановится и перечислит профили. - `defaults`: эти значения получает каждый профиль, если не задал своё. `update_check`, `skill_hint` и `transcribe_model` допустимы только здесь: программа и модели речи одни на все профили. - `profiles.personal`: больше строк, цвета и запись каждого запуска — только для `personal`. diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 89e7e6c4..85eed56d 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -488,8 +488,8 @@ common settings resolve through cli-core `settingsFor`. MAX retains its strict c (`src/config.ts`). Commands take what they are given; one that re-derived the order would disagree. Every field, variable and the file format: [`../configuration.md`](../configuration.md); profiles and paging for users: [`../usage.md`](../usage.md). The profile follows the same order: the first word -(`max personal chats list`), then `MAX_PROFILE=personal`, then `"defaultProfile"` in the file, then -`default`. +(`max personal chats list`), then `MAX_PROFILE=personal`; nothing else picks one. A leftover +`default_profile` in the file stops a command that names none, with the profile to type. ### The profile is the first word, not a flag diff --git a/docs/profiles.md b/docs/profiles.md index 3177f849..3bfe4bb6 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -3,8 +3,7 @@ **Профиль** — имя для одного входа `max` на этом компьютере: личного аккаунта MAX или бота, со своей сессией и своими настройками. Имя выбираете вы при первом входе — `max personal setup` — и повторяете его в каждой команде: `max` никогда не выбирает профиль сам, даже единственный. Чтобы не -писать имя каждый раз, задайте `MAX_PROFILE` на сеанс оболочки или -`max config set default_profile personal` насовсем. +писать имя каждый раз, задайте `MAX_PROFILE` на сеанс оболочки. Прочитайте эту страницу, если вам нужен второй аккаунт, если ИИ-агенту нужно меньше прав, чем вам, или если вы хотите вести бота. В конце вы знаете, как выбрать профиль для команды, как дать diff --git a/docs/sessions.md b/docs/sessions.md index 794f3259..5fdecc75 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -15,8 +15,8 @@ - **Ключница** (keyring) — хранилище паролей операционной системы: Keychain в macOS, Secret Service (GNOME Keyring, KWallet) в Linux, диспетчер учётных данных в Windows. - **Профиль** — имя для одного входа на этом компьютере, со своим токеном, состоянием и - настройками. Имя называется в каждой команде (`max personal …`), `MAX_PROFILE` или - `default_profile`; сам `max` профиль не выбирает. Второй профиль нужен только для второго + настройками. Имя называется в каждой команде (`max personal …`) или + `MAX_PROFILE`; сам `max` профиль не выбирает. Второй профиль нужен только для второго аккаунта или для отдельных ограничений ([профили](#профили)). Не путайте `max personal session` и `max personal account sessions`. `max personal session` — вход самого `max`; @@ -146,8 +146,7 @@ export MAX_PROFILE=personal # или на всю сессию оболочки имя. Иначе `max personal chats` молча значило бы «профиль chats без команды». Имя состоит из латинских букв, цифр, точки, дефиса и подчёркивания и начинается с буквы или цифры. -Порядок, первое совпадение побеждает: первое слово, `MAX_PROFILE`, `default_profile` в файле -настроек. Если ничего из этого нет, команда, которой нужен аккаунт, завершается с кодом `2` и +Порядок, первое совпадение побеждает: первое слово, `MAX_PROFILE`. Если ничего из этого нет, команда, которой нужен аккаунт, завершается с кодом `2` и перечисляет профили этого компьютера — даже когда профиль один: ```text diff --git a/docs/usage.md b/docs/usage.md index 1cf3bb49..fc31e613 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -158,7 +158,7 @@ max work chats list # профиль work профиль нельзя назвать `chats` — при создании такое имя будет отвергнуто, с объяснением. `max` никогда не выбирает профиль сам, даже если он один: без имени команда останавливается и -перечисляет профили этого компьютера. Имя даёт первое слово, `MAX_PROFILE` или `default_profile`. +перечисляет профили этого компьютера. Имя даёт первое слово или `MAX_PROFILE`. Для целой сессии оболочки удобнее переменная: @@ -1521,7 +1521,6 @@ max personal runs path # каталог, для jq и grep ```json { - "default_profile": "personal", "profiles": { "personal": { "limit": 50, "timeout_ms": 20000, "color": true, "record": false, "keep_runs_for_days": 30 } } @@ -1531,7 +1530,7 @@ max personal runs path # каталог, для jq и grep Порядок, в котором решается любая настройка: **флаг → переменная окружения → файл → встроенное значение**. В файле профиль сильнее общих `defaults`, а разделы `personal` и `bot` задают значения отдельно для личного аккаунта и для ботов (`max personal config set --personal …`, `--bot …`). Все поля, -`default_profile` и `permissions` в том числе, — [настройки](configuration.md). Опечатка в имени поля — +`permissions` в том числе, — [настройки](configuration.md). Опечатка в имени поля — ошибка с именем поля, а не молчаливое значение по умолчанию. **Секрета в этом файле быть не может**: в схеме нет поля, куда его положить. diff --git a/package.json b/package.json index 368eca05..d3f33b9e 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "@msgpack/msgpack": "3.1.3", "@valibot/to-json-schema": "1.8.0", "@wirecat/cli-base": "0.40.2", - "@wirecat/cli-core": "0.315.0", + "@wirecat/cli-core": "0.319.0", "commander": "15.0.0", "lossless-json": "4.3.1", "ogg-opus-decoder": "1.7.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af4dcff..b692d9a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 0.40.2 version: 0.40.2(commander@15.0.0)(typescript@7.0.2) '@wirecat/cli-core': - specifier: 0.315.0 - version: 0.315.0(@napi-rs/canvas@1.0.11)(@wirecat/cli-base@0.40.2(commander@15.0.0)(typescript@7.0.2))(cac@6.7.14)(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5) + specifier: 0.319.0 + version: 0.319.0(@napi-rs/canvas@1.0.11)(@wirecat/cli-base@0.40.2(commander@15.0.0)(typescript@7.0.2))(cac@6.7.14)(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5) commander: specifier: 15.0.0 version: 15.0.0 @@ -1330,8 +1330,8 @@ packages: '@wirecat/cli-core-sqlite@1.0.1': resolution: {integrity: sha512-dFkTE3+XdvZwrt7bZZEd2fzdqR2vFgnnGPOhmBKGNa7uKQHjOdqJL8Q9O5cajIjy5o8Gl0LVn0OZbYajhLvQ1A==} - '@wirecat/cli-core@0.315.0': - resolution: {integrity: sha512-H/cfFsHHAyZXOeOGiab3ZnIFR7r/umWlzIu76dUgr8SfD3EkG+8oEvEv/wg8dNjPuWaGekGfV14H8ptqlbd/uw==} + '@wirecat/cli-core@0.319.0': + resolution: {integrity: sha512-bpWbSCMSiEoaIRQGDHjgMuKqPiitVI2D/8+Nt4RXZfULxekW6ThPQXiu8cSp3rHvKcMb5LGT+CpNJMjjljf1ig==} engines: {node: ^22.16.0 || >=24} hasBin: true peerDependencies: @@ -3539,7 +3539,7 @@ snapshots: '@wirecat/cli-core-sqlite@1.0.1': {} - '@wirecat/cli-core@0.315.0(@napi-rs/canvas@1.0.11)(@wirecat/cli-base@0.40.2(commander@15.0.0)(typescript@7.0.2))(cac@6.7.14)(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5)': + '@wirecat/cli-core@0.319.0(@napi-rs/canvas@1.0.11)(@wirecat/cli-base@0.40.2(commander@15.0.0)(typescript@7.0.2))(cac@6.7.14)(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.131.0(zod@4.6.5) '@bomb.sh/tab': 0.0.22(cac@6.7.14)(commander@15.0.0) diff --git a/scripts/commands.ts b/scripts/commands.ts index d68e368e..6fb1516d 100644 --- a/scripts/commands.ts +++ b/scripts/commands.ts @@ -46,7 +46,7 @@ max [профиль] [опции] <команда> <действие> [аргу **Первое слово — профиль**, если оно не совпадает с именем команды: \`max personal chats list\` читает чаты профиля \`personal\`. То же самое говорит переменная \`MAX_PROFILE\` на весь сеанс -оболочки, а \`defaultProfile\` в файле настроек — насовсем (\`max config set defaultProfile personal\`). +оболочки. **Профиль никогда не подразумевается**: если его не назвать, команда, которой нужен аккаунт, останавливается и перечисляет профили этого компьютера — даже если профиль один. Первый вход тоже с именем: \`max personal setup\`. Без профиля работают только команды, которым аккаунт не нужен: diff --git a/skills/max-cli/SKILL.md b/skills/max-cli/SKILL.md index 628c1d62..2cb94a84 100644 --- a/skills/max-cli/SKILL.md +++ b/skills/max-cli/SKILL.md @@ -119,7 +119,7 @@ remove|admins|update|link reset|requests accept|requests decline` видят д 1. **Id — всегда строки.** Id сообщений 18-значные, больше `Number.MAX_SAFE_INTEGER`. Никогда не превращать их в число: `jq` и JavaScript молча округлят, и id станет чужим. 2. **Первое слово — профиль, если это не команда.** `max work chats list` — профиль `work`. - Флага `--profile` нет; то же делает переменная `MAX_PROFILE` или настройка `default_profile`. + Флага `--profile` нет; то же делает переменная `MAX_PROFILE`. **Профиль не подразумевается, даже единственный:** без имени команда завершается с кодом `2` и перечисляет профили (`error.profiles`) — спросите владельца, какой нужен, не берите первый. Без профиля работают `--help`, `commands`, `skill show`, `account list`, `config show`. Имя профиля — латиница, цифры, diff --git a/src/adapter/contract.test.ts b/src/adapter/contract.test.ts index e3f319b4..c3a2d07c 100644 --- a/src/adapter/contract.test.ts +++ b/src/adapter/contract.test.ts @@ -20,7 +20,13 @@ afterEach(async () => { const connect = async (seed: Seed) => { const dir = mkdtempSync(join(tmpdir(), "max-contract-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) if (seed.account) { store.writeToken("a-token") store.writeState({ ...store.readState(), viewerId: seed.account.id }) diff --git a/src/client.test.ts b/src/client.test.ts index e2c24b34..879bf2d4 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -29,7 +29,7 @@ const historyAnswer = { const clientWith = (max: ReturnType, token = "a-token") => { const dir = mkdtempSync(join(tmpdir(), "max-cli-")) const keyring = memoryKeyring() - const store = new SessionStore({ keyring, configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ profile: "default", keyring, configDir: dir, stateDir: join(dir, "state"), env: {} }) if (token) store.writeToken(token) const notes: string[] = [] @@ -923,6 +923,7 @@ describe("the token MAX answers with", () => { const dir = mkdtempSync(join(tmpdir(), "max-cli-")) const keyring = memoryKeyring() const store = new SessionStore({ + profile: "default", keyring, configDir: dir, stateDir: join(dir, "state"), @@ -941,7 +942,13 @@ describe("the token MAX answers with", () => { expect(handedOut.token).toBeUndefined() - const unset = new SessionStore({ keyring, configDir: dir, stateDir: join(dir, "state"), env: {} }) + const unset = new SessionStore({ + profile: "default", + keyring, + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) expect(unset.readToken()).toBeUndefined() expect(readdirSync(dir).filter((file) => file !== "state")).toEqual([]) expect(notes.join(" ")).toContain("MAX_TOKEN") @@ -1034,7 +1041,13 @@ describe("with a shared record", () => { } const clientSharing = (record: MaxRecord, max: ReturnType) => { const dir = mkdtempSync(join(tmpdir(), "max-cli-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) store.writeToken("a-token") return new MaxClient({ store, @@ -1254,7 +1267,13 @@ describe("one event per request", () => { it("keeps working when whoever is listening throws", async () => { const max = mockMax({ answers: { [Opcode.SESSION_INIT]: {}, [Opcode.LOGIN]: loginAnswer } }) const dir = mkdtempSync(join(tmpdir(), "max-cli-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) store.writeToken("a-token") const client = new MaxClient({ diff --git a/src/config.test.ts b/src/config.test.ts index 9308ccf4..8d8bb476 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -47,10 +47,12 @@ describe("where a setting came from", () => { expect(settings({ profile: "work" }, { MAX_PROFILE: "other" }).sources.profile).toBe("first word") }) - it("credits the file when the file is what decided it", () => { + it("**stops on a leftover default_profile** with the profile to type and how to remove it", () => { withConfig(JSON.stringify({ default_profile: "work" })) - expect(settings().profile).toBe("work") - expect(settings().sources.profile).toBe("config file: default_profile") + expect(() => resolveSettings({}, { env: {}, configDir })).toThrow( + "default_profile is no longer read — name the profile: `max work …`; `max config unset default_profile`", + ) + expect(resolveSettings({ profile: "work" }, { env: {}, configDir }).profile).toBe("work") }) it("**names where the command budget came from**, which has no file row to fall back on", () => { @@ -154,11 +156,6 @@ describe("the order a setting is decided in", () => { expect(settings({}, { MAX_PROFILE: "fromEnv" }).profile).toBe("fromEnv") }) - it("prefers the file to the built-in default", () => { - withConfig(JSON.stringify({ default_profile: "fromFile" })) - expect(settings().profile).toBe("fromFile") - }) - it("falls back to `default`, which is what an unconfigured machine has", () => { expect(settings().profile).toBe("default") }) @@ -297,12 +294,11 @@ describe("where each setting came from", () => { expect(settings().configFound).toBe(true) }) - it("names what chose the profile: the first word, then MAX_PROFILE, then the file", () => { - withConfig(JSON.stringify({ default_profile: "home", profiles: { home: {}, work: {} } })) + it("names what chose the profile: the first word, then MAX_PROFILE", () => { + withConfig(JSON.stringify({ profiles: { home: {}, work: {} } })) expect(settings({ profile: "work" }, { MAX_PROFILE: "home" }).sources.profile).toBe("first word") expect(settings({}, { MAX_PROFILE: "work" }).sources.profile).toBe("MAX_PROFILE") - expect(settings().sources.profile).toBe("config file: default_profile") expect(settings().configuredProfiles).toEqual(["home", "work"]) }) }) @@ -426,12 +422,12 @@ describe("changing a setting", () => { expect(() => changeSetting(path(), { profile: "me", setting: "read_other_bots", value: "true" })).toThrow(/--bot/) }) - it("sets and clears default_profile at the top of the file", () => { - expect(changeSetting(path(), { profile: "mila", setting: "default_profile", value: "mila" })).toBe("mila") - expect(file().default_profile).toBe("mila") - expect(() => changeSetting(path(), { profile: undefined, setting: "default_profile", value: "a/b" })).toThrow( - /profile name/, + it("refuses to set default_profile and still clears a leftover one", () => { + withConfig(JSON.stringify({ default_profile: "mila", profiles: {} })) + expect(() => changeSetting(path(), { profile: undefined, setting: "default_profile", value: "mila" })).toThrow( + /default_profile is gone/, ) + expect(file().default_profile).toBe("mila") changeSetting(path(), { profile: undefined, setting: "default_profile", value: undefined }) expect(file().default_profile).toBeUndefined() }) diff --git a/src/config.ts b/src/config.ts index 599f25ca..7a88d983 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,6 @@ import type { Level } from "@wirecat/cli-core/sends" import * as v from "valibot" import { MAX_APP } from "./app.js" import { knownProfiles } from "./diagnose.js" -import { usableProfileName } from "./profile.js" import { DEFAULT_MODEL, MODELS } from "./transcribe/models.js" const plain = @@ -347,7 +346,7 @@ export const scopePath = ({ profile, kind }: SettingScope): string => /** * Sets or removes one setting of one profile, or of `defaults`, and writes the file back. - * `defaultProfile` is the one top-level key, and takes no scope. + * `default_profile` can only be unset: it is the one top-level key, takes no scope, and is no longer read. * * **The value is checked by the same schema that reads the file**, on the whole result, before * anything is written — so `config set` cannot produce a file the next command refuses to load. @@ -449,12 +448,16 @@ export const changeSetting = ( : ((scope.permissions as Record | undefined)?.[permission] ?? null) } -const changeDefaultProfile = (path: string, config: Config, value: string | undefined): string | null => { - const changed = { ...config } - if (value === undefined) delete changed.default_profile - else changed.default_profile = usableProfileName(value) - saveConfigFile(path, changed) - return changed.default_profile ?? null +const changeDefaultProfile = (path: string, config: Config, value: string | undefined): null => { + if (value !== undefined) { + throw new CliError( + "validation_error", + "default_profile is gone: a command names its profile every time — `max …` or MAX_PROFILE", + ) + } + const { default_profile: _, ...rest } = config + saveConfigFile(path, rest) + return null } /** `send,reaction` as the owner types it, or the JSON array the file holds; blank is the empty list. */ diff --git a/src/messenger.test.ts b/src/messenger.test.ts index b014a379..77e7d1cf 100644 --- a/src/messenger.test.ts +++ b/src/messenger.test.ts @@ -43,16 +43,15 @@ const SHARED = [ ] as const const CONFIG = { - default_profile: "work", defaults: { limit: 7, sends_per_hour: 3 }, profiles: { work: { permissions: { messages: "readonly" }, record: true }, home: {} }, } describe("the MAX messenger's settings", () => { it.each([ - ["no flags", {}, {}], + ["only the profile", { profile: "work" }, {}], ["a profile flag", { profile: "home" }, {}], - ["flags over the file", { limit: 2, json: true, trace: true, record: false }, {}], + ["flags over the file", { profile: "work", limit: 2, json: true, trace: true, record: false }, {}], ["the environment", {}, { MAX_PROFILE: "home", MAX_TIMEOUT: "10s" }], ])("answer what max's own settings answer, with %s", (_, flags, env) => { const configDir = mkdtempSync(join(tmpdir(), "max-messenger-")) diff --git a/src/output.test.ts b/src/output.test.ts index 3910d64f..64bafd1f 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -34,7 +34,13 @@ const readingAChatThatAnswersOddly = async (options: { quiet?: boolean; json?: b }) const dir = mkdtempSync(join(tmpdir(), "max-output-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) store.writeToken("a-token") const client = new MaxClient({ diff --git a/src/profile.ts b/src/profile.ts index de0e916a..9e4e4562 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -1,8 +1,6 @@ import { CliError } from "@wirecat/cli-base" import type { Command } from "commander" -export const DEFAULT_PROFILE = "default" - /** How the command line names this profile: always the first word, since none is assumed. */ export const asFirstWord = (profile: string): string => `${profile} ` @@ -38,7 +36,7 @@ export const commandWords = (program: Command): Set => /** * **The first word is the profile unless it names a command.** `max personal chats list`, with no - * flag; without it, `MAX_PROFILE` or `defaultProfile` must name one. + * flag; without it, `MAX_PROFILE` must name one. * * An option is never a profile, so `max --json chats list` still works. */ diff --git a/src/program.test.ts b/src/program.test.ts index 8ebb36c7..8abf91b4 100644 --- a/src/program.test.ts +++ b/src/program.test.ts @@ -508,14 +508,15 @@ describe("the program", () => { } }) - it("`config set default_profile` picks the profile a bare `max` uses", async () => { + it("refuses `config set default_profile`, so a bare `max` still names no profile", async () => { const { MAX_PROFILE } = process.env delete process.env.MAX_PROFILE try { - await runWith(["config", "set", "default_profile", "mila"]) + const set = await runWith(["config", "set", "default_profile", "mila", "--json"]) + expect(JSON.parse(set.stderr).error.message).toMatch(/default_profile is gone/) const shown = JSON.parse((await runWith(["config", "show", "--json"])).stdout).item - expect(shown).toMatchObject({ profile: "mila", profile_from: "config file: default_profile" }) - await runWith(["config", "unset", "default_profile"]) + expect(shown).toMatchObject({ profile: null, profile_from: "none named" }) + expect((await runWith(["config", "unset", "default_profile"])).code).toBe(0) } finally { process.env.MAX_PROFILE = MAX_PROFILE } diff --git a/src/protocol/parity.test.ts b/src/protocol/parity.test.ts index 5c79add5..f305721c 100644 --- a/src/protocol/parity.test.ts +++ b/src/protocol/parity.test.ts @@ -79,7 +79,13 @@ describe("our requests beside web.max.ru's", async () => { }, }) const dir = mkdtempSync(join(tmpdir(), "max-cli-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) store.writeToken("a-token") const record = maxRecord({ account: () => store.readState().viewerId, diff --git a/src/record.test.ts b/src/record.test.ts index c2e3cae7..fc6989d9 100644 --- a/src/record.test.ts +++ b/src/record.test.ts @@ -32,7 +32,13 @@ class Served extends Connection { const setUp = () => { const dir = mkdtempSync(join(tmpdir(), "max-record-")) const env = { MESSAGING_STORE: join(dir, "messages.db") } - const session = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), env: {} }) + const session = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "state"), + env: {}, + }) session.writeToken("a-token") const loginOnce = async ( served = false, diff --git a/src/run-log.test.ts b/src/run-log.test.ts index 17b8eecf..b57e1f3e 100644 --- a/src/run-log.test.ts +++ b/src/run-log.test.ts @@ -64,7 +64,13 @@ describe("MAX's refusal, as a key", () => { const run = async (refusal: string) => { const max = mockMax({ answers, refuse: { [Opcode.CHAT_HISTORY]: refusal } }) const dir = mkdtempSync(join(tmpdir(), "max-run-log-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "s"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "s"), + env: {}, + }) store.writeToken("a-token") const events: DiagnosticEvent[] = [] const client = new MaxClient({ @@ -156,7 +162,13 @@ describe("a warning", () => { refuse: { [Opcode.MSG_GET_REACTIONS]: "proto.payload" }, }) const dir = mkdtempSync(join(tmpdir(), "max-run-log-")) - const store = new SessionStore({ keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "s"), env: {} }) + const store = new SessionStore({ + profile: "default", + keyring: memoryKeyring(), + configDir: dir, + stateDir: join(dir, "s"), + env: {}, + }) store.writeToken("a-token") const events: DiagnosticEvent[] = [] const client = new MaxClient({ diff --git a/src/session/adopt.test.ts b/src/session/adopt.test.ts index d6ead5b8..ae6d069e 100644 --- a/src/session/adopt.test.ts +++ b/src/session/adopt.test.ts @@ -15,6 +15,7 @@ const loginAnswer = { profile: { contact: { id: 10000001, names: [{ name: "Test const setup = (max: ReturnType, existing?: string) => { const dir = mkdtempSync(join(tmpdir(), "max-cli-")) const store = new SessionStore({ + profile: "default", keyring: memoryKeyring(), configDir: dir, stateDir: join(dir, "state"), diff --git a/src/session/store.ts b/src/session/store.ts index 7473a8b7..f1dd94f9 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -54,7 +54,7 @@ export const withLoginRefused = (state: SessionState, now = Date.now()): Session export const withoutLoginPause = ({ loginRefusals, loginPausedUntil, ...state }: SessionState): SessionState => state export interface SessionStoreOptions { - profile?: string + profile: string env?: NodeJS.ProcessEnv keyring?: KeyringStore /** Overrides the config and state directories; tests pass a temporary one. */ @@ -69,7 +69,7 @@ export class SessionStore { readonly #stateDir: string readonly #credentials: Credentials - constructor({ profile = "default", env = process.env, keyring, configDir, stateDir }: SessionStoreOptions = {}) { + constructor({ profile, env = process.env, keyring, configDir, stateDir }: SessionStoreOptions) { const paths = resolvePaths({ appName: APP, prefix: "MAX", env }) this.profile = profile this.#stateDir = stateDir ?? paths.state