diff --git a/docs/explanation/plugin-system.md b/docs/explanation/plugin-system.md index 83badbb..eeeaff8 100644 --- a/docs/explanation/plugin-system.md +++ b/docs/explanation/plugin-system.md @@ -51,10 +51,18 @@ Rules: Plugin visibility can be restricted via: -```toml -[plugins] -enabled = ["takopi-engine-acme", "takopi-transport-slack"] -``` +=== "takopi config" + + ```sh + takopi config set plugins.enabled '["takopi-engine-acme", "takopi-transport-slack"]' + ``` + +=== "toml" + + ```toml + [plugins] + enabled = ["takopi-engine-acme", "takopi-transport-slack"] + ``` When set, Takopi filters by **distribution name** (package metadata), not by entrypoint name. This lets you: diff --git a/docs/how-to/chat-sessions.md b/docs/how-to/chat-sessions.md index 36354be..4be7644 100644 --- a/docs/how-to/chat-sessions.md +++ b/docs/how-to/chat-sessions.md @@ -9,10 +9,18 @@ Chat sessions store one resume token per engine per chat (per sender in group ch If you chose **handoff** during onboarding and want to switch to chat mode: -```toml -[transports.telegram] -session_mode = "chat" # stateless | chat -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.session_mode "chat" + ``` + +=== "toml" + + ```toml + [transports.telegram] + session_mode = "chat" # stateless | chat + ``` With `session_mode = "chat"`, new messages in the chat continue the current thread automatically. @@ -32,10 +40,18 @@ Chat sessions do not remove reply-to-continue. If resume lines are visible, you If you prefer a cleaner chat, hide resume lines: -```toml -[transports.telegram] -show_resume_line = false -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.show_resume_line false + ``` + +=== "toml" + + ```toml + [transports.telegram] + show_resume_line = false + ``` ## How it behaves in groups diff --git a/docs/how-to/file-transfer.md b/docs/how-to/file-transfer.md index 6d404cb..c2a17b6 100644 --- a/docs/how-to/file-transfer.md +++ b/docs/how-to/file-transfer.md @@ -4,15 +4,28 @@ Upload files into the active repo/worktree or fetch files back into Telegram. ## Enable file transfer -```toml -[transports.telegram.files] -enabled = true -auto_put = true -auto_put_mode = "upload" # upload | prompt -uploads_dir = "incoming" -allowed_user_ids = [123456789] -deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"] -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.files.enabled true + takopi config set transports.telegram.files.auto_put true + takopi config set transports.telegram.files.auto_put_mode "upload" + takopi config set transports.telegram.files.uploads_dir "incoming" + takopi config set transports.telegram.files.allowed_user_ids "[123456789]" + takopi config set transports.telegram.files.deny_globs '[".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]' + ``` + +=== "toml" + + ```toml + [transports.telegram.files] + enabled = true + auto_put = true + auto_put_mode = "upload" # upload | prompt + uploads_dir = "incoming" + allowed_user_ids = [123456789] + deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"] + ``` Notes: @@ -56,4 +69,3 @@ Directories are zipped automatically. - [Commands & directives](../reference/commands-and-directives.md) - [Config reference](../reference/config.md) - diff --git a/docs/how-to/projects.md b/docs/how-to/projects.md index 08f8a4f..a0f0a5b 100644 --- a/docs/how-to/projects.md +++ b/docs/how-to/projects.md @@ -11,10 +11,18 @@ takopi init happy-gadgets This adds a project to your config: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + ``` ## Target a project from chat @@ -28,30 +36,56 @@ Send: Projects can override global defaults: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -default_engine = "claude" -worktrees_dir = ".worktrees" -worktree_base = "master" -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.default_engine "claude" + takopi config set projects.happy-gadgets.worktrees_dir ".worktrees" + takopi config set projects.happy-gadgets.worktree_base "master" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + default_engine = "claude" + worktrees_dir = ".worktrees" + worktree_base = "master" + ``` If you expect to edit config while Takopi is running, enable hot reload: -```toml -watch_config = true -``` +=== "takopi config" + + ```sh + takopi config set watch_config true + ``` + +=== "toml" + + ```toml + watch_config = true + ``` ## Set a default project If you mostly work in one repo: -```toml -default_project = "happy-gadgets" -``` +=== "takopi config" + + ```sh + takopi config set default_project "happy-gadgets" + ``` + +=== "toml" + + ```toml + default_project = "happy-gadgets" + ``` ## Related - [Context resolution](../reference/context-resolution.md) - [Worktrees](worktrees.md) - diff --git a/docs/how-to/route-by-chat.md b/docs/how-to/route-by-chat.md index 3a048c7..310b32b 100644 --- a/docs/how-to/route-by-chat.md +++ b/docs/how-to/route-by-chat.md @@ -12,11 +12,20 @@ takopi chat-id --project happy-gadgets Then send any message in the target chat. Takopi captures the `chat_id` and updates your config: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -chat_id = -1001234567890 -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.chat_id -1001234567890 + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + chat_id = -1001234567890 + ``` Messages from that chat now default to the project. @@ -36,4 +45,3 @@ takopi chat-id - [Topics](topics.md) - [Context resolution](../reference/context-resolution.md) - diff --git a/docs/how-to/topics.md b/docs/how-to/topics.md index d53aa0f..6a230ab 100644 --- a/docs/how-to/topics.md +++ b/docs/how-to/topics.md @@ -27,11 +27,20 @@ Topics bind Telegram **forum threads** to a project/branch context. Each topic k ## Enable topics -```toml -[transports.telegram.topics] -enabled = true -scope = "auto" # auto | main | projects | all -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.topics.enabled true + takopi config set transports.telegram.topics.scope "auto" + ``` + +=== "toml" + + ```toml + [transports.telegram.topics] + enabled = true + scope = "auto" # auto | main | projects | all + ``` ### Scope explained diff --git a/docs/how-to/voice-notes.md b/docs/how-to/voice-notes.md index 78f24a5..eb56a35 100644 --- a/docs/how-to/voice-notes.md +++ b/docs/how-to/voice-notes.md @@ -4,11 +4,20 @@ Enable transcription so voice notes become normal text runs. ## Enable transcription -```toml -[transports.telegram] -voice_transcription = true -voice_transcription_model = "gpt-4o-mini-transcribe" # optional -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.voice_transcription true + takopi config set transports.telegram.voice_transcription_model "gpt-4o-mini-transcribe" + ``` + +=== "toml" + + ```toml + [transports.telegram] + voice_transcription = true + voice_transcription_model = "gpt-4o-mini-transcribe" # optional + ``` Set `OPENAI_API_KEY` in your environment (uses OpenAI’s transcription API). @@ -24,4 +33,3 @@ If transcription fails, you’ll get an error message and the run is skipped. ## Related - [Config reference](../reference/config.md) - diff --git a/docs/how-to/worktrees.md b/docs/how-to/worktrees.md index d9bee2e..2db9e3d 100644 --- a/docs/how-to/worktrees.md +++ b/docs/how-to/worktrees.md @@ -6,12 +6,22 @@ Use `@branch` to run tasks in a dedicated git worktree for that branch. Add a `worktrees_dir` (and optionally a base branch) to the project: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -worktrees_dir = ".worktrees" # relative to project path -worktree_base = "master" # base branch for new worktrees -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.worktrees_dir ".worktrees" + takopi config set projects.happy-gadgets.worktree_base "master" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + worktrees_dir = ".worktrees" # relative to project path + worktree_base = "master" # base branch for new worktrees + ``` ## Run in a branch worktree @@ -39,4 +49,3 @@ When you reply, this context carries forward (you usually don’t need to repeat ## Related - [Context resolution](../reference/context-resolution.md) - diff --git a/docs/how-to/write-a-plugin.md b/docs/how-to/write-a-plugin.md index da6eb1f..998acdc 100644 --- a/docs/how-to/write-a-plugin.md +++ b/docs/how-to/write-a-plugin.md @@ -52,10 +52,18 @@ BACKEND = EngineBackend( Engine config is a raw table in `takopi.toml`: -```toml -[myengine] -model = "..." -``` +=== "takopi config" + + ```sh + takopi config set myengine.model "..." + ``` + +=== "toml" + + ```toml + [myengine] + model = "..." + ``` ## Transport backend plugin @@ -92,19 +100,35 @@ BACKEND = MyCommand() Configure under `[plugins.]`: -```toml -[plugins.hello] -greeting = "hello" -``` +=== "takopi config" + + ```sh + takopi config set plugins.hello.greeting "hello" + ``` + +=== "toml" + + ```toml + [plugins.hello] + greeting = "hello" + ``` The parsed dict is available as `ctx.plugin_config` in `handle()`. ## Enable/disable installed plugins -```toml -[plugins] -enabled = ["takopi-transport-slack", "takopi-engine-acme"] -``` +=== "takopi config" + + ```sh + takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]' + ``` + +=== "toml" + + ```toml + [plugins] + enabled = ["takopi-transport-slack", "takopi-engine-acme"] + ``` - `enabled = []` (default) means “load all installed plugins”. - If non-empty, only distributions with matching names are visible. diff --git a/docs/reference/config.md b/docs/reference/config.md index db9be52..3dfb474 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -4,9 +4,17 @@ Takopi reads configuration from `~/.takopi/takopi.toml`. If you expect to edit config while Takopi is running, set: -```toml -watch_config = true -``` +=== "takopi config" + + ```sh + takopi config set watch_config true + ``` + +=== "toml" + + ```toml + watch_config = true + ``` ## Top-level keys @@ -19,11 +27,20 @@ watch_config = true ## `transports.telegram` -```toml -[transports.telegram] -bot_token = "..." -chat_id = 123 -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.bot_token "..." + takopi config set transports.telegram.chat_id 123 + ``` + +=== "toml" + + ```toml + [transports.telegram] + bot_token = "..." + chat_id = 123 + ``` | Key | Type | Default | Notes | |-----|------|---------|-------| @@ -62,14 +79,26 @@ File size limits (not configurable): ## `projects.` -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -worktrees_dir = ".worktrees" -default_engine = "claude" -worktree_base = "master" -chat_id = -1001234567890 -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.worktrees_dir ".worktrees" + takopi config set projects.happy-gadgets.default_engine "claude" + takopi config set projects.happy-gadgets.worktree_base "master" + takopi config set projects.happy-gadgets.chat_id -1001234567890 + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + worktrees_dir = ".worktrees" + default_engine = "claude" + worktree_base = "master" + chat_id = -1001234567890 + ``` | Key | Type | Default | Notes | |-----|------|---------|-------| @@ -85,10 +114,18 @@ Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into `[t ### `plugins.enabled` -```toml -[plugins] -enabled = ["takopi-transport-slack", "takopi-engine-acme"] -``` +=== "takopi config" + + ```sh + takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]' + ``` + +=== "toml" + + ```toml + [plugins] + enabled = ["takopi-transport-slack", "takopi-engine-acme"] + ``` - `enabled = []` (default) means “load all installed plugins”. - If non-empty, only distributions with matching names are visible (case-insensitive). @@ -99,11 +136,99 @@ Plugin-specific configuration lives under `[plugins.]` and is passed to comm ## Engine-specific config tables -Engines can have top-level config tables keyed by engine id, for example: +Engines use **top-level tables** keyed by engine id. Built-in engines are listed +here; plugin engines should document their own keys. -```toml -[codex] -model = "..." -``` +### `codex` -The shape is engine-defined. +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `extra_args` | string[] | `["-c", "notify=[]"]` | Extra CLI args for `codex` (exec-only flags are rejected). | +| `profile` | string | (unset) | Passed as `--profile ` and used as the session title. | + +=== "takopi config" + + ```sh + takopi config set codex.extra_args '["-c", "notify=[]"]' + takopi config set codex.profile "work" + ``` + +=== "toml" + + ```toml + [codex] + extra_args = ["-c", "notify=[]"] + profile = "work" + ``` + +### `claude` + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `model` | string | (unset) | Optional model override. | +| `allowed_tools` | string[] | `["Bash", "Read", "Edit", "Write"]` | Auto-approve tool rules. | +| `dangerously_skip_permissions` | bool | `false` | Skip Claude permissions prompts. | +| `use_api_billing` | bool | `false` | Keep `ANTHROPIC_API_KEY` for API billing. | + +=== "takopi config" + + ```sh + takopi config set claude.model "claude-sonnet-4-5-20250929" + takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write"]' + takopi config set claude.dangerously_skip_permissions false + takopi config set claude.use_api_billing false + ``` + +=== "toml" + + ```toml + [claude] + model = "claude-sonnet-4-5-20250929" + allowed_tools = ["Bash", "Read", "Edit", "Write"] + dangerously_skip_permissions = false + use_api_billing = false + ``` + +### `pi` + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `model` | string | (unset) | Passed as `--model`. | +| `provider` | string | (unset) | Passed as `--provider`. | +| `extra_args` | string[] | `[]` | Extra CLI args for `pi`. | + +=== "takopi config" + + ```sh + takopi config set pi.model "..." + takopi config set pi.provider "..." + takopi config set pi.extra_args "[]" + ``` + +=== "toml" + + ```toml + [pi] + model = "..." + provider = "..." + extra_args = [] + ``` + +### `opencode` + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `model` | string | (unset) | Optional model override. | + +=== "takopi config" + + ```sh + takopi config set opencode.model "claude-sonnet" + ``` + +=== "toml" + + ```toml + [opencode] + model = "claude-sonnet" + ``` diff --git a/docs/reference/context-resolution.md b/docs/reference/context-resolution.md index 353b845..ddbec6d 100644 --- a/docs/reference/context-resolution.md +++ b/docs/reference/context-resolution.md @@ -18,22 +18,39 @@ worktree-based runs via `@branch`. All config lives in `~/.takopi/takopi.toml`. See [Config](config.md) for the full reference. -```toml -default_engine = "codex" # optional -default_project = "z80" # optional -transport = "telegram" # optional, defaults to "telegram" +=== "takopi config" -[transports.telegram] -bot_token = "..." # required -chat_id = 123 # required + ```sh + takopi config set default_engine "codex" + takopi config set default_project "z80" + takopi config set transport "telegram" + takopi config set transports.telegram.bot_token "..." + takopi config set transports.telegram.chat_id 123 + takopi config set projects.z80.path "~/dev/z80" + takopi config set projects.z80.worktrees_dir ".worktrees" + takopi config set projects.z80.default_engine "codex" + takopi config set projects.z80.worktree_base "master" + takopi config set projects.z80.chat_id -123 + ``` -[projects.z80] -path = "~/dev/z80" # required (repo root) -worktrees_dir = ".worktrees" # optional, default ".worktrees" -default_engine = "codex" # optional, per-project override -worktree_base = "master" # optional, base for new branches -chat_id = -123 # optional, project chat id -``` +=== "toml" + + ```toml + default_engine = "codex" # optional + default_project = "z80" # optional + transport = "telegram" # optional, defaults to "telegram" + + [transports.telegram] + bot_token = "..." # required + chat_id = 123 # required + + [projects.z80] + path = "~/dev/z80" # required (repo root) + worktrees_dir = ".worktrees" # optional, default ".worktrees" + default_engine = "codex" # optional, per-project override + worktree_base = "master" # optional, base for new branches + chat_id = -123 # optional, project chat id + ``` Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into `[transports.telegram]` on startup. diff --git a/docs/reference/runners/claude/runner.md b/docs/reference/runners/claude/runner.md index ae39575..0e90ad4 100644 --- a/docs/reference/runners/claude/runner.md +++ b/docs/reference/runners/claude/runner.md @@ -68,17 +68,29 @@ Add a new optional `[claude]` section. Recommended v1 schema: -```toml -# ~/.takopi/takopi.toml +=== "takopi config" -default_engine = "claude" + ```sh + takopi config set default_engine "claude" + takopi config set claude.model "claude-sonnet-4-5-20250929" + takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write"]' + takopi config set claude.dangerously_skip_permissions false + takopi config set claude.use_api_billing false + ``` -[claude] -model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too) -allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation -dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only) -use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing) -``` +=== "toml" + + ```toml + # ~/.takopi/takopi.toml + + default_engine = "claude" + + [claude] + model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too) + allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation + dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only) + use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing) + ``` Notes: diff --git a/docs/reference/runners/claude/takopi-events.md b/docs/reference/runners/claude/takopi-events.md index 2fa80af..a0edae3 100644 --- a/docs/reference/runners/claude/takopi-events.md +++ b/docs/reference/runners/claude/takopi-events.md @@ -210,15 +210,26 @@ Claude runner implementation summary (no Takopi domain model changes): A minimal TOML config for Claude: -```toml -[claude] -# model: opus | sonnet | haiku -model = "sonnet" +=== "takopi config" -allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"] -dangerously_skip_permissions = false -use_api_billing = false -``` + ```sh + takopi config set claude.model "sonnet" + takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write", "WebSearch"]' + takopi config set claude.dangerously_skip_permissions false + takopi config set claude.use_api_billing false + ``` + +=== "toml" + + ```toml + [claude] + # model: opus | sonnet | haiku + model = "sonnet" + + allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"] + dangerously_skip_permissions = false + use_api_billing = false + ``` Takopi only maps these keys to Claude CLI flags; other options should be configured in Claude Code settings. If `allowed_tools` is omitted, Takopi defaults to `["Bash", "Read", "Edit", "Write"]`. diff --git a/docs/reference/runners/opencode/runner.md b/docs/reference/runners/opencode/runner.md index b4a89ec..7358ae4 100644 --- a/docs/reference/runners/opencode/runner.md +++ b/docs/reference/runners/opencode/runner.md @@ -13,10 +13,18 @@ npm i -g opencode-ai@latest Add to your `takopi.toml`: -```toml -[opencode] -model = "claude-sonnet" # optional -``` +=== "takopi config" + + ```sh + takopi config set opencode.model "claude-sonnet" + ``` + +=== "toml" + + ```toml + [opencode] + model = "claude-sonnet" # optional + ``` ## Usage diff --git a/docs/reference/runners/pi/runner.md b/docs/reference/runners/pi/runner.md index 8b186bd..a0d52b2 100644 --- a/docs/reference/runners/pi/runner.md +++ b/docs/reference/runners/pi/runner.md @@ -58,16 +58,27 @@ Add a new optional `[pi]` section. Recommended schema: -```toml -# ~/.takopi/takopi.toml +=== "takopi config" -default_engine = "pi" + ```sh + takopi config set default_engine "pi" + takopi config set pi.model "..." + takopi config set pi.provider "..." + takopi config set pi.extra_args "[]" + ``` -[pi] -model = "..." # optional; passed as --model -provider = "..." # optional; passed as --provider -extra_args = [] # optional list of strings, appended verbatim -``` +=== "toml" + + ```toml + # ~/.takopi/takopi.toml + + default_engine = "pi" + + [pi] + model = "..." # optional; passed as --model + provider = "..." # optional; passed as --provider + extra_args = [] # optional list of strings, appended verbatim + ``` Notes: diff --git a/docs/reference/runners/pi/takopi-events.md b/docs/reference/runners/pi/takopi-events.md index d8325f8..d7ad6a9 100644 --- a/docs/reference/runners/pi/takopi-events.md +++ b/docs/reference/runners/pi/takopi-events.md @@ -144,11 +144,21 @@ transformation. A minimal TOML config for Pi: -```toml -[pi] -model = "..." -provider = "..." -extra_args = [] -``` +=== "takopi config" + + ```sh + takopi config set pi.model "..." + takopi config set pi.provider "..." + takopi config set pi.extra_args "[]" + ``` + +=== "toml" + + ```toml + [pi] + model = "..." + provider = "..." + extra_args = [] + ``` Use `extra_args` for any Pi CLI flags not explicitly mapped. diff --git a/docs/reference/transports/telegram.md b/docs/reference/transports/telegram.md index 00375b9..9178e7b 100644 --- a/docs/reference/transports/telegram.md +++ b/docs/reference/transports/telegram.md @@ -28,10 +28,19 @@ directive pipeline as typed text. Configuration (under `[transports.telegram]`): -```toml -voice_transcription = true -voice_transcription_model = "gpt-4o-mini-transcribe" # optional -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.voice_transcription true + takopi config set transports.telegram.voice_transcription_model "gpt-4o-mini-transcribe" + ``` + +=== "toml" + + ```toml + voice_transcription = true + voice_transcription_model = "gpt-4o-mini-transcribe" # optional + ``` Set `OPENAI_API_KEY` in the environment. If transcription is enabled but the API key is missing or the audio download fails, takopi replies with a short error and skips @@ -88,9 +97,17 @@ Behavior: Configuration (under `[transports.telegram]`): -```toml -forward_coalesce_s = 1.0 # set 0 to disable the delay -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.forward_coalesce_s 1.0 + ``` + +=== "toml" + + ```toml + forward_coalesce_s = 1.0 # set 0 to disable the delay + ``` ## Chat sessions (optional) @@ -100,10 +117,19 @@ use chat mode with auto-resume enabled. Configuration (under `[transports.telegram]`): -```toml -show_resume_line = true # set false to hide resume lines -session_mode = "chat" # or "stateless" -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.show_resume_line true + takopi config set transports.telegram.session_mode "chat" + ``` + +=== "toml" + + ```toml + show_resume_line = true # set false to hide resume lines + session_mode = "chat" # or "stateless" + ``` Behavior: @@ -124,10 +150,18 @@ By default, takopi trims long final responses to ~3500 characters to stay under Telegram's 4096 character limit after entity parsing. You can opt into splitting instead: -```toml -[transports.telegram] -message_overflow = "split" # trim | split -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.message_overflow "split" + ``` + +=== "toml" + + ```toml + [transports.telegram] + message_overflow = "split" # trim | split + ``` Split mode sends multiple messages. Each chunk includes the footer; follow-up chunks add a "continued (N/M)" header. @@ -140,11 +174,20 @@ topic, so replies keep the right context even after restarts. Configuration (under `[transports.telegram]`): -```toml -[transports.telegram.topics] -enabled = true -scope = "auto" # auto | main | projects | all -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.topics.enabled true + takopi config set transports.telegram.topics.scope "auto" + ``` + +=== "toml" + + ```toml + [transports.telegram.topics] + enabled = true + scope = "auto" # auto | main | projects | all + ``` Requirements: diff --git a/docs/tutorials/conversation-modes.md b/docs/tutorials/conversation-modes.md index 7eadee7..ce3c53c 100644 --- a/docs/tutorials/conversation-modes.md +++ b/docs/tutorials/conversation-modes.md @@ -57,11 +57,20 @@ To continue the same session, **reply** to a message with a resume line: You can manually change these settings in your config file: -```toml -[transports.telegram] -session_mode = "chat" # "chat" or "stateless" -show_resume_line = false # true or false -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.session_mode "chat" + takopi config set transports.telegram.show_resume_line false + ``` + +=== "toml" + + ```toml + [transports.telegram] + session_mode = "chat" # "chat" or "stateless" + show_resume_line = false # true or false + ``` Or re-run onboarding to pick a different workflow: @@ -76,10 +85,18 @@ Resume lines are still shown when no project context is set, so replies can bran If you prefer always-visible resume lines, set: -```toml -[transports.telegram] -show_resume_line = true -``` +=== "takopi config" + + ```sh + takopi config set transports.telegram.show_resume_line true + ``` + +=== "toml" + + ```toml + [transports.telegram] + show_resume_line = true + ``` ## Reply-to-continue still works diff --git a/docs/tutorials/install.md b/docs/tutorials/install.md index 6837426..3dc8f8e 100644 --- a/docs/tutorials/install.md +++ b/docs/tutorials/install.md @@ -268,54 +268,99 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on === "assistant" - ```toml title="~/.takopi/takopi.toml" - default_engine = "codex" - transport = "telegram" + === "takopi config" - [transports.telegram] - bot_token = "..." - chat_id = 123456789 - session_mode = "chat" # auto-resume - show_resume_line = false # cleaner chat + ```sh + takopi config set default_engine "codex" + takopi config set transport "telegram" + takopi config set transports.telegram.bot_token "..." + takopi config set transports.telegram.chat_id 123456789 + takopi config set transports.telegram.session_mode "chat" + takopi config set transports.telegram.show_resume_line false + takopi config set transports.telegram.topics.enabled false + takopi config set transports.telegram.topics.scope "auto" + ``` - [transports.telegram.topics] - enabled = false - scope = "auto" - ``` + === "toml" + + ```toml title="~/.takopi/takopi.toml" + default_engine = "codex" + transport = "telegram" + + [transports.telegram] + bot_token = "..." + chat_id = 123456789 + session_mode = "chat" # auto-resume + show_resume_line = false # cleaner chat + + [transports.telegram.topics] + enabled = false + scope = "auto" + ``` === "workspace" - ```toml title="~/.takopi/takopi.toml" - default_engine = "codex" - transport = "telegram" + === "takopi config" - [transports.telegram] - bot_token = "..." - chat_id = -1001234567890 # forum group - session_mode = "chat" - show_resume_line = false + ```sh + takopi config set default_engine "codex" + takopi config set transport "telegram" + takopi config set transports.telegram.bot_token "..." + takopi config set transports.telegram.chat_id -1001234567890 + takopi config set transports.telegram.session_mode "chat" + takopi config set transports.telegram.show_resume_line false + takopi config set transports.telegram.topics.enabled true + takopi config set transports.telegram.topics.scope "auto" + ``` - [transports.telegram.topics] - enabled = true # topics on - scope = "auto" - ``` + === "toml" + + ```toml title="~/.takopi/takopi.toml" + default_engine = "codex" + transport = "telegram" + + [transports.telegram] + bot_token = "..." + chat_id = -1001234567890 # forum group + session_mode = "chat" + show_resume_line = false + + [transports.telegram.topics] + enabled = true # topics on + scope = "auto" + ``` === "handoff" - ```toml title="~/.takopi/takopi.toml" - default_engine = "codex" - transport = "telegram" + === "takopi config" - [transports.telegram] - bot_token = "..." - chat_id = 123456789 - session_mode = "stateless" # reply-to-continue - show_resume_line = true # always show resume lines + ```sh + takopi config set default_engine "codex" + takopi config set transport "telegram" + takopi config set transports.telegram.bot_token "..." + takopi config set transports.telegram.chat_id 123456789 + takopi config set transports.telegram.session_mode "stateless" + takopi config set transports.telegram.show_resume_line true + takopi config set transports.telegram.topics.enabled false + takopi config set transports.telegram.topics.scope "auto" + ``` - [transports.telegram.topics] - enabled = false - scope = "auto" - ``` + === "toml" + + ```toml title="~/.takopi/takopi.toml" + default_engine = "codex" + transport = "telegram" + + [transports.telegram] + bot_token = "..." + chat_id = 123456789 + session_mode = "stateless" # reply-to-continue + show_resume_line = true # always show resume lines + + [transports.telegram.topics] + enabled = false + scope = "auto" + ``` This config file controls all of Takopi's behavior. You can edit it directly to change settings or add advanced features. diff --git a/docs/tutorials/multi-engine.md b/docs/tutorials/multi-engine.md index 5f1acf2..ee40d0c 100644 --- a/docs/tutorials/multi-engine.md +++ b/docs/tutorials/multi-engine.md @@ -109,11 +109,20 @@ Each topic remembers its own default. Set a default engine in your project config: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -default_engine = "claude" -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.default_engine "claude" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + default_engine = "claude" + ``` Now `/happy-gadgets` tasks default to Claude, even if your global default is Codex. @@ -144,15 +153,28 @@ This means: resume lines always win, then explicit directives, then the most spe **Pattern: Quick questions vs. deep work** -``` -# Global default for quick stuff -default_engine = "codex" +=== "takopi config" -# Project default for complex codebase -[projects.backend] -path = "~/dev/backend" -default_engine = "claude" -``` + ```sh + # Global default for quick stuff + takopi config set default_engine "codex" + + # Project default for complex codebase + takopi config set projects.backend.path "~/dev/backend" + takopi config set projects.backend.default_engine "claude" + ``` + +=== "toml" + + ```toml + # Global default for quick stuff + default_engine = "codex" + + # Project default for complex codebase + [projects.backend] + path = "~/dev/backend" + default_engine = "claude" + ``` Simple messages go to Codex. `/backend` messages go to Claude. diff --git a/docs/tutorials/projects-and-branches.md b/docs/tutorials/projects-and-branches.md index 332b0b8..ca2452a 100644 --- a/docs/tutorials/projects-and-branches.md +++ b/docs/tutorials/projects-and-branches.md @@ -31,10 +31,18 @@ saved project 'happy-gadgets' to ~/.takopi/takopi.toml This adds an entry to your config (Takopi also fills in defaults like `worktrees_dir`, `default_engine`, and sometimes `worktree_base`): -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + ``` !!! tip "Project aliases are also Telegram commands" The alias becomes a `/command` you can use in chat. Keep them short and lowercase: `myapp`, `backend`, `docs`. @@ -69,12 +77,22 @@ Worktrees let you run tasks on feature branches without touching your main check Add worktree config to your project: -```toml -[projects.happy-gadgets] -path = "~/dev/happy-gadgets" -worktrees_dir = ".worktrees" # where branches go -worktree_base = "main" # base for new branches -``` +=== "takopi config" + + ```sh + takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets" + takopi config set projects.happy-gadgets.worktrees_dir ".worktrees" + takopi config set projects.happy-gadgets.worktree_base "main" + ``` + +=== "toml" + + ```toml + [projects.happy-gadgets] + path = "~/dev/happy-gadgets" + worktrees_dir = ".worktrees" # where branches go + worktree_base = "main" # base for new branches + ``` !!! note "Ignore the worktrees directory" Add `.worktrees/` to your global gitignore so it doesn't clutter `git status`: @@ -125,9 +143,17 @@ The `ctx:` line in each message carries the context forward. If you mostly work in one repo, set it as the default: -```toml -default_project = "happy-gadgets" -``` +=== "takopi config" + + ```sh + takopi config set default_project "happy-gadgets" + ``` + +=== "toml" + + ```toml + default_project = "happy-gadgets" + ``` Now messages without a `/project` prefix go to that repo: diff --git a/src/takopi/cli.py b/src/takopi/cli.py index 888b21e..a680d47 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -1,19 +1,29 @@ from __future__ import annotations import os +import re import sys +import tomllib from dataclasses import dataclass from collections.abc import Callable from importlib.metadata import EntryPoint from pathlib import Path -from typing import Literal +from typing import Any, Literal import anyio from functools import partial +from pydantic import BaseModel import typer from . import __version__ -from .config import ConfigError, load_or_init_config, write_config +from .config import ( + ConfigError, + HOME_CONFIG_PATH, + dump_toml, + load_or_init_config, + read_config, + write_config, +) from .config_migrations import migrate_config from .commands import get_command from .backends import EngineBackend @@ -47,6 +57,14 @@ from .telegram.topics import _validate_topics_setup_for logger = get_logger(__name__) +_KEY_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_MISSING = object() +_CONFIG_PATH_OPTION = typer.Option( + None, + "--config-path", + help="Override the default config path.", +) + def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]: try: @@ -671,6 +689,276 @@ def plugins_cmd( typer.echo(f" {group} {err.name} ({dist}): {err.error}") +def _resolve_config_path_override(value: Path | None) -> Path: + if value is None: + return HOME_CONFIG_PATH + return value.expanduser() + + +def _exit_config_error(exc: ConfigError, *, code: int = 2) -> None: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(code=code) from exc + + +def _parse_key_path(raw: str) -> list[str]: + value = raw.strip() + if not value: + raise ConfigError("Invalid key path; expected a non-empty value.") + segments = value.split(".") + for segment in segments: + if not segment: + raise ConfigError(f"Invalid key path {raw!r}; empty segment.") + if not _KEY_SEGMENT_RE.fullmatch(segment): + raise ConfigError( + f"Invalid key segment {segment!r} in {raw!r}; " + "use only letters, numbers, '_' or '-'." + ) + return segments + + +def _parse_value(raw: str) -> Any: + value = raw.strip() + if not value: + return "" + try: + return tomllib.loads(f"__v__ = {value}")["__v__"] + except tomllib.TOMLDecodeError: + return value + + +def _toml_literal(value: Any) -> str: + dumped = dump_toml({"__v__": value}) + prefix = "__v__ = " + if dumped.startswith(prefix): + return dumped[len(prefix) :].rstrip("\n") + raise ConfigError("Unsupported config value; unable to render TOML literal.") + + +def _normalized_value_from_settings( + settings: TakopiSettings, segments: list[str] +) -> Any: + node: Any = settings + for segment in segments: + if isinstance(node, BaseModel): + if segment in node.__class__.model_fields: + node = getattr(node, segment) + else: + extra = node.model_extra or {} + node = extra.get(segment, _MISSING) + elif isinstance(node, dict): + node = node.get(segment, _MISSING) + else: + return _MISSING + if node is _MISSING: + return _MISSING + if isinstance(node, BaseModel): + return node.model_dump(exclude_unset=True) + return node + + +def _flatten_config(config: dict[str, Any]) -> list[tuple[str, Any]]: + items: list[tuple[str, Any]] = [] + + def _walk(node: Any, prefix: str) -> None: + if isinstance(node, dict): + for key in sorted(node): + value = node[key] + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + _walk(value, path) + else: + items.append((path, value)) + elif prefix: + items.append((prefix, node)) + + _walk(config, "") + return items + + +def _load_config_or_exit(path: Path, *, missing_code: int) -> dict[str, Any]: + if not path.exists(): + _fail_missing_config(path) + raise typer.Exit(code=missing_code) + try: + return read_config(path) + except ConfigError as exc: + _exit_config_error(exc) + return {} + + +def config_path_cmd( + config_path: Path | None = _CONFIG_PATH_OPTION, +) -> None: + """Print the resolved config path.""" + path = _resolve_config_path_override(config_path) + typer.echo(_config_path_display(path)) + + +def config_list( + config_path: Path | None = _CONFIG_PATH_OPTION, +) -> None: + """List config keys as flattened dot-paths.""" + path = _resolve_config_path_override(config_path) + config = _load_config_or_exit(path, missing_code=1) + try: + for key, value in _flatten_config(config): + literal = _toml_literal(value) + typer.echo(f"{key} = {literal}") + except ConfigError as exc: + _exit_config_error(exc) + + +def config_get( + key: str = typer.Argument(..., help="Dot-path key to fetch."), + config_path: Path | None = _CONFIG_PATH_OPTION, +) -> None: + """Fetch a single config key.""" + path = _resolve_config_path_override(config_path) + config = _load_config_or_exit(path, missing_code=2) + try: + segments = _parse_key_path(key) + except ConfigError as exc: + _exit_config_error(exc) + + node: Any = config + for index, segment in enumerate(segments): + if not isinstance(node, dict): + prefix = ".".join(segments[:index]) + _exit_config_error( + ConfigError(f"Invalid `{prefix}` in {path}; expected a table.") + ) + if segment not in node: + raise typer.Exit(code=1) + node = node[segment] + + if isinstance(node, dict): + typer.echo( + f"error: {'.'.join(segments)!r} is a table; pick a leaf node.", + err=True, + ) + raise typer.Exit(code=2) + + try: + typer.echo(_toml_literal(node)) + except ConfigError as exc: + _exit_config_error(exc) + + +def config_set( + key: str = typer.Argument(..., help="Dot-path key to set."), + value: str = typer.Argument(..., help="Value to assign (auto-parsed)."), + config_path: Path | None = _CONFIG_PATH_OPTION, +) -> None: + """Set a config value.""" + path = _resolve_config_path_override(config_path) + config = _load_config_or_exit(path, missing_code=2) + try: + segments = _parse_key_path(key) + except ConfigError as exc: + _exit_config_error(exc) + + try: + migrate_config(config, config_path=path) + except ConfigError as exc: + _exit_config_error(exc) + + parsed = _parse_value(value) + node: Any = config + for index, segment in enumerate(segments[:-1]): + next_node = node.get(segment) + if next_node is None: + created: dict[str, Any] = {} + node[segment] = created + node = created + continue + if not isinstance(next_node, dict): + prefix = ".".join(segments[: index + 1]) + _exit_config_error( + ConfigError(f"Invalid `{prefix}` in {path}; expected a table.") + ) + node = next_node + node[segments[-1]] = parsed + + try: + settings = validate_settings_data(config, config_path=path) + except ConfigError as exc: + _exit_config_error(exc) + + normalized = _normalized_value_from_settings(settings, segments) + if normalized is not _MISSING: + node[segments[-1]] = normalized + parsed = normalized + + try: + write_config(config, path) + except ConfigError as exc: + _exit_config_error(exc) + + try: + rendered = _toml_literal(parsed) + except ConfigError as exc: + _exit_config_error(exc) + typer.echo(f"updated {'.'.join(segments)} = {rendered}") + + +def config_unset( + key: str = typer.Argument(..., help="Dot-path key to remove."), + config_path: Path | None = _CONFIG_PATH_OPTION, +) -> None: + """Remove a config key.""" + path = _resolve_config_path_override(config_path) + config = _load_config_or_exit(path, missing_code=2) + try: + segments = _parse_key_path(key) + except ConfigError as exc: + _exit_config_error(exc) + + try: + migrate_config(config, config_path=path) + except ConfigError as exc: + _exit_config_error(exc) + + node: Any = config + stack: list[tuple[dict[str, Any], str]] = [] + for index, segment in enumerate(segments[:-1]): + if not isinstance(node, dict): + prefix = ".".join(segments[:index]) + _exit_config_error( + ConfigError(f"Invalid `{prefix}` in {path}; expected a table.") + ) + next_node = node.get(segment) + if next_node is None: + raise typer.Exit(code=1) + if not isinstance(next_node, dict): + prefix = ".".join(segments[: index + 1]) + _exit_config_error( + ConfigError(f"Invalid `{prefix}` in {path}; expected a table.") + ) + stack.append((node, segment)) + node = next_node + + if not isinstance(node, dict): + prefix = ".".join(segments[:-1]) + _exit_config_error( + ConfigError(f"Invalid `{prefix}` in {path}; expected a table.") + ) + leaf = segments[-1] + if leaf not in node: + raise typer.Exit(code=1) + node.pop(leaf, None) + + while stack and not node: + parent, key_name = stack.pop() + parent.pop(key_name, None) + node = parent + + try: + validate_settings_data(config, config_path=path) + write_config(config, path) + except ConfigError as exc: + _exit_config_error(exc) + + def app_main( ctx: typer.Context, version: bool = typer.Option( @@ -774,11 +1062,18 @@ def create_app() -> typer.Typer: invoke_without_command=True, help="Telegram bridge for coding agents. Docs: https://takopi.dev/", ) + config_app = typer.Typer(help="Read and modify takopi config.") + config_app.command(name="path")(config_path_cmd) + config_app.command(name="list")(config_list) + config_app.command(name="get")(config_get) + config_app.command(name="set")(config_set) + config_app.command(name="unset")(config_unset) app.command(name="init")(init) app.command(name="chat-id")(chat_id) app.command(name="doctor")(doctor) app.command(name="onboarding-paths")(onboarding_paths) app.command(name="plugins")(plugins_cmd) + app.add_typer(config_app, name="config") app.callback()(app_main) for engine_id in _engine_ids_for_cli(): help_text = f"Run with the {engine_id} engine." diff --git a/src/takopi/config.py b/src/takopi/config.py index d3c4d9d..128ca06 100644 --- a/src/takopi/config.py +++ b/src/takopi/config.py @@ -2,7 +2,9 @@ from __future__ import annotations import tomllib from dataclasses import dataclass, field +import os from pathlib import Path +import tempfile from typing import Any import tomli_w @@ -104,4 +106,29 @@ def dump_toml(config: dict[str, Any]) -> str: def write_config(config: dict[str, Any], path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(dump_toml(config), encoding="utf-8") + payload = dump_toml(config) + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp.write(payload) + tmp.flush() + os.fsync(tmp.fileno()) + tmp_path = Path(tmp.name) + os.replace(tmp_path, path) + except OSError as e: + raise ConfigError(f"Failed to write config file {path}: {e}") from e + finally: + if tmp_path is not None: + try: + tmp_path.unlink() + except FileNotFoundError: + pass + except OSError: + pass diff --git a/src/takopi/ids.py b/src/takopi/ids.py index beeda4a..69235d7 100644 --- a/src/takopi/ids.py +++ b/src/takopi/ids.py @@ -5,7 +5,7 @@ import re ID_PATTERN = r"^[a-z0-9_]{1,32}$" _ID_RE = re.compile(ID_PATTERN) -RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"}) +RESERVED_CLI_COMMANDS = frozenset({"config", "doctor", "init", "plugins"}) RESERVED_CHAT_COMMANDS = frozenset( {"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"} ) diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..e893f8d --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,205 @@ +from pathlib import Path +import tomllib + +from typer.testing import CliRunner + +from takopi import cli + + +def _write_min_config(path: Path) -> None: + path.write_text( + 'transport = "telegram"\n' + "\n" + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n", + encoding="utf-8", + ) + + +def test_config_list_outputs_flattened(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n' + "watch_config = true\n" + "\n" + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n", + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + ["config", "list", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0 + lines = [line.strip() for line in result.output.splitlines() if line.strip()] + assert 'transport = "telegram"' in lines + assert "watch_config = true" in lines + assert 'transports.telegram.bot_token = "token"' in lines + assert "transports.telegram.chat_id = 123" in lines + assert not any(line.startswith("default_engine") for line in lines) + + +def test_config_get_outputs_literal_and_table_error(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + _write_min_config(config_path) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + [ + "config", + "get", + "transports.telegram.chat_id", + "--config-path", + str(config_path), + ], + ) + + assert result.exit_code == 0 + assert result.output.strip() == "123" + + result = runner.invoke( + cli.create_app(), + [ + "config", + "get", + "transports.telegram", + "--config-path", + str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "table" in result.output + + +def test_config_get_missing_key(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + _write_min_config(config_path) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + ["config", "get", "nope", "--config-path", str(config_path)], + ) + + assert result.exit_code == 1 + assert result.output == "" + + +def test_config_set_parses_and_writes(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + _write_min_config(config_path) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + ["config", "set", "watch_config", "true", "--config-path", str(config_path)], + ) + assert result.exit_code == 0 + + result = runner.invoke( + cli.create_app(), + [ + "config", + "set", + "default_engine", + "openai", + "--config-path", + str(config_path), + ], + ) + assert result.exit_code == 0 + + result = runner.invoke( + cli.create_app(), + [ + "config", + "set", + "watch_config", + "False", + "--config-path", + str(config_path), + ], + ) + assert result.exit_code == 0 + + result = runner.invoke( + cli.create_app(), + [ + "config", + "set", + "transports.telegram.chat_id", + "456", + "--config-path", + str(config_path), + ], + ) + assert result.exit_code == 0 + + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert data["watch_config"] is False + assert data["default_engine"] == "openai" + assert data["transports"]["telegram"]["chat_id"] == 456 + + +def test_config_unset_prunes_tables(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n' + "\n" + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n" + "\n" + "[projects.foo]\n" + 'path = "/tmp/repo"\n', + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + ["config", "unset", "projects.foo", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0 + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert "projects" not in data + + +def test_config_set_schema_validation_error(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n' + "\n" + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n" + "\n" + "[projects.foo]\n" + 'path = "/tmp/repo"\n', + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli.create_app(), + [ + "config", + "set", + "projects.foo.extra", + "nope", + "--config-path", + str(config_path), + ], + ) + + assert result.exit_code == 2 + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert "extra" not in data.get("projects", {}).get("foo", {})