feat(cli): add takopi config subcommand (#153)

This commit is contained in:
banteg
2026-01-16 11:28:06 +04:00
committed by GitHub
parent dec93019b1
commit 92b33c5181
25 changed files with 1248 additions and 246 deletions
+12 -4
View File
@@ -51,10 +51,18 @@ Rules:
Plugin visibility can be restricted via: Plugin visibility can be restricted via:
```toml === "takopi config"
[plugins]
enabled = ["takopi-engine-acme", "takopi-transport-slack"] ```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. When set, Takopi filters by **distribution name** (package metadata), not by entrypoint name.
This lets you: This lets you:
+24 -8
View File
@@ -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: If you chose **handoff** during onboarding and want to switch to chat mode:
```toml === "takopi config"
[transports.telegram]
session_mode = "chat" # stateless | chat ```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. 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: If you prefer a cleaner chat, hide resume lines:
```toml === "takopi config"
[transports.telegram]
show_resume_line = false ```sh
``` takopi config set transports.telegram.show_resume_line false
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = false
```
## How it behaves in groups ## How it behaves in groups
+22 -10
View File
@@ -4,15 +4,28 @@ Upload files into the active repo/worktree or fetch files back into Telegram.
## Enable file transfer ## Enable file transfer
```toml === "takopi config"
[transports.telegram.files]
enabled = true ```sh
auto_put = true takopi config set transports.telegram.files.enabled true
auto_put_mode = "upload" # upload | prompt takopi config set transports.telegram.files.auto_put true
uploads_dir = "incoming" takopi config set transports.telegram.files.auto_put_mode "upload"
allowed_user_ids = [123456789] takopi config set transports.telegram.files.uploads_dir "incoming"
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"] 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: Notes:
@@ -56,4 +69,3 @@ Directories are zipped automatically.
- [Commands & directives](../reference/commands-and-directives.md) - [Commands & directives](../reference/commands-and-directives.md)
- [Config reference](../reference/config.md) - [Config reference](../reference/config.md)
+52 -18
View File
@@ -11,10 +11,18 @@ takopi init happy-gadgets
This adds a project to your config: This adds a project to your config:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```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 ## Target a project from chat
@@ -28,30 +36,56 @@ Send:
Projects can override global defaults: Projects can override global defaults:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
default_engine = "claude" takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
worktrees_dir = ".worktrees" takopi config set projects.happy-gadgets.default_engine "claude"
worktree_base = "master" 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: If you expect to edit config while Takopi is running, enable hot reload:
```toml === "takopi config"
watch_config = true
``` ```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
## Set a default project ## Set a default project
If you mostly work in one repo: If you mostly work in one repo:
```toml === "takopi config"
default_project = "happy-gadgets"
``` ```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
## Related ## Related
- [Context resolution](../reference/context-resolution.md) - [Context resolution](../reference/context-resolution.md)
- [Worktrees](worktrees.md) - [Worktrees](worktrees.md)
+14 -6
View File
@@ -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: Then send any message in the target chat. Takopi captures the `chat_id` and updates your config:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
chat_id = -1001234567890 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. Messages from that chat now default to the project.
@@ -36,4 +45,3 @@ takopi chat-id
- [Topics](topics.md) - [Topics](topics.md)
- [Context resolution](../reference/context-resolution.md) - [Context resolution](../reference/context-resolution.md)
+14 -5
View File
@@ -27,11 +27,20 @@ Topics bind Telegram **forum threads** to a project/branch context. Each topic k
## Enable topics ## Enable topics
```toml === "takopi config"
[transports.telegram.topics]
enabled = true ```sh
scope = "auto" # auto | main | projects | all 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 ### Scope explained
+14 -6
View File
@@ -4,11 +4,20 @@ Enable transcription so voice notes become normal text runs.
## Enable transcription ## Enable transcription
```toml === "takopi config"
[transports.telegram]
voice_transcription = true ```sh
voice_transcription_model = "gpt-4o-mini-transcribe" # optional 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 OpenAIs transcription API). Set `OPENAI_API_KEY` in your environment (uses OpenAIs transcription API).
@@ -24,4 +33,3 @@ If transcription fails, youll get an error message and the run is skipped.
## Related ## Related
- [Config reference](../reference/config.md) - [Config reference](../reference/config.md)
+16 -7
View File
@@ -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: Add a `worktrees_dir` (and optionally a base branch) to the project:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
worktrees_dir = ".worktrees" # relative to project path takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
worktree_base = "master" # base branch for new worktrees 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 ## Run in a branch worktree
@@ -39,4 +49,3 @@ When you reply, this context carries forward (you usually dont need to repeat
## Related ## Related
- [Context resolution](../reference/context-resolution.md) - [Context resolution](../reference/context-resolution.md)
+36 -12
View File
@@ -52,10 +52,18 @@ BACKEND = EngineBackend(
Engine config is a raw table in `takopi.toml`: Engine config is a raw table in `takopi.toml`:
```toml === "takopi config"
[myengine]
model = "..." ```sh
``` takopi config set myengine.model "..."
```
=== "toml"
```toml
[myengine]
model = "..."
```
## Transport backend plugin ## Transport backend plugin
@@ -92,19 +100,35 @@ BACKEND = MyCommand()
Configure under `[plugins.<id>]`: Configure under `[plugins.<id>]`:
```toml === "takopi config"
[plugins.hello]
greeting = "hello" ```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()`. The parsed dict is available as `ctx.plugin_config` in `handle()`.
## Enable/disable installed plugins ## Enable/disable installed plugins
```toml === "takopi config"
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"] ```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”. - `enabled = []` (default) means “load all installed plugins”.
- If non-empty, only distributions with matching names are visible. - If non-empty, only distributions with matching names are visible.
+151 -26
View File
@@ -4,9 +4,17 @@ Takopi reads configuration from `~/.takopi/takopi.toml`.
If you expect to edit config while Takopi is running, set: If you expect to edit config while Takopi is running, set:
```toml === "takopi config"
watch_config = true
``` ```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
## Top-level keys ## Top-level keys
@@ -19,11 +27,20 @@ watch_config = true
## `transports.telegram` ## `transports.telegram`
```toml === "takopi config"
[transports.telegram]
bot_token = "..." ```sh
chat_id = 123 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 | | Key | Type | Default | Notes |
|-----|------|---------|-------| |-----|------|---------|-------|
@@ -62,14 +79,26 @@ File size limits (not configurable):
## `projects.<alias>` ## `projects.<alias>`
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
worktrees_dir = ".worktrees" takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
default_engine = "claude" takopi config set projects.happy-gadgets.worktrees_dir ".worktrees"
worktree_base = "master" takopi config set projects.happy-gadgets.default_engine "claude"
chat_id = -1001234567890 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 | | Key | Type | Default | Notes |
|-----|------|---------|-------| |-----|------|---------|-------|
@@ -85,10 +114,18 @@ Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into `[t
### `plugins.enabled` ### `plugins.enabled`
```toml === "takopi config"
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"] ```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”. - `enabled = []` (default) means “load all installed plugins”.
- If non-empty, only distributions with matching names are visible (case-insensitive). - If non-empty, only distributions with matching names are visible (case-insensitive).
@@ -99,11 +136,99 @@ Plugin-specific configuration lives under `[plugins.<id>]` and is passed to comm
## Engine-specific config tables ## 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`
[codex]
model = "..."
```
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 <name>` 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"
```
+31 -14
View File
@@ -18,22 +18,39 @@ worktree-based runs via `@branch`.
All config lives in `~/.takopi/takopi.toml`. All config lives in `~/.takopi/takopi.toml`.
See [Config](config.md) for the full reference. See [Config](config.md) for the full reference.
```toml === "takopi config"
default_engine = "codex" # optional
default_project = "z80" # optional
transport = "telegram" # optional, defaults to "telegram"
[transports.telegram] ```sh
bot_token = "..." # required takopi config set default_engine "codex"
chat_id = 123 # required 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] === "toml"
path = "~/dev/z80" # required (repo root)
worktrees_dir = ".worktrees" # optional, default ".worktrees" ```toml
default_engine = "codex" # optional, per-project override default_engine = "codex" # optional
worktree_base = "master" # optional, base for new branches default_project = "z80" # optional
chat_id = -123 # optional, project chat id 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 Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into
`[transports.telegram]` on startup. `[transports.telegram]` on startup.
+21 -9
View File
@@ -68,17 +68,29 @@ Add a new optional `[claude]` section.
Recommended v1 schema: Recommended v1 schema:
```toml === "takopi config"
# ~/.takopi/takopi.toml
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] === "toml"
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 ```toml
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only) # ~/.takopi/takopi.toml
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
``` 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: Notes:
+19 -8
View File
@@ -210,15 +210,26 @@ Claude runner implementation summary (no Takopi domain model changes):
A minimal TOML config for Claude: A minimal TOML config for Claude:
```toml === "takopi config"
[claude]
# model: opus | sonnet | haiku
model = "sonnet"
allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"] ```sh
dangerously_skip_permissions = false takopi config set claude.model "sonnet"
use_api_billing = false 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. 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"]`. If `allowed_tools` is omitted, Takopi defaults to `["Bash", "Read", "Edit", "Write"]`.
+12 -4
View File
@@ -13,10 +13,18 @@ npm i -g opencode-ai@latest
Add to your `takopi.toml`: Add to your `takopi.toml`:
```toml === "takopi config"
[opencode]
model = "claude-sonnet" # optional ```sh
``` takopi config set opencode.model "claude-sonnet"
```
=== "toml"
```toml
[opencode]
model = "claude-sonnet" # optional
```
## Usage ## Usage
+19 -8
View File
@@ -58,16 +58,27 @@ Add a new optional `[pi]` section.
Recommended schema: Recommended schema:
```toml === "takopi config"
# ~/.takopi/takopi.toml
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] === "toml"
model = "..." # optional; passed as --model
provider = "..." # optional; passed as --provider ```toml
extra_args = [] # optional list of strings, appended verbatim # ~/.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: Notes:
+16 -6
View File
@@ -144,11 +144,21 @@ transformation.
A minimal TOML config for Pi: A minimal TOML config for Pi:
```toml === "takopi config"
[pi]
model = "..." ```sh
provider = "..." takopi config set pi.model "..."
extra_args = [] 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. Use `extra_args` for any Pi CLI flags not explicitly mapped.
+63 -20
View File
@@ -28,10 +28,19 @@ directive pipeline as typed text.
Configuration (under `[transports.telegram]`): Configuration (under `[transports.telegram]`):
```toml === "takopi config"
voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" # optional ```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 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 is missing or the audio download fails, takopi replies with a short error and skips
@@ -88,9 +97,17 @@ Behavior:
Configuration (under `[transports.telegram]`): Configuration (under `[transports.telegram]`):
```toml === "takopi config"
forward_coalesce_s = 1.0 # set 0 to disable the delay
``` ```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) ## Chat sessions (optional)
@@ -100,10 +117,19 @@ use chat mode with auto-resume enabled.
Configuration (under `[transports.telegram]`): Configuration (under `[transports.telegram]`):
```toml === "takopi config"
show_resume_line = true # set false to hide resume lines
session_mode = "chat" # or "stateless" ```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: 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 Telegram's 4096 character limit after entity parsing. You can opt into splitting
instead: instead:
```toml === "takopi config"
[transports.telegram]
message_overflow = "split" # trim | split ```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 Split mode sends multiple messages. Each chunk includes the footer; follow-up
chunks add a "continued (N/M)" header. 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]`): Configuration (under `[transports.telegram]`):
```toml === "takopi config"
[transports.telegram.topics]
enabled = true ```sh
scope = "auto" # auto | main | projects | all 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: Requirements:
+26 -9
View File
@@ -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: You can manually change these settings in your config file:
```toml === "takopi config"
[transports.telegram]
session_mode = "chat" # "chat" or "stateless" ```sh
show_resume_line = false # true or false 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: 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: If you prefer always-visible resume lines, set:
```toml === "takopi config"
[transports.telegram]
show_resume_line = true ```sh
``` takopi config set transports.telegram.show_resume_line true
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = true
```
## Reply-to-continue still works ## Reply-to-continue still works
+81 -36
View File
@@ -268,54 +268,99 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on
=== "assistant" === "assistant"
```toml title="~/.takopi/takopi.toml" === "takopi config"
default_engine = "codex"
transport = "telegram"
[transports.telegram] ```sh
bot_token = "..." takopi config set default_engine "codex"
chat_id = 123456789 takopi config set transport "telegram"
session_mode = "chat" # auto-resume takopi config set transports.telegram.bot_token "..."
show_resume_line = false # cleaner chat 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] === "toml"
enabled = false
scope = "auto" ```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" === "workspace"
```toml title="~/.takopi/takopi.toml" === "takopi config"
default_engine = "codex"
transport = "telegram"
[transports.telegram] ```sh
bot_token = "..." takopi config set default_engine "codex"
chat_id = -1001234567890 # forum group takopi config set transport "telegram"
session_mode = "chat" takopi config set transports.telegram.bot_token "..."
show_resume_line = false 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] === "toml"
enabled = true # topics on
scope = "auto" ```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" === "handoff"
```toml title="~/.takopi/takopi.toml" === "takopi config"
default_engine = "codex"
transport = "telegram"
[transports.telegram] ```sh
bot_token = "..." takopi config set default_engine "codex"
chat_id = 123456789 takopi config set transport "telegram"
session_mode = "stateless" # reply-to-continue takopi config set transports.telegram.bot_token "..."
show_resume_line = true # always show resume lines 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] === "toml"
enabled = false
scope = "auto" ```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. This config file controls all of Takopi's behavior. You can edit it directly to change settings or add advanced features.
+35 -13
View File
@@ -109,11 +109,20 @@ Each topic remembers its own default.
Set a default engine in your project config: Set a default engine in your project config:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
default_engine = "claude" 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. 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** **Pattern: Quick questions vs. deep work**
``` === "takopi config"
# Global default for quick stuff
default_engine = "codex"
# Project default for complex codebase ```sh
[projects.backend] # Global default for quick stuff
path = "~/dev/backend" takopi config set default_engine "codex"
default_engine = "claude"
``` # 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. Simple messages go to Codex. `/backend` messages go to Claude.
+39 -13
View File
@@ -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`): This adds an entry to your config (Takopi also fills in defaults like `worktrees_dir`, `default_engine`, and sometimes `worktree_base`):
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```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" !!! 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`. 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: Add worktree config to your project:
```toml === "takopi config"
[projects.happy-gadgets]
path = "~/dev/happy-gadgets" ```sh
worktrees_dir = ".worktrees" # where branches go takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
worktree_base = "main" # base for new branches 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" !!! note "Ignore the worktrees directory"
Add `.worktrees/` to your global gitignore so it doesn't clutter `git status`: 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: If you mostly work in one repo, set it as the default:
```toml === "takopi config"
default_project = "happy-gadgets"
``` ```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
Now messages without a `/project` prefix go to that repo: Now messages without a `/project` prefix go to that repo:
+297 -2
View File
@@ -1,19 +1,29 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import sys import sys
import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from collections.abc import Callable from collections.abc import Callable
from importlib.metadata import EntryPoint from importlib.metadata import EntryPoint
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Any, Literal
import anyio import anyio
from functools import partial from functools import partial
from pydantic import BaseModel
import typer import typer
from . import __version__ 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 .config_migrations import migrate_config
from .commands import get_command from .commands import get_command
from .backends import EngineBackend from .backends import EngineBackend
@@ -47,6 +57,14 @@ from .telegram.topics import _validate_topics_setup_for
logger = get_logger(__name__) 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]: def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
try: try:
@@ -671,6 +689,276 @@ def plugins_cmd(
typer.echo(f" {group} {err.name} ({dist}): {err.error}") 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( def app_main(
ctx: typer.Context, ctx: typer.Context,
version: bool = typer.Option( version: bool = typer.Option(
@@ -774,11 +1062,18 @@ def create_app() -> typer.Typer:
invoke_without_command=True, invoke_without_command=True,
help="Telegram bridge for coding agents. Docs: https://takopi.dev/", 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="init")(init)
app.command(name="chat-id")(chat_id) app.command(name="chat-id")(chat_id)
app.command(name="doctor")(doctor) app.command(name="doctor")(doctor)
app.command(name="onboarding-paths")(onboarding_paths) app.command(name="onboarding-paths")(onboarding_paths)
app.command(name="plugins")(plugins_cmd) app.command(name="plugins")(plugins_cmd)
app.add_typer(config_app, name="config")
app.callback()(app_main) app.callback()(app_main)
for engine_id in _engine_ids_for_cli(): for engine_id in _engine_ids_for_cli():
help_text = f"Run with the {engine_id} engine." help_text = f"Run with the {engine_id} engine."
+28 -1
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
import os
from pathlib import Path from pathlib import Path
import tempfile
from typing import Any from typing import Any
import tomli_w 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: def write_config(config: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True) 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
+1 -1
View File
@@ -5,7 +5,7 @@ import re
ID_PATTERN = r"^[a-z0-9_]{1,32}$" ID_PATTERN = r"^[a-z0-9_]{1,32}$"
_ID_RE = re.compile(ID_PATTERN) _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( RESERVED_CHAT_COMMANDS = frozenset(
{"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"} {"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
) )
+205
View File
@@ -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", {})