mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
Compare commits
22 Commits
bbed7c87cd
..
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36156043ca | |||
| 2cc5222dd3 | |||
| 0343e58dd9 | |||
| 7127190b9a | |||
| de03e5932b | |||
| 76933b044c | |||
| 2a6130495c | |||
| 9b365403d7 | |||
| 20fc493c47 | |||
| b967a000dc | |||
| 90174340d7 | |||
| c7441d4a43 | |||
| 1d05da62c2 | |||
| a9f58428bf | |||
| bbda658eab | |||
| ec228e9219 | |||
| d6c4aa0d4f | |||
| 221d5d5b68 | |||
| d697ecdaf6 | |||
| 836ac8d6f8 | |||
| 72d323d680 | |||
| 76e5b8ac0f |
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project overview
|
||||
|
||||
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
|
||||
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -56,7 +56,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
||||
|
||||
### Config (`config/`)
|
||||
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in.
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `inject_catalog` / `max_catalog_skills`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
||||
|
||||
### Runtime (`runtime/`)
|
||||
|
||||
@@ -64,6 +64,10 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
|
||||
|
||||
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
|
||||
|
||||
### Skills (`skills/`)
|
||||
|
||||
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one), parses the frontmatter subset (`frontmatter.py`), and caches the records against the directory list and its `SKILL.md` timestamps. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root), `reload()` forces a rescan; `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
|
||||
|
||||
### Memory (`memory/`)
|
||||
|
||||
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns; `PRAGMA user_version` 4), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`; `todo_stack` + `access_grants` JSON columns), messages stored as msgspec chat message JSON.
|
||||
@@ -80,7 +84,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
@@ -88,7 +92,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
@@ -98,7 +102,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
||||
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
|
||||
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the mtime-cached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -109,12 +114,12 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
|
||||
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
|
||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count.
|
||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart after `/clear`), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` and `/skills read NAME [--file F]` run the matching tool handlers (the slash path binds the tool context itself); `/skills reload` rescans the roots and rebuilds tools. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: known tools (`read_file`, `run_argv`, `grep_files`, …) print their `* Verb 'target' ` prefix as the call starts and append the outcome (`L1-4 (done)`, `(exit code 0)`) when the result lands, so a blocking tool stays visible while it runs. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines / `[tool ok]`. A second call before the first result (parallel batch) erases the open prefix and falls back to whole lines.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call before the first result (parallel batch) erases the open prefix — only that line, never the streamed line above it — and falls back to whole lines.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
|
||||
@@ -155,6 +155,16 @@ max_context_tokens = 200000
|
||||
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
|
||||
# auto_continue_limits = false
|
||||
|
||||
# Optional skills (instruction directories). See doc/skills.md.
|
||||
# [skills]
|
||||
# enabled = true # false = no skill tools, no prompt catalog
|
||||
# paths = ["~/team-skills"] # explicit roots, highest priority
|
||||
# discover = ["plyngent", "claude"] # our user dir + other harnesses' dirs
|
||||
# include_project_roots = true # also <workspace>/.claude/skills
|
||||
# inject_catalog = true # fold name + description into the system prompt
|
||||
# max_catalog_skills = 50
|
||||
# allow_write = false # true = skip the per-write grant prompt
|
||||
|
||||
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
||||
# [plugins]
|
||||
# enable = ["acme"]
|
||||
@@ -164,6 +174,8 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
|
||||
|
||||
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
||||
|
||||
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). The catalog — name, source, description — is folded into the system prompt when tools are on; the model reads a `SKILL.md` with `skill_read`, searches with `skill_search`, and creates or updates skills with `skill_create` / `skill_edit` behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
|
||||
|
||||
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
|
||||
|
||||
Supported provider presets today:
|
||||
@@ -225,7 +237,7 @@ Sessions resume the **most recently updated** session for the current workspace
|
||||
|
||||
When a tool-loop or PTY limit is hit, the prompt accepts `y` (continue once), `n` (stop), or `yyy` — **stop asking for the rest of this turn** (the next user turn prompts again). `--auto-continue` / `[agent] auto_continue_limits = true` skip the prompt for every turn.
|
||||
|
||||
Tool calls show progress as they run: known tools (`read_file`, `run_argv`, `grep_files`, …) print a `* Read 'src/x.py' ` prefix the moment the call starts, and the outcome (`L1-80 (done)`, `(exit code 0)`) is appended once the result lands. Piped output and `--verbose` print the finished line as before.
|
||||
Tool calls show progress as they run: every builtin tool prints a `* Read 'src/x.py' ` prefix the moment the call starts, and the outcome (`L1-80 (done)`, `(exit code 0)`, `(session 3)`) is appended once the result lands; MCP tools render as `* MCP <server>:<tool> (done)`. Plugin tools keep the `[tool] …` / `[tool ok] …` style. Piped output and `--verbose` print the finished line as before.
|
||||
|
||||
### One-shot (scripts / CI)
|
||||
|
||||
@@ -256,8 +268,8 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/status` | Provider, session, context/usage estimates |
|
||||
| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) |
|
||||
| `/history --full` | Full bodies for the selected window |
|
||||
| `/history [N \| last [N]]` | Conversation grouped by turn: default = the last turn (your message + the model's answer); `N` = turn N, `last N` = the last N turns |
|
||||
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart after `/clear`) |
|
||||
| `/sessions` | Sessions for this workspace |
|
||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||
@@ -272,6 +284,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
||||
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
||||
| `/todos` | Todo/task stack: list, push, pop, done, clear |
|
||||
| `/skills [list\|search\|read\|reload]` | List discovered skills, regex-search them, print a `SKILL.md` (or one bundled file), or rescan the roots |
|
||||
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
|
||||
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
||||
| `/quit` | Leave the REPL |
|
||||
@@ -289,7 +302,9 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
|
||||
|
||||
## Tools (when enabled)
|
||||
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
|
||||
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that the catalog (name + description) goes into the system prompt, the body is read on demand with `skill_read`, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
|
||||
|
||||
Safety defaults:
|
||||
|
||||
|
||||
+5
-1
@@ -23,11 +23,15 @@
|
||||
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
|
||||
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
|
||||
may override provider ``preset`` / ``url`` for mixed API routing.
|
||||
``[skills]`` holds the skill roots, discovery sources, catalog limits, and
|
||||
the standing write grant.
|
||||
- runtime: ``create_client`` maps effective provider preset → protocol client.
|
||||
- skills: Skill discovery (ordered roots, ``SKILL.md`` frontmatter subset,
|
||||
mtime-cached store) and the read/write path rules the tools use.
|
||||
- agent: Kind-based tool loop, streaming, usage, compact; bridges:
|
||||
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
|
||||
``@tool`` / tags / registry. History stays chat-completions-shaped.
|
||||
- tools: Workspace file/process/VCS/chat/todo/net tools + directory-access
|
||||
- tools: Workspace file/process/VCS/chat/todo/net/skill tools + directory-access
|
||||
grants (`access`); catalog; plugins; instance/session context and views.
|
||||
- prompting: Shared ask/choose/form for CLI and tools.
|
||||
- cli: Click entry, slash registry, REPL, one-shot chat.
|
||||
|
||||
@@ -26,6 +26,24 @@
|
||||
# [networking]
|
||||
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
|
||||
|
||||
# Skills: instruction directories holding a SKILL.md (YAML frontmatter with at
|
||||
# least name/description, then a Markdown body) plus optional bundled files.
|
||||
# Discovery roots in priority order: explicit `paths`, our own user config
|
||||
# directory (<config dir>/skills), other harnesses' skill directories
|
||||
# (~/.claude/skills; plus <workspace>/.claude/skills when include_project_roots).
|
||||
# The catalog (name + description) is folded into the system prompt when tools
|
||||
# are on; the model reads bodies with skill_read and searches with skill_search.
|
||||
# Reading needs no grant (the roots are read-only roots); creating or editing a
|
||||
# skill asks once/session, or stands with allow_write = true. See doc/skills.md.
|
||||
# [skills]
|
||||
# enabled = true
|
||||
# paths = ["~/team-skills", "/srv/skills"]
|
||||
# discover = ["plyngent", "claude"]
|
||||
# include_project_roots = true
|
||||
# inject_catalog = true
|
||||
# max_catalog_skills = 50
|
||||
# allow_write = false
|
||||
|
||||
# MCP (Model Context Protocol) servers exposed as agent tools.
|
||||
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
|
||||
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Skills
|
||||
|
||||
A **skill** is a directory of instructions the agent can find, read, and follow:
|
||||
a `SKILL.md` (frontmatter + Markdown body) plus whatever bundled files it needs
|
||||
— scripts, references, templates. Plyngent discovers skills from your own user
|
||||
directory and from the directories other harnesses install into, so one skill
|
||||
folder can serve several agents.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
<root>/
|
||||
pdf-processing/
|
||||
SKILL.md # required: frontmatter + instructions
|
||||
scripts/extract.sh # optional bundled files
|
||||
references/forms.md
|
||||
```
|
||||
|
||||
`SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDFs; fill forms.
|
||||
version: 1
|
||||
allowed-tools: [run_argv, read_file]
|
||||
---
|
||||
|
||||
# PDF processing
|
||||
|
||||
Use `scripts/extract.sh <file>` for the text layer, then ...
|
||||
```
|
||||
|
||||
## Roots and discovery
|
||||
|
||||
Roots are scanned in priority order; the **first** skill with a given name wins
|
||||
and the copies it shadows stay listed (`skill_list`, `/skills`) instead of
|
||||
disappearing:
|
||||
|
||||
1. `[skills].paths` — explicit roots (highest priority)
|
||||
2. our own user directory: `<user config>/skills` (`plyngent config path` shows
|
||||
the directory that contains `plyngent.toml`)
|
||||
3. other harnesses: `~/.claude/skills`, and `<workspace>/.claude/skills` when
|
||||
`include_project_roots` is on (`discover = ["plyngent", "claude"]` by default)
|
||||
|
||||
A root may also *be* a skill directory (its own `SKILL.md`), which is handy when
|
||||
you point `paths` at a single skill. Missing roots are not an error; `/skills`
|
||||
lists the ones with no directory.
|
||||
|
||||
```toml
|
||||
[skills]
|
||||
enabled = true # false = no skill tools and no prompt catalog
|
||||
paths = ["~/team-skills"] # explicit roots; `~` expands to your home
|
||||
discover = ["plyngent", "claude"]
|
||||
include_project_roots = true
|
||||
inject_catalog = true # fold the catalog into the system prompt
|
||||
max_catalog_skills = 50
|
||||
allow_write = false # true = standing grant for skill writes
|
||||
```
|
||||
|
||||
## Frontmatter
|
||||
|
||||
The reader covers the YAML subset those files use — top-level `key: value`
|
||||
scalars (quoted, plain, `true`/`false`, integers), inline `[a, b]` lists, block
|
||||
`- item` lists, and `|` / `>` blocks. A plain scalar folds its indented
|
||||
continuation lines. A nested mapping (say a `metadata:` block) is skipped rather
|
||||
than guessed at, and a structurally broken block degrades to "no metadata" with
|
||||
a note: the skill stays listable instead of vanishing.
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `name` | Skill name; must be lowercase letters, digits, and single hyphens (max 64). A name that cannot be used falls back to the directory name, with a note. |
|
||||
| `description` | One line shown in the catalog and in `skill_list`. Missing → the first non-heading paragraph of the body. |
|
||||
| `version`, `license`, `allowed-tools`, … | Kept and shown by `skill_list`; `allowed-tools` is **recorded, not enforced** today. |
|
||||
|
||||
## What the agent sees
|
||||
|
||||
- **Catalog** (system prompt, `inject_catalog`): names, sources, descriptions —
|
||||
never the bodies, so a turn only pays for the skill it actually reads.
|
||||
- `skill_list` — what exists, where it came from, shadowed copies, and the notes
|
||||
from repaired frontmatter.
|
||||
- `skill_read NAME [file]` — the `SKILL.md` body (ranges, line numbers, and a
|
||||
continuation token like any other read) or one bundled file, resolved inside
|
||||
the skill directory only.
|
||||
- `skill_search PATTERN [skill]` — regex over every visible skill; hits come back
|
||||
as `<skill>/<file>:<line>: content`.
|
||||
|
||||
## Grants: reading vs writing
|
||||
|
||||
- **Reading needs no grant.** The host registers the roots as read-only roots, so
|
||||
`read_file`, `regex_files`, and `listdir` see skill files too. Writes are denied
|
||||
by the path policy — a skill directory never becomes a write grant.
|
||||
- **Writing needs a grant.** `skill_create` / `skill_edit` ask the human for the
|
||||
skill directory: `o` once, `s` for the rest of the chat, anything else (or a
|
||||
timeout) denies. The prompt is independent of YOLO: `--yes` / `/yolo` never
|
||||
skips it. `[skills].allow_write = true` is the standing grant, and the path
|
||||
denylist (`[agent].path_denylist`) still wins over everything.
|
||||
|
||||
`skill_create` writes a new `SKILL.md` (name, description, body) plus any
|
||||
`files` you list; `skill_edit` changes one file by literal replacement
|
||||
(`old_string` → `new_string`, all matches with `replace_all`), by `append`, or
|
||||
by rewriting it. A rewritten `SKILL.md` is re-parsed, and the reply says so when
|
||||
the description (or the whole frontmatter) is gone.
|
||||
|
||||
## Care and safety
|
||||
|
||||
- A skill is **data, not code**: nothing in a skill body bypasses the confirm
|
||||
flow, the path denylist, or the command denylist. Bundled scripts run through
|
||||
the normal process tools, so they still need whatever grant those require.
|
||||
- Third-party skills (other harnesses' directories, shared repos) are untrusted
|
||||
input. Read before you follow, keep `paths` pointed at directories you trust,
|
||||
and prefer `allow_write = false` + per-write prompts when a skill comes from
|
||||
outside.
|
||||
- Keep skills small and specific; long bodies are better split into a short
|
||||
`SKILL.md` plus reference files the model reads on demand.
|
||||
@@ -171,51 +171,51 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.5"
|
||||
version = "3.5.6"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Lightweight in-process concurrent programming"
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
|
||||
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24"},
|
||||
{file = "greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -242,43 +242,58 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "jh2"
|
||||
version = "5.0.14"
|
||||
version = "5.0.15"
|
||||
requires_python = ">=3.7"
|
||||
summary = "HTTP/2 State-Machine based protocol implementation"
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "jh2-5.0.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"},
|
||||
{file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"},
|
||||
{file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:855dea668a30eab826ecdbc07b69b19e8daedeb1f0519e02759a6b37426354c5"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd129c07f30387463e634d7aec629da31b041219e17c6d42fc7a7641e1e7ef8b"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f702abcd2e3666d54f721629ec921a1e698357e28f6348af7b4d9d95b89fe37"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9698673b2cccac7a27dd4ae48d6f4936c998a7962043049783dab4fd03e65084"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9695b0f3c696cb87b851b3f28d5dc81d8932c81a1547713913266b88ee99e37a"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d71f6aa2c97f8042669102200a1a2dc1241e0fb5044cc14032d0321936f85cd0"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4ac921ddd2d519646df9b94f96e2cd55529f92f6d7ff4c65508ca2f65399ef"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f0257484827212950bb4624dbfdf70d713dbaed5b1c489151b41f525fea3c1"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f1f45e3a7f4b13ec10e9a55d1188fb64f090a24fd847ad124fde4da3682e8ab"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:1a7f6ee93c57672f7bd7c3e2c2f841a35c1a0c3571c39abc674e125f344242fa"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_i686.whl", hash = "sha256:7bddf19067d411e900cc19fdfee566cc3bc0aa1b76d07666202a8fcbf2c980e5"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bfd24d34ed13b5943d5d548a86fd0efaa5029aaa6615932e677ea14d1a47324b"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win32.whl", hash = "sha256:03ef4cea5c924a89d3747fa5eca768763e727326ddf8d2ee20b66f4cd2942161"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win_amd64.whl", hash = "sha256:5a32cd4bb2fc7f03315b3415b0d1f37b204f4334b352e68c25e3e31f0750b7cc"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win_arm64.whl", hash = "sha256:4d0b98c5c2f27d7dfcec835960f9e4c76c7936f4bad1c94ab323f8f0831295c6"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:136f1f06d644e105ace8e830a55c66bb6be15355af427c013cefabbb4360889c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7aae38ee0dea5f3af2a918597df28f7659d1e82d1795605ee46c5d291d7e8ff"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f864522d4fafd8a61bf40dc01e2a93840e6c1a17dba37db008529be22ef40a"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:783463574dcac3a6d8ae948e2110bb1b46099c79b919912c88360d21b7c91b5c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d6069850eb0febdcdd649a4f5dbe74560ea6219ddce467808b4b4bf532c14e3"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af18cc6b061b5830b121fff1cc876e864e881909c446fd606903e64a9796567"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aaf2dd05d85289cf83641f3c4f54947a0168fa81c46fc9038ea9d241caad50e"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bad01872ec803e67d91228fe5f040175c86bbdb0b345be4ce0c5ccd620a220a0"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c201b89f9d15f563fe4d6dad0738ce02641636c13b141096ad7497aa7bd95b3c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:b43d69d715f77d840d5600842a179d8175625bcb5f5d8dbe9bc7ed71dcb43696"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:4a511057fb3d8bc6cb76eed0645a8a446e870640d9250144f0d403007383e2c3"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c4a2c16fae658a2f7e67d1af3f1cfaf03003cb2f6d9a8c1d3f237b337614d568"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win32.whl", hash = "sha256:36aeaa24c9af2a2ac0830523721d0fadad5602842d662aa0c5391b89ea3e7d1e"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:a88320003a21783b5ab5c605ba4b687f836b46dadedc55cfc3e64ca88c41b81d"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:7eb050ada76d078cf27b7660451c0ee7b38e3f770222d852cc34d2f4cf0bc782"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:06cd5acc4ff28ac27cca5c2f0985d259e81bc29b845c521aae04440a369a00ae"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8912f22c97cdcb00edce434b61fa1d05af41ad07deb6bb1ed9e06348df11eea"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d28771bccaa6944c4e606a44b1035ea11b2d684bd2c7c195ee3c3a9afd84443f"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e0fda6e8a7b7364077a03749321f3bb9f01aa369ab659f63d241860f4b66bbb"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6596a9d05f049f783326e927b5363ebe0b1da6716835237ae32b642529b405ac"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29a0014ee53ffc8bf0581dd77270e25329397c55f4724bb18fddef9a105177e5"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd7e4d259eab50e076e4b30ecbd937e55b319a58ad75642bdffb401455520b7"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97299e27743ebf52d9544e596165700407b8b46394870dc374d6fd49c3251084"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:95443c602d6e7fa8aa5e49d19381cb5a39d53b79a1678e90a4c39f4f0f0542e5"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:13fa1a5e98a8c8f55405e2e7c6f6a1e005d01a48f7510826ff34939f624b1ad6"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:aa99840f9222928b0c9c0dd2b0c0308f5d1be8f6815bbf6f820746c51de54dd7"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:723a0a4c5ac0e61c94cb275118c7a6a6aaf5fd56084b02632f4b080cc83c6691"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win32.whl", hash = "sha256:fa1c3885ed090c65054e4bbd983a407df0436121b1a841ba7fe1f678a7982c65"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win_amd64.whl", hash = "sha256:8c5850335fe11078b42fab606e5ac4a68c6ee8c6799864badc464f7839f06ec2"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win_arm64.whl", hash = "sha256:9493d5a20f6ba04dc285e3580bcffe64c0d61660c85aee7fc19e9adbaea55ef7"},
|
||||
{file = "jh2-5.0.15-py3-none-any.whl", hash = "sha256:0ed7246846e68109fef261fdb00534ca5af3363d48dbf881d06a2d0e3a814e99"},
|
||||
{file = "jh2-5.0.15.tar.gz", hash = "sha256:53fb377caca50441fd1892f60745c44d3ba8c43844c3792e73c34bf9f841adaf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -382,13 +397,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.7"
|
||||
version = "4.11.12"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
|
||||
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
|
||||
{file = "platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254"},
|
||||
{file = "platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -553,7 +568,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.52"
|
||||
version = "2.0.54"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Database Abstraction Library"
|
||||
groups = ["default"]
|
||||
@@ -563,16 +578,22 @@ dependencies = [
|
||||
"typing-extensions>=4.6.0",
|
||||
]
|
||||
files = [
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"},
|
||||
{file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"},
|
||||
{file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"},
|
||||
{file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"},
|
||||
{file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -599,7 +620,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.24.907"
|
||||
version = "2.25.900"
|
||||
requires_python = ">=3.7"
|
||||
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
||||
groups = ["default"]
|
||||
@@ -609,8 +630,8 @@ dependencies = [
|
||||
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
|
||||
]
|
||||
files = [
|
||||
{file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
|
||||
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
|
||||
{file = "urllib3_future-2.25.900-py3-none-any.whl", hash = "sha256:1d4809b9714aef7f35338b7d3a388b0280aefa41d2edb78ba48de91309d014c8"},
|
||||
{file = "urllib3_future-2.25.900.tar.gz", hash = "sha256:0583bd313d223cb0d92367fe9bf07e7c8d926eebb0bdf6960670102e7ad4b6c1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -22,6 +22,8 @@ use `run_argv_batch` for ordered pipelines.
|
||||
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
|
||||
`read_file(with_lineno=true)`, and line numbers reset after any file write.
|
||||
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
|
||||
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
|
||||
`path` is a literal file/dir (no glob expansion).
|
||||
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
|
||||
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
|
||||
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||
|
||||
@@ -387,6 +387,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
if isinstance(state_obj, ReplState):
|
||||
state_obj.instance_state.workspace.policy_confirm_hook = None
|
||||
state_obj.instance_state.workspace.policy_allowed_commands.clear()
|
||||
from plyngent.tools.skills import set_skill_write_policy
|
||||
|
||||
set_skill_write_policy(None, instance=state_obj.instance_state)
|
||||
from plyngent.tools.net import (
|
||||
clear_private_grants,
|
||||
clear_ssrf_assume_public_cidrs,
|
||||
|
||||
+457
-49
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
@@ -31,6 +32,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_TOOL_RESULT_PREVIEW = 120
|
||||
_TOOL_ARGS_PREVIEW = 80
|
||||
_ASK_SUBJECT_PREVIEW = 60
|
||||
_MCP_TOOL_PREFIX = "mcp__"
|
||||
_SKILL_MAIN_FILE = "SKILL.md"
|
||||
|
||||
# Process/session display flags (set from ReplState / slash).
|
||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
||||
@@ -123,6 +127,33 @@ def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _json_int_arg(args_json: str, key: str) -> int | None:
|
||||
value = _json_arg(args_json, key)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _status_fields(result: str) -> dict[str, str]:
|
||||
"""Parse the leading ``key=value`` block of a structured tool result.
|
||||
|
||||
The block ends at the first ``--- <payload> ---`` marker (``run_argv``,
|
||||
``fetch``, ``read_pty``).
|
||||
"""
|
||||
fields: dict[str, str] = {}
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def _pretty_prefix(text: str) -> str:
|
||||
"""Style the leading ``* Verb 'target' `` segment of a pretty tool line."""
|
||||
return click.style(text, fg="yellow")
|
||||
@@ -163,6 +194,14 @@ def _read_file_prefix(args_json: str) -> str:
|
||||
return _pretty_prefix(f"* Read '{path}' ")
|
||||
|
||||
|
||||
def _range_detail(result: str) -> str | None:
|
||||
"""``L{begin}-{end} (done)`` detail of a ranged result, when it carries one."""
|
||||
first = result.splitlines()[0] if result else ""
|
||||
if first.startswith("L") and "-" in first:
|
||||
return _pretty_segments((f"{first} ", "dim"), ("(done)", "green"))
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_detail(_args_json: str, result: str) -> str:
|
||||
"""``read_file`` outcome: range/done, or which failure kind occurred."""
|
||||
if result.startswith("error: file not found"):
|
||||
@@ -171,16 +210,21 @@ def _read_file_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments(("(not a file)", "red"))
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments(("(error)", "red"))
|
||||
lines = result.splitlines()
|
||||
first = lines[0] if lines else ""
|
||||
if first.startswith("L") and "-" in first:
|
||||
return _pretty_segments((f"{first} ", "dim"), ("(done)", "green"))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
_TODO_LABELS: dict[str, str] = {
|
||||
"todo_list": "Todo List",
|
||||
"todo_push": "Todo Push",
|
||||
"todo_pop": "Todo Pop",
|
||||
"todo_update": "Todo Update",
|
||||
"todo_clear": "Todo Clear",
|
||||
}
|
||||
|
||||
|
||||
def _todo_line(name: str) -> PrettyLine:
|
||||
"""``todo_push`` / ``todo_update``: header first, rendered stack as the detail."""
|
||||
label = "Todo Push" if name == "todo_push" else "Todo Update"
|
||||
"""Todo tools: header first, the rendered stack as the detail."""
|
||||
label = _TODO_LABELS[name]
|
||||
|
||||
def prefix(_args_json: str) -> str:
|
||||
return click.style(f"* {label}:", fg="yellow")
|
||||
@@ -288,14 +332,14 @@ def _glob_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments((f"({count} {unit})", None))
|
||||
|
||||
|
||||
def _grep_prefix(args_json: str) -> str:
|
||||
def _regex_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
path = _json_str_arg(args_json, "path") or "."
|
||||
return _pretty_prefix(f"* Grep '{pattern}' in '{path}' ")
|
||||
return _pretty_prefix(f"* Search '{pattern}' in '{path}' ")
|
||||
|
||||
|
||||
def _grep_detail(_args_json: str, result: str) -> str:
|
||||
"""``grep_files`` outcome: matches and file count, or none/error."""
|
||||
def _regex_detail(_args_json: str, result: str) -> str:
|
||||
"""``regex_files`` outcome: matches and file count, or none/error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
@@ -317,13 +361,7 @@ def _run_argv_detail(_args_json: str, result: str) -> str:
|
||||
"""``run_argv`` outcome: exit code, timeout, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields: dict[str, str] = {}
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
fields[key] = value
|
||||
fields = _status_fields(result)
|
||||
if fields.get("timed_out") == "true":
|
||||
return _pretty_segments(("(timed out)", "red"))
|
||||
code = fields.get("exit_code", "")
|
||||
@@ -363,13 +401,7 @@ def _fetch_detail(_args_json: str, result: str) -> str:
|
||||
"""``fetch`` outcome: HTTP status, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
status = ""
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if line.startswith("status="):
|
||||
status = line.partition("=")[2]
|
||||
break
|
||||
status = _status_fields(result).get("status", "")
|
||||
return _pretty_segments((f"({status or 'done'})", _http_status_fg(status)))
|
||||
|
||||
|
||||
@@ -413,6 +445,10 @@ def _mutator_line(name: str) -> PrettyLine:
|
||||
)
|
||||
|
||||
|
||||
def _vcs_kind_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Kind ")
|
||||
|
||||
|
||||
def _vcs_status_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Status ")
|
||||
|
||||
@@ -428,6 +464,15 @@ def _vcs_log_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Log ")
|
||||
|
||||
|
||||
def _vcs_diff_prefix(args_json: str) -> str:
|
||||
staged = _json_arg(args_json, "staged") is True
|
||||
return _pretty_prefix("* VCS Diff (staged) " if staged else "* VCS Diff ")
|
||||
|
||||
|
||||
def _vcs_branch_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Branch ")
|
||||
|
||||
|
||||
def _vcs_log_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_log`` outcome: commit count, none, or error."""
|
||||
if result.startswith("error:"):
|
||||
@@ -454,15 +499,311 @@ def _wait_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments(("(disturbed)", "yellow"))
|
||||
|
||||
|
||||
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
|
||||
# PTY sessions share one prefix shape: ``* PTY <verb> <session> ``.
|
||||
def _pty_session_label(args_json: str) -> str:
|
||||
session_id = _json_int_arg(args_json, "session_id")
|
||||
return "?" if session_id is None else str(session_id)
|
||||
|
||||
|
||||
def _pty_session_line(verb: str, detail: Callable[[str, str], str]) -> PrettyLine:
|
||||
"""``* PTY <verb> <session> `` plus the verb's own outcome."""
|
||||
return PrettyLine(
|
||||
prefix=lambda args_json: _pretty_prefix(f"* PTY {verb} {_pty_session_label(args_json)} "),
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _open_pty_prefix(args_json: str) -> str:
|
||||
argv = _json_list_arg(args_json, "command")
|
||||
cmd = shlex.join(argv) if argv else "?"
|
||||
return _pretty_prefix(f"* PTY Open $ {cmd} ")
|
||||
|
||||
|
||||
def _open_pty_detail(_args_json: str, result: str) -> str:
|
||||
"""``open_pty`` outcome: the new session id, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
session_id = _status_fields(result).get("session_id", "")
|
||||
return _pretty_segments((f"(session {session_id or '?'})", "green"))
|
||||
|
||||
|
||||
def _read_pty_detail(args_json: str, result: str) -> str:
|
||||
"""``read_pty`` outcome: exit status, matched wait, payload size, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
data = result.split("--- data ---", 1)[1].removeprefix("\n") if "--- data ---" in result else ""
|
||||
count = len(data.splitlines())
|
||||
if fields.get("alive") == "false":
|
||||
code = fields.get("exit_code", "")
|
||||
return _pretty_segments((f"(exited {code})" if code else "(exited)", "green"))
|
||||
unit = "line" if count == 1 else "lines"
|
||||
if _json_str_arg(args_json, "until") and fields.get("matched") == "true":
|
||||
return _pretty_segments((f"(matched, {count} {unit})", "green"))
|
||||
if count == 0:
|
||||
return _pretty_segments(("(alive, no output)", "dim"))
|
||||
return _pretty_segments((f"(alive, {count} {unit})", None))
|
||||
|
||||
|
||||
def _pty_write_detail(_args_json: str, result: str) -> str:
|
||||
"""``write_pty`` / ``write_pty_keys`` / ``ask_into_pty``: size, sent, error.
|
||||
|
||||
The human's answer never appears here — only that it was written.
|
||||
"""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
if fields.get("source") == "human":
|
||||
extra = ", secret" if fields.get("secret") == "true" else ""
|
||||
return _pretty_segments((f"(answer sent{extra})", "green"))
|
||||
wrote = fields.get("wrote", "")
|
||||
if wrote:
|
||||
unit = "char" if wrote == "1" else "chars"
|
||||
return _pretty_segments((f"({wrote} {unit})", None))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _close_pty_detail(_args_json: str, result: str) -> str:
|
||||
"""``close_pty`` outcome: closed, already closed, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if _status_fields(result).get("closed") == "true":
|
||||
return _pretty_segments(("(closed)", "green"))
|
||||
return _pretty_segments(("(already closed)", "dim"))
|
||||
|
||||
|
||||
def _ask_line(verb: str, field: str, detail: Callable[[str, str], str]) -> PrettyLine:
|
||||
"""``* Ask <verb> '<subject>' `` plus the verb's own outcome.
|
||||
|
||||
*field* names the argument that carries the human-facing subject: the
|
||||
question for ``ask_user_line`` / ``ask_user_choice``, the title for a form.
|
||||
"""
|
||||
label = f"Ask {verb}" if verb else "Ask"
|
||||
|
||||
def prefix(args_json: str) -> str:
|
||||
subject = _json_str_arg(args_json, field) or "?"
|
||||
return _pretty_prefix(f"* {label} '{_preview(subject, _ASK_SUBJECT_PREVIEW)}' ")
|
||||
|
||||
return PrettyLine(prefix=prefix, detail=detail)
|
||||
|
||||
|
||||
def _ask_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_line`` outcome: an answer came back, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(answered)" if result else "(empty)", "green"))
|
||||
|
||||
|
||||
def _ask_choice_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_choice`` outcome: the chosen label, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if not result:
|
||||
return _pretty_segments(("(no selection)", "dim"))
|
||||
return _pretty_segments((f"({_preview(result, _ASK_SUBJECT_PREVIEW)})", "green"))
|
||||
|
||||
|
||||
def _ask_form_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_form`` outcome: answer count, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
try:
|
||||
loaded: object = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
return _pretty_segments(("(answered)", "green"))
|
||||
if not isinstance(loaded, dict):
|
||||
return _pretty_segments(("(answered)", "green"))
|
||||
count = len(cast("dict[str, object]", loaded))
|
||||
unit = "answer" if count == 1 else "answers"
|
||||
return _pretty_segments((f"({count} {unit})", "green"))
|
||||
|
||||
|
||||
def _access_prefix(args_json: str) -> str:
|
||||
path = _json_str_arg(args_json, "path") or "?"
|
||||
mode = _json_str_arg(args_json, "mode") or "read"
|
||||
return _pretty_prefix(f"* Access '{path}' ({mode}) ")
|
||||
|
||||
|
||||
def _access_detail(_args_json: str, result: str) -> str:
|
||||
"""``request_directory_access``: granted, already allowed, or denied."""
|
||||
if result.startswith("access granted:"):
|
||||
return _pretty_segments(("(granted)", "green"))
|
||||
if result.startswith("already accessible:"):
|
||||
return _pretty_segments(("(already accessible)", "dim"))
|
||||
if result.startswith("already granted:"):
|
||||
return _pretty_segments(("(already granted)", "dim"))
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(done)", None))
|
||||
|
||||
|
||||
def _temp_dir_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Temp Dir ")
|
||||
|
||||
|
||||
def _temp_dir_detail(_args_json: str, result: str) -> str:
|
||||
"""``new_temporary_workspace`` outcome: the created path, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
path = _status_fields(result).get("temporary_workspace", "")
|
||||
return _pretty_segments((f"({path})" if path else "(done)", "dim"))
|
||||
|
||||
|
||||
def _resume_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Resume Truncated ")
|
||||
|
||||
|
||||
def _resume_detail(_args_json: str, result: str) -> str:
|
||||
"""``get_truncated`` outcome: the resumed range, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _vcs_kind_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_kind`` outcome: the detected kind, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments((f"({result})", None))
|
||||
|
||||
|
||||
def _vcs_branch_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_branch`` outcome: the current branch or head, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments((f"({result})", None))
|
||||
|
||||
|
||||
def _vcs_diff_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_diff`` outcome: changed files and line counts, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no diff)":
|
||||
return _pretty_segments(("(no diff)", "dim"))
|
||||
files = 0
|
||||
added = 0
|
||||
removed = 0
|
||||
for line in result.splitlines():
|
||||
if line.startswith("diff --git "):
|
||||
files += 1
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
added += 1
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
removed += 1
|
||||
file_unit = "file" if files == 1 else "files"
|
||||
return _pretty_segments((f"({files} {file_unit}, +{added}/-{removed})", None))
|
||||
|
||||
|
||||
def _mcp_detail(_args_json: str, result: str) -> str:
|
||||
"""MCP tool outcome: done, or the error the server returned."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _skill_list_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Skills ")
|
||||
|
||||
|
||||
def _skill_list_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_list`` outcome: how many skills were found, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
match = re.match(r"skills: (\d+)", result)
|
||||
if match is None:
|
||||
return _pretty_segments(("(none)", "dim"))
|
||||
count = int(match.group(1))
|
||||
unit = "skill" if count == 1 else "skills"
|
||||
return _pretty_segments((f"({count} {unit})", None if count else "dim"))
|
||||
|
||||
|
||||
def _skill_read_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
file = _json_str_arg(args_json, "file") or ""
|
||||
subject = f"'{name}'" if file in {"", _SKILL_MAIN_FILE} else f"'{name}' {file}"
|
||||
return _pretty_prefix(f"* Skill {subject} ")
|
||||
|
||||
|
||||
def _skill_read_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_read`` outcome: the range read, or the failure kind."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _skill_search_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
scope = _json_str_arg(args_json, "skill") or ""
|
||||
where = f" in '{scope}'" if scope else ""
|
||||
return _pretty_prefix(f"* Skill Search '{_preview(pattern, _ASK_SUBJECT_PREVIEW)}'{where} ")
|
||||
|
||||
|
||||
def _skill_search_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_search`` outcome: matches and skills, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
return _pretty_segments(("(no matches)", "dim"))
|
||||
if result == "(no skills to search)":
|
||||
return _pretty_segments(("(no skills)", "dim"))
|
||||
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
|
||||
skills = {line.split("/", 1)[0] for line in matches}
|
||||
match_unit = "match" if len(matches) == 1 else "matches"
|
||||
skill_unit = "skill" if len(skills) == 1 else "skills"
|
||||
return _pretty_segments((f"({len(matches)} {match_unit} in {len(skills)} {skill_unit})", None))
|
||||
|
||||
|
||||
def _skill_create_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
return _pretty_prefix(f"* Skill Create '{name}' ")
|
||||
|
||||
|
||||
def _skill_create_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_create`` outcome: created (with any notes), or the failure."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
detail = _pretty_segments(("(created)", "green"))
|
||||
notes = result.partition("\nnotes: ")[2]
|
||||
if notes:
|
||||
detail += _pretty_segments((f" — {notes}", "yellow"))
|
||||
return detail
|
||||
|
||||
|
||||
def _skill_edit_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
file = _json_str_arg(args_json, "file") or ""
|
||||
subject = f"'{name}'" if file in {"", _SKILL_MAIN_FILE} else f"'{name}' {file}"
|
||||
return _pretty_prefix(f"* Skill Edit {subject} ")
|
||||
|
||||
|
||||
def _skill_edit_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_edit`` outcome: the change made, or the failure."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
first = result.splitlines()[0] if result else ""
|
||||
match = re.search(r"\((.+)\)$", first)
|
||||
text = match.group(1) if match is not None else "updated"
|
||||
detail = _pretty_segments((f"({text})", "green"))
|
||||
notes = result.partition("\nnotes: ")[2]
|
||||
if notes:
|
||||
detail += _pretty_segments((f" — {notes}", "yellow"))
|
||||
return detail
|
||||
|
||||
|
||||
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
|
||||
_PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"read_file": PrettyLine(prefix=_read_file_prefix, detail=_read_file_detail),
|
||||
"tree": PrettyLine(prefix=_tree_prefix, detail=_tree_detail),
|
||||
"todo_list": _todo_line("todo_list"),
|
||||
"todo_push": _todo_line("todo_push"),
|
||||
"todo_pop": _todo_line("todo_pop"),
|
||||
"todo_update": _todo_line("todo_update"),
|
||||
"todo_clear": _todo_line("todo_clear"),
|
||||
"listdir": PrettyLine(prefix=_listdir_prefix, detail=_listdir_detail),
|
||||
"glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail),
|
||||
"grep_files": PrettyLine(prefix=_grep_prefix, detail=_grep_detail),
|
||||
"regex_files": PrettyLine(prefix=_regex_prefix, detail=_regex_detail),
|
||||
"get_truncated": PrettyLine(prefix=_resume_prefix, detail=_resume_detail),
|
||||
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
|
||||
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
|
||||
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
|
||||
@@ -472,19 +813,50 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"copy_path": _mutator_line("copy_path"),
|
||||
"move_path": _mutator_line("move_path"),
|
||||
"delete_path": _mutator_line("delete_path"),
|
||||
"new_temporary_workspace": PrettyLine(prefix=_temp_dir_prefix, detail=_temp_dir_detail),
|
||||
"request_directory_access": PrettyLine(prefix=_access_prefix, detail=_access_detail),
|
||||
"skill_list": PrettyLine(prefix=_skill_list_prefix, detail=_skill_list_detail),
|
||||
"skill_read": PrettyLine(prefix=_skill_read_prefix, detail=_skill_read_detail),
|
||||
"skill_search": PrettyLine(prefix=_skill_search_prefix, detail=_skill_search_detail),
|
||||
"skill_create": PrettyLine(prefix=_skill_create_prefix, detail=_skill_create_detail),
|
||||
"skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
|
||||
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
|
||||
"read_pty": _pty_session_line("Read", _read_pty_detail),
|
||||
"write_pty": _pty_session_line("Write", _pty_write_detail),
|
||||
"write_pty_keys": _pty_session_line("Keys", _pty_write_detail),
|
||||
"ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
|
||||
"close_pty": _pty_session_line("Close", _close_pty_detail),
|
||||
"ask_user_line": _ask_line("", "question", _ask_detail),
|
||||
"ask_user_choice": _ask_line("Choose", "question", _ask_choice_detail),
|
||||
"ask_user_form": _ask_line("Form", "title", _ask_form_detail),
|
||||
"vcs_kind": PrettyLine(prefix=_vcs_kind_prefix, detail=_vcs_kind_detail),
|
||||
"vcs_status": PrettyLine(prefix=_vcs_status_prefix, detail=_vcs_status_detail),
|
||||
"vcs_diff": PrettyLine(prefix=_vcs_diff_prefix, detail=_vcs_diff_detail),
|
||||
"vcs_log": PrettyLine(prefix=_vcs_log_prefix, detail=_vcs_log_detail),
|
||||
"vcs_branch": PrettyLine(prefix=_vcs_branch_prefix, detail=_vcs_branch_detail),
|
||||
"wait": PrettyLine(prefix=_wait_prefix, detail=_wait_detail),
|
||||
}
|
||||
|
||||
_PRETTY_TOOLS = frozenset(_PRETTY_LINES)
|
||||
|
||||
|
||||
def _pretty_parts(name: str, args_json: str, result: str) -> tuple[str, str] | None:
|
||||
"""Prefix + detail for a known tool call; None keeps the ``[tool]`` style."""
|
||||
line = _PRETTY_LINES.get(name)
|
||||
if line is None:
|
||||
def _mcp_line(name: str) -> PrettyLine | None:
|
||||
"""Generic renderer for namespaced MCP tools (``mcp__<server>__<tool>``)."""
|
||||
if not name.startswith(_MCP_TOOL_PREFIX):
|
||||
return None
|
||||
server, _, tool = name.removeprefix(_MCP_TOOL_PREFIX).partition("__")
|
||||
label = f"{server or '?'}:{tool or '?'}"
|
||||
return PrettyLine(
|
||||
prefix=lambda _args_json: _pretty_prefix(f"* MCP {label} "),
|
||||
detail=_mcp_detail,
|
||||
)
|
||||
|
||||
|
||||
def _pretty_line_for(name: str) -> PrettyLine | None:
|
||||
"""Resolve the renderer for *name*: a named entry, else the MCP naming rule."""
|
||||
return _PRETTY_LINES.get(name) or _mcp_line(name)
|
||||
|
||||
|
||||
def _pretty_parts(line: PrettyLine, args_json: str, result: str) -> tuple[str, str]:
|
||||
"""Prefix + detail for a resolved pretty tool call."""
|
||||
return line.prefix(args_json), line.detail(args_json, result)
|
||||
|
||||
|
||||
@@ -495,25 +867,58 @@ def _echo_stream(text: str) -> None:
|
||||
_ = sys.stdout.flush()
|
||||
|
||||
|
||||
def _clear_streamed_lines(line_count: int) -> None:
|
||||
"""Move cursor up and clear the streamed plain-text region (TTY only)."""
|
||||
def _clear_streamed_lines(line_count: int, *, resume_above: bool = False) -> None:
|
||||
"""Clear exactly *line_count* terminal lines ending at the cursor line.
|
||||
|
||||
The cursor ends at column 0 of the topmost cleared line, so a re-render
|
||||
overwrites the erased block in place instead of appending below it. With
|
||||
``resume_above`` the cursor steps one line further up: the next
|
||||
``"\\n"``-prefixed write then reuses the last cleared line — needed by the
|
||||
parallel-batch fallback, which must not touch the line above the prefix.
|
||||
|
||||
``line_count`` counts *physical* rows; callers fold wrapped lines via
|
||||
:func:`_line_count_for_clear`.
|
||||
"""
|
||||
if line_count <= 0:
|
||||
return
|
||||
# Clear current line, then each previous line of the streamed block.
|
||||
for _ in range(line_count):
|
||||
# Clear the cursor line, then each line above it while moving up.
|
||||
for _ in range(line_count - 1):
|
||||
_ = sys.stdout.write("\r\033[2K\033[1A")
|
||||
_ = sys.stdout.write("\r\033[2K")
|
||||
if resume_above:
|
||||
_ = sys.stdout.write("\033[1A")
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stdout.flush()
|
||||
|
||||
|
||||
def _terminal_columns() -> int:
|
||||
"""Terminal width for wrap-aware row counts (fallback: 80)."""
|
||||
try:
|
||||
columns = shutil.get_terminal_size().columns
|
||||
except OSError, ValueError:
|
||||
return 80
|
||||
return columns if columns > 0 else 80
|
||||
|
||||
|
||||
def _physical_line_count(text: str) -> int:
|
||||
"""Rows streamed *text* occupies on the terminal, counting wrapping.
|
||||
|
||||
``_echo_stream`` writes without a trailing newline, so the cursor sits at
|
||||
the end of the last row and owns one row even when the text ends in ``"\\n"``.
|
||||
A row filled to the exact width is counted once: the wrap of the final
|
||||
column is pending, so erasing must not claim the row below it.
|
||||
"""
|
||||
columns = _terminal_columns()
|
||||
return sum(max(1, -(-len(line) // columns)) for line in text.split("\n"))
|
||||
|
||||
|
||||
def _line_count_for_clear(label: str, body: str) -> int:
|
||||
"""Approximate terminal lines used by ``label\\n + body`` for cursor erase."""
|
||||
"""Physical terminal rows used by ``label\\n + body`` for cursor erase."""
|
||||
if not body and not label:
|
||||
return 0
|
||||
# Label is on its own line; body may contain newlines.
|
||||
# Label is on its own line; body may contain newlines and wrap.
|
||||
text = f"{label}\n{body}" if label else body
|
||||
return text.count("\n") + 1
|
||||
return _physical_line_count(text)
|
||||
|
||||
|
||||
class _PrettyToolStream:
|
||||
@@ -542,7 +947,9 @@ class _PrettyToolStream:
|
||||
return
|
||||
if self._open:
|
||||
# Parallel batch: the open prefix could never receive its detail.
|
||||
_clear_streamed_lines(1)
|
||||
# Erase only the prefix line — the line above may be streamed
|
||||
# reasoning — and let the fallback's leading newline reuse it.
|
||||
_clear_streamed_lines(1, resume_above=True)
|
||||
self._open = False
|
||||
self._whole_lines = True
|
||||
return
|
||||
@@ -591,7 +998,8 @@ def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
|
||||
click.echo()
|
||||
return
|
||||
if pretty:
|
||||
lines = _line_count_for_clear("assistant:", body)
|
||||
# +1: the blank separator ``begin_assistant`` printed above the label.
|
||||
lines = _line_count_for_clear("assistant:", body) + 1
|
||||
_clear_streamed_lines(lines)
|
||||
print_markdown(body, label="assistant:")
|
||||
click.echo()
|
||||
@@ -627,7 +1035,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
printed_assistant = False
|
||||
# Tool calls buffer so prettified tools can render once their result lands
|
||||
# (the summary line needs the status/range). FIFO matches loop event order.
|
||||
pending_tools: list[tuple[str, str, bool]] = []
|
||||
pending_tools: list[tuple[str, str, PrettyLine | None]] = []
|
||||
|
||||
def flush_assistant() -> None:
|
||||
nonlocal source, assistant_buf, printed_assistant
|
||||
@@ -680,23 +1088,23 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
name = call.function.name
|
||||
args = call.function.arguments
|
||||
pretty_tool = name in _PRETTY_TOOLS
|
||||
pending_tools.append((name, args, pretty_tool))
|
||||
if pretty_tool:
|
||||
tool_lines.start_call(_PRETTY_LINES[name].prefix(args))
|
||||
line = _pretty_line_for(name)
|
||||
pending_tools.append((name, args, line))
|
||||
if line is not None:
|
||||
tool_lines.start_call(line.prefix(args))
|
||||
else:
|
||||
tool_lines.close_line()
|
||||
preview = _preview(args, _TOOL_ARGS_PREVIEW)
|
||||
click.secho(f"\n[tool] {name}({preview})", fg="yellow")
|
||||
else:
|
||||
tool_lines.close_line()
|
||||
pending_tools.append(("custom", call.id, False))
|
||||
pending_tools.append(("custom", call.id, None))
|
||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
flush_assistant()
|
||||
content = event.message.content
|
||||
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
|
||||
parts = _pretty_parts(name, args, content) if pretty_tool else None
|
||||
name, args, line = pending_tools.pop(0) if pending_tools else ("", "", None)
|
||||
parts = _pretty_parts(line, args, content) if line is not None else None
|
||||
if parts is not None and not show_full:
|
||||
prefix, detail = parts
|
||||
if not tool_lines.finish_call(detail):
|
||||
|
||||
@@ -21,6 +21,7 @@ from plyngent.prompting import (
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.skills import SkillWriteDecision
|
||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -347,6 +348,54 @@ def prompt_policy_fetch_confirm(
|
||||
return False
|
||||
|
||||
|
||||
def prompt_skill_write_confirm(
|
||||
skill_dir: Path,
|
||||
action: str,
|
||||
detail: str,
|
||||
timeout_seconds: float,
|
||||
) -> SkillWriteDecision:
|
||||
"""Ask whether to write into *skill_dir* (timed; default deny).
|
||||
|
||||
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
|
||||
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
|
||||
denies. Independent of YOLO: a skill directory is not a path grant, so the
|
||||
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
|
||||
"""
|
||||
lines = [
|
||||
f"action: {action} skill {skill_dir.name!r}",
|
||||
f"path: {skill_dir}",
|
||||
f"change: {detail}",
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
||||
]
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return SkillWriteDecision(allow=False)
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
|
||||
backend.echo()
|
||||
prompt = (
|
||||
f"[skills] allow {action} in {skill_dir.name!r}? [o]nce/[s]ession/[n]o (timeout {timeout_seconds:g}s): "
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
token = raw.strip().lower()
|
||||
if token in {"s", "session"}:
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
if token in {"o", "once", "y", "yes"}:
|
||||
return SkillWriteDecision(allow=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return SkillWriteDecision(allow=False)
|
||||
|
||||
|
||||
def prompt_directory_access_confirm(
|
||||
path: Path,
|
||||
mode: AccessMode,
|
||||
|
||||
+148
-161
@@ -2,23 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, cast, override
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, override
|
||||
|
||||
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||
import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
|
||||
from plyngent.cli.retry import retry_pending_with_retries
|
||||
from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.runtime import ProviderNotSupportedError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,10 +18,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
from plyngent.cli.state import ReplState, YoloMode
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
_DEFAULT_HISTORY_LINES = 20
|
||||
_CONTENT_PREVIEW = 200
|
||||
_COMPACT_PREVIEW = 400
|
||||
_ON_OFF_CHOICES = ("on", "off")
|
||||
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||
@@ -49,33 +38,77 @@ _TODO_ACTION_CHOICES = (
|
||||
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||
|
||||
|
||||
class HistoryLimitType(click.ParamType[int]):
|
||||
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
||||
class HistoryTargetType(click.ParamType[int | Literal["last"]]):
|
||||
"""``N`` (turn number >= 1) or the keyword ``last`` (most recent turn)."""
|
||||
|
||||
name: str = "history_limit"
|
||||
name: str = "history_target"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
if isinstance(value, int):
|
||||
if value < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int | Literal["last"]:
|
||||
text = value.strip().lower() if isinstance(value, str) else str(value)
|
||||
if text == "last":
|
||||
return 1
|
||||
return "last"
|
||||
try:
|
||||
n = int(text, 10)
|
||||
except ValueError:
|
||||
self.fail("expected an integer N or 'last'", param, ctx)
|
||||
self.fail("expected a turn number N or 'last'", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
tokens = ("last", "1", "5", "10", "20")
|
||||
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
|
||||
del param
|
||||
tokens = ["last"]
|
||||
state = _repl_state(ctx)
|
||||
if state is not None:
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
turns = build_transcript(state.agent.messages).turns
|
||||
# Recent turn numbers are the ones a human is likely to jump back to.
|
||||
tokens += [str(turn.ordinal) for turn in turns[-5:]]
|
||||
token = incomplete.strip().lower()
|
||||
return [CompletionItem(item) for item in tokens if item.startswith(token)]
|
||||
|
||||
|
||||
HISTORY_TARGET = HistoryTargetType()
|
||||
|
||||
|
||||
class HistoryRowNumberType(click.ParamType[int]):
|
||||
"""One-based display number of a transcript row (``/history --message N``)."""
|
||||
|
||||
name: str = "history_message"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
try:
|
||||
n = int(str(value), 10)
|
||||
except ValueError:
|
||||
self.fail("expected a message number N", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del param
|
||||
state = _repl_state(ctx)
|
||||
if state is None:
|
||||
return []
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
transcript = build_transcript(state.agent.messages)
|
||||
token = incomplete.strip()
|
||||
recent = range(max(1, transcript.messages - 4), transcript.messages + 1)
|
||||
return [CompletionItem(str(n)) for n in recent if str(n).startswith(token)]
|
||||
|
||||
|
||||
HISTORY_ROW_NUMBER = HistoryRowNumberType()
|
||||
|
||||
|
||||
HELP_FOOTER = (
|
||||
@@ -557,6 +590,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"rounds={last_rounds}\n"
|
||||
f"usage_session={session_u.format_line(billed=True)}\n"
|
||||
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
|
||||
f"skills={len(state.skills.skills) if state.skills is not None else 'off'} "
|
||||
f"workspace={state.workspace}"
|
||||
)
|
||||
|
||||
@@ -1217,6 +1251,54 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||
_print_mcp_status(state)
|
||||
|
||||
|
||||
@slash.command("skills")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
|
||||
@click.argument("target", required=False)
|
||||
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
|
||||
@click.pass_obj
|
||||
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
|
||||
"""List skills, search or read them, or rescan the roots.
|
||||
|
||||
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
||||
``/skills search PATTERN`` — regex over every skill's contents.
|
||||
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
|
||||
``/skills reload`` — rescan the roots after an external edit.
|
||||
"""
|
||||
from plyngent.tools.context import bind_tool_context
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
|
||||
|
||||
if state.skills is None:
|
||||
click.echo("skills: disabled (set [skills].enabled = true)")
|
||||
return
|
||||
act = (action or "list").lower()
|
||||
if act == "reload":
|
||||
state.reload_skills()
|
||||
_slash_rebuild_tools_if_on(state)
|
||||
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
|
||||
return
|
||||
# The tool handlers read the bound instance (the registry binds it for a
|
||||
# model call; a slash command has to do it itself).
|
||||
with bind_tool_context(instance=state.instance_state, session=state.session_state):
|
||||
if act == "search":
|
||||
if not target:
|
||||
msg = "/skills search requires a pattern"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_search.handler(target)))
|
||||
return
|
||||
if act == "read":
|
||||
if not target:
|
||||
msg = "/skills read requires a skill name"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
|
||||
return
|
||||
click.echo(format_skill_table(state.skills))
|
||||
if not state.tools_enabled:
|
||||
click.secho(
|
||||
f"note: tools are off, so {', '.join(sorted(SKILL_TOOL_NAMES))} are not offered to the model",
|
||||
fg="bright_black",
|
||||
)
|
||||
|
||||
|
||||
@slash.command("grants")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
||||
@click.argument("target", required=False)
|
||||
@@ -1367,65 +1449,62 @@ def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||
|
||||
|
||||
@slash.command("history")
|
||||
@click.argument("n", required=False, type=HistoryLimitType())
|
||||
@click.argument("target", required=False, type=HISTORY_TARGET)
|
||||
@click.argument("count", required=False, type=click.IntRange(min=1), metavar="[N]")
|
||||
@click.option(
|
||||
"--full",
|
||||
"mode_full",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print full message bodies (markdown for assistant when TTY).",
|
||||
"--message",
|
||||
"message_no",
|
||||
type=HISTORY_ROW_NUMBER,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Print message N (display number) instead of a turn.",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
"verbose",
|
||||
count=True,
|
||||
help="Print every row of the turn(s); repeat (-vv) for full bodies.",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
"mode_preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force short previews even for a single message.",
|
||||
help="One-line preview for every printed row.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def history_cmd(
|
||||
state: ReplState,
|
||||
n: int | None,
|
||||
target: int | Literal["last"] | None,
|
||||
count: int | None,
|
||||
*,
|
||||
mode_full: bool,
|
||||
message_no: int | None,
|
||||
verbose: int,
|
||||
mode_preview: bool,
|
||||
) -> None:
|
||||
"""Show recent messages (default: last 20 as short previews).
|
||||
"""Show conversation history, grouped by turn.
|
||||
|
||||
``/history last`` or ``/history 1`` shows the last message in full with Rich
|
||||
markdown when available. Use ``--full`` for multiple messages in full;
|
||||
``--preview`` forces the short form even for a single message.
|
||||
``/history`` — the last turn: your message and the model's final answer
|
||||
``/history N`` — turn N (numbered from the first turn of this session)
|
||||
``/history last [N]`` — the last N turns (default 1)
|
||||
``/history --message N`` — one message (display number, not a DB row)
|
||||
The turn's ends are printed in full; ``-v`` adds every intermediate row (tool
|
||||
calls/results, notices) as a one-liner, ``-vv`` prints those in full too, and
|
||||
``--preview`` collapses every row (ends included) to one line.
|
||||
"""
|
||||
if mode_full and mode_preview:
|
||||
msg = "use only one of --full / --preview"
|
||||
raise click.UsageError(msg)
|
||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||
messages = state.agent.messages
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
start = max(0, len(messages) - limit)
|
||||
slice_msgs = messages[start:]
|
||||
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
||||
mode_tag = "full" if full else "preview"
|
||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
||||
for offset, message in enumerate(slice_msgs):
|
||||
idx = start + offset
|
||||
if full:
|
||||
_print_history_message_full(idx, message)
|
||||
else:
|
||||
click.echo(_format_history_message(idx, message))
|
||||
if state.agent.pending_retry_text is not None:
|
||||
pending = state.agent.pending_retry_text
|
||||
if full:
|
||||
click.secho("(pending retry) user:", fg="yellow")
|
||||
click.echo(pending)
|
||||
else:
|
||||
click.secho(
|
||||
f"(pending retry) user: {_preview_content(pending)}",
|
||||
fg="yellow",
|
||||
)
|
||||
from plyngent.cli import transcript as transcript_mod
|
||||
|
||||
transcript_mod.echo_history(
|
||||
state.agent.messages,
|
||||
session_id=state.session_id,
|
||||
pending_retry_text=state.agent.pending_retry_text,
|
||||
target=target,
|
||||
count=count,
|
||||
message_no=message_no,
|
||||
verbose=verbose,
|
||||
preview=mode_preview,
|
||||
)
|
||||
|
||||
|
||||
@slash.command("todos")
|
||||
@@ -1519,98 +1598,6 @@ def retry_cmd(state: ReplState) -> None:
|
||||
_ = _await(retry_pending_with_retries(state.agent))
|
||||
|
||||
|
||||
def _preview_content(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) <= _CONTENT_PREVIEW:
|
||||
return text
|
||||
return text[:_CONTENT_PREVIEW] + "…"
|
||||
|
||||
|
||||
def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
click.secho(f"{index}. assistant:", fg="cyan")
|
||||
content = message.content
|
||||
has_text = isinstance(content, str) and content.strip()
|
||||
if has_text and isinstance(content, str):
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
reasoning = message.reasoning_content
|
||||
has_reason = isinstance(reasoning, str) and reasoning.strip()
|
||||
if has_reason and isinstance(reasoning, str):
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(reasoning)
|
||||
tool_calls = message.tool_calls
|
||||
has_tools = tool_calls is not UNSET and bool(tool_calls)
|
||||
if has_tools and tool_calls is not UNSET:
|
||||
for call in tool_calls:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
click.secho(
|
||||
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
click.secho(f" tool_call custom id={call.id}", fg="yellow")
|
||||
if not (has_text or has_reason or has_tools):
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
|
||||
"""Print one history message with full body; Rich markdown for assistant text."""
|
||||
if isinstance(message, UserChatMessage):
|
||||
click.secho(f"{index}. user:", fg="green")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
click.secho(f"{index}. developer:", fg="blue")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, SystemChatMessage):
|
||||
click.secho(f"{index}. system:", fg="bright_black")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
_print_history_assistant_full(index, message)
|
||||
return
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return f"{index}. user: {_preview_content(message.content)}"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return f"{index}. developer: {_preview_content(message.content)}"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return f"{index}. system: {_preview_content(message.content)}"
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
parts: list[str] = []
|
||||
if isinstance(message.content, str) and message.content:
|
||||
parts.append(_preview_content(message.content))
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is not UNSET and tool_calls:
|
||||
names: list[str] = []
|
||||
for call in tool_calls:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
names.append(call.function.name)
|
||||
else:
|
||||
names.append("custom")
|
||||
parts.append(f"tool_calls=[{', '.join(names)}]")
|
||||
body = " ".join(parts) if parts else "(empty)"
|
||||
return f"{index}. assistant: {body}"
|
||||
# ToolChatMessage
|
||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
||||
|
||||
|
||||
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||
"""Sync Click entrypoint; may call awaitlet() for async work."""
|
||||
# standalone_mode=False → UsageError/ClickException instead of SystemExit.
|
||||
|
||||
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
from plyngent.runtime.mcp_client import McpManager
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
@@ -88,6 +89,8 @@ class ReplState:
|
||||
session_state: SessionState = field(default_factory=SessionState)
|
||||
# MCP connections started by the host app before this state was built.
|
||||
mcp_manager: McpManager | None = None
|
||||
# Discovered skills (None when ``[skills].enabled`` is false).
|
||||
skills: SkillStore | None = field(default=None, init=False, repr=False)
|
||||
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
||||
@@ -103,6 +106,7 @@ class ReplState:
|
||||
self.instance_state.workspace_root = self.workspace
|
||||
self.instance_state.workspace.root = self.workspace
|
||||
self._install_config_access()
|
||||
self._install_skills()
|
||||
from plyngent.cli.limits import set_auto_continue_default
|
||||
|
||||
set_auto_continue_default(enabled=self.auto_continue_limits)
|
||||
@@ -302,6 +306,71 @@ class ReplState:
|
||||
err=True,
|
||||
)
|
||||
|
||||
def _install_skills(self) -> None:
|
||||
"""Build the skill store and expose its roots read-only to path tools.
|
||||
|
||||
Roots follow ``[skills]`` (explicit paths first, then our own user
|
||||
directory and other harnesses' directories, with the project-scope ones
|
||||
tied to the current workspace). Writing is *not* part of this: the roots
|
||||
are read-only, and skill writes go through the write policy installed
|
||||
below, so a skill directory never becomes a general write grant.
|
||||
"""
|
||||
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||
from plyngent.config.path import get_default_path
|
||||
from plyngent.skills import SkillStore, build_roots
|
||||
from plyngent.tools.skills import SkillWritePolicy, set_skill_write_policy
|
||||
from plyngent.tools.workspace import set_static_read_roots
|
||||
|
||||
cfg = self.config.skills_config
|
||||
set_skill_write_policy(
|
||||
SkillWritePolicy(allow_all=cfg.allow_write, confirm=prompt_skill_write_confirm),
|
||||
instance=self.instance_state,
|
||||
)
|
||||
if not cfg.enabled:
|
||||
self.skills = None
|
||||
self.instance_state.skills = None
|
||||
_ = set_static_read_roots([], instance=self.instance_state)
|
||||
return
|
||||
roots = build_roots(
|
||||
cfg,
|
||||
workspace=self.workspace,
|
||||
user_config_dir=get_default_path().parent,
|
||||
home=Path.home(),
|
||||
)
|
||||
self.skills = SkillStore(roots)
|
||||
self.instance_state.skills = self.skills
|
||||
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
||||
|
||||
def reload_skills(self) -> None:
|
||||
"""Rescan the roots (after an external edit or a config change)."""
|
||||
if self.skills is not None:
|
||||
self.skills.reload()
|
||||
|
||||
def _skills_text(self) -> str:
|
||||
"""Fold the discovered skills into the system prompt (like MCP instructions).
|
||||
|
||||
Only names, sources, and descriptions: the bodies stay on disk so a turn
|
||||
pays for the skill it actually reads. Notes and shadowed copies are left
|
||||
to ``skill_list``, which the model can call when something looks off.
|
||||
"""
|
||||
cfg = self.config.skills_config
|
||||
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
|
||||
return ""
|
||||
skills = self.skills.skills
|
||||
if not skills:
|
||||
return ""
|
||||
limit = max(0, cfg.max_catalog_skills)
|
||||
shown = skills[:limit]
|
||||
lines = [
|
||||
"### Skills",
|
||||
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
|
||||
]
|
||||
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
|
||||
if len(skills) > len(shown):
|
||||
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
|
||||
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
@@ -309,6 +378,7 @@ class ReplState:
|
||||
from plyngent.tools.catalog import register_builtin_tools
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.plugins import load_plugin_tools
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES
|
||||
|
||||
plugins_cfg = self.config.plugins_config
|
||||
catalog = register_builtin_tools()
|
||||
@@ -323,7 +393,9 @@ class ReplState:
|
||||
# Connections were started by the host; registration is sync-only.
|
||||
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
||||
tools = catalog.select(surface="local")
|
||||
# ``[skills].enabled = false`` drops the skill tools from the surface.
|
||||
excluded = None if self.config.skills_config.enabled else set(SKILL_TOOL_NAMES)
|
||||
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||
yolo = self.effective_yolo() != "off"
|
||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||
return ToolRegistry(
|
||||
@@ -357,6 +429,7 @@ class ReplState:
|
||||
system_prompt = compose_agent_system_content(
|
||||
agent_cfg.system_prompt,
|
||||
agent_cfg.tool_directives,
|
||||
self._skills_text(),
|
||||
mcp_text,
|
||||
)
|
||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||
@@ -539,6 +612,8 @@ class ReplState:
|
||||
|
||||
self.config.reload()
|
||||
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
||||
# Roots and their switches came from the file that was just re-read.
|
||||
self._install_skills()
|
||||
|
||||
selectable = self.config.selectable_providers()
|
||||
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
||||
@@ -576,6 +651,8 @@ class ReplState:
|
||||
self.workspace = resolved
|
||||
self.instance_state.workspace_root = resolved
|
||||
self.instance_state.workspace.root = resolved
|
||||
# Project-scope skill roots follow the workspace.
|
||||
self._install_skills()
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Turn-oriented transcript grouping/rendering for ``/history``.
|
||||
|
||||
The REPL transcript is a flat list of chat messages, but what a human reviews is
|
||||
a *turn*: their message, the tool-loop rounds it triggered, and the model's final
|
||||
answer. This module groups the list into numbered rows and turns, and renders
|
||||
either the two ends of a turn (default), every row of it (``-v``), or full bodies
|
||||
for every printed row (``-vv``).
|
||||
|
||||
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
||||
order the session holds them): it is what ``/history`` prints and what
|
||||
``/history --message N`` addresses. Database row numbers are deliberately never
|
||||
shown or used — they are an implementation detail that shifts with ``/clear``.
|
||||
Local-only rows (the injected system prompt) have no number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
PREVIEW_CHARS = 200
|
||||
# ``-v`` expands a turn's rows; ``-vv`` also prints their full bodies.
|
||||
MAX_VERBOSE = 2
|
||||
|
||||
# Detail policy: ``mixed`` = a turn's two ends (the user message and the final
|
||||
# answer) in full, the rounds between them on one line; ``full``/``preview``
|
||||
# force the extremes (``--preview`` also collapses the ends).
|
||||
type Detail = Literal["mixed", "full", "preview"]
|
||||
# ``/history N`` addresses a turn by number; ``last`` counts from the end.
|
||||
type TurnTarget = int | Literal["last"]
|
||||
|
||||
|
||||
def _content(message: AnyChatMessage) -> str:
|
||||
"""Message body as text (``content`` may be UNSET/None for assistants)."""
|
||||
return message.content if isinstance(message.content, str) else ""
|
||||
|
||||
|
||||
def one_line(text: str | None, *, limit: int = PREVIEW_CHARS) -> str:
|
||||
"""Collapse *text* to one line: first line, truncated, may add ``(+N lines)``."""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
lines = body.splitlines()
|
||||
head = lines[0].strip()
|
||||
if len(head) > limit:
|
||||
head = head[:limit] + "…"
|
||||
if len(lines) > 1:
|
||||
head += f" (+{len(lines) - 1} lines)"
|
||||
return head
|
||||
|
||||
|
||||
def _role(message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return "user"
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
return "assistant"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return "developer"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return "system"
|
||||
assert isinstance(message, ToolChatMessage)
|
||||
return f"tool({message.tool_call_id})"
|
||||
|
||||
|
||||
_ROLE_COLOR: dict[str, str] = {
|
||||
"user": "green",
|
||||
"assistant": "cyan",
|
||||
"developer": "blue",
|
||||
"system": "bright_black",
|
||||
}
|
||||
|
||||
|
||||
def _role_color(message: AnyChatMessage) -> str:
|
||||
return "magenta" if isinstance(message, ToolChatMessage) else _ROLE_COLOR[_role(message)]
|
||||
|
||||
|
||||
def _tool_call_names(message: AssistantChatMessage) -> list[str]:
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
return []
|
||||
return [call.function.name if isinstance(call, AssistantFunctionToolCall) else "custom" for call in tool_calls]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Row:
|
||||
"""One transcript row; ``number`` is the display ordinal (None = local-only)."""
|
||||
|
||||
message: AnyChatMessage
|
||||
number: int | None
|
||||
|
||||
def prefix(self) -> str:
|
||||
"""``12. user:`` — always styled (local rows keep the role colour)."""
|
||||
label = "local" if self.number is None else str(self.number)
|
||||
return str(click.style(f"{label}. {_role(self.message)}:", fg=_role_color(self.message)))
|
||||
|
||||
def preview(self) -> str:
|
||||
message = self.message
|
||||
names = _tool_call_names(message) if isinstance(message, AssistantChatMessage) else []
|
||||
suffix = f" tool_calls=[{', '.join(names)}]" if names else ""
|
||||
text = one_line(_content(message))
|
||||
if not text and isinstance(message, AssistantChatMessage) and not names:
|
||||
text = "(empty)"
|
||||
return f"{text}{suffix}".strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Turn:
|
||||
"""A user turn: the user row plus the rounds it triggered."""
|
||||
|
||||
ordinal: int
|
||||
rows: tuple[Row, ...]
|
||||
incomplete: bool = False
|
||||
|
||||
@property
|
||||
def user(self) -> Row:
|
||||
return self.rows[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Row:
|
||||
return self.rows[-1]
|
||||
|
||||
@property
|
||||
def rounds(self) -> int:
|
||||
"""Model rounds that asked for tools (one tool batch each)."""
|
||||
return sum(
|
||||
1
|
||||
for row in self.rows
|
||||
if isinstance(row.message, AssistantChatMessage) and bool(_tool_call_names(row.message))
|
||||
)
|
||||
|
||||
def label(self) -> str:
|
||||
numbers = [row.number for row in self.rows if row.number is not None]
|
||||
parts = [f"msgs {numbers[0]}-{numbers[-1]}" if numbers else "local rows"]
|
||||
if self.rounds:
|
||||
parts.append(f"rounds={self.rounds}")
|
||||
text = f"turn {self.ordinal} ({', '.join(parts)})"
|
||||
if self.incomplete:
|
||||
text += " [incomplete — /retry continues]"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Transcript:
|
||||
"""Numbered rows grouped by turn (plus rows that precede the first turn)."""
|
||||
|
||||
prelude: tuple[Row, ...]
|
||||
turns: tuple[Turn, ...]
|
||||
|
||||
@property
|
||||
def messages(self) -> int:
|
||||
"""Numbered rows (what ``/history --message N`` can address)."""
|
||||
return sum(1 for turn in self.turns for row in turn.rows if row.number is not None) + sum(
|
||||
1 for row in self.prelude if row.number is not None
|
||||
)
|
||||
|
||||
def turn(self, ordinal: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if turn.ordinal == ordinal), None)
|
||||
|
||||
def row(self, number: int) -> Row | None:
|
||||
for row in self.prelude:
|
||||
if row.number == number:
|
||||
return row
|
||||
for turn in self.turns:
|
||||
for row in turn.rows:
|
||||
if row.number == number:
|
||||
return row
|
||||
return None
|
||||
|
||||
def turn_of(self, number: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if any(row.number == number for row in turn.rows)), None)
|
||||
|
||||
|
||||
def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text: str | None = None) -> Transcript:
|
||||
"""Group *messages* into numbered rows and turns.
|
||||
|
||||
A turn starts at a user message and runs until the next one, so tool results,
|
||||
developer notices/checkpoints and synthetic todo nags stay inside the turn
|
||||
they belong to. Rows before the first user message (the injected system
|
||||
prompt, a compact seed summary, a resume notice) form the prelude.
|
||||
|
||||
``pending_retry_text`` marks the last turn as incomplete when it matches that
|
||||
turn's user message (``ChatAgent.pending_retry_text``).
|
||||
"""
|
||||
rows: list[Row] = []
|
||||
number = 0
|
||||
for message in messages:
|
||||
if isinstance(message, SystemChatMessage):
|
||||
# The system prompt is injected locally and never persisted.
|
||||
rows.append(Row(message=message, number=None))
|
||||
continue
|
||||
number += 1
|
||||
rows.append(Row(message=message, number=number))
|
||||
|
||||
prelude: list[Row] = []
|
||||
turns: list[Turn] = []
|
||||
current: list[Row] = []
|
||||
for row in rows:
|
||||
if isinstance(row.message, UserChatMessage):
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
current = [row]
|
||||
elif current:
|
||||
current.append(row)
|
||||
else:
|
||||
prelude.append(row)
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
|
||||
if turns and pending_retry_text is not None:
|
||||
user = turns[-1].user.message
|
||||
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
||||
turns[-1] = replace(turns[-1], incomplete=True)
|
||||
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||
|
||||
|
||||
def echo_row(row: Row, *, detail: Detail) -> None:
|
||||
"""Print one row: a one-line preview, or the full body for ``full``/``mixed``."""
|
||||
if detail == "preview":
|
||||
click.echo(f"{row.prefix()} {row.preview()}")
|
||||
return
|
||||
if isinstance(row.message, AssistantChatMessage):
|
||||
_echo_assistant(row, reasoning=detail == "full")
|
||||
return
|
||||
click.echo(row.prefix())
|
||||
click.echo(row.message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _echo_reasoning(message: AssistantChatMessage) -> None:
|
||||
"""Print the model's reasoning trace for *message*, when it has one."""
|
||||
shown = message.reasoning_content
|
||||
if not isinstance(shown, str) or not shown.strip():
|
||||
return
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(shown)
|
||||
click.echo()
|
||||
|
||||
|
||||
def _echo_assistant(row: Row, *, reasoning: bool) -> None:
|
||||
"""Print an assistant row: the thinking first, then the answer it produced.
|
||||
|
||||
A transcript reads in the order the model produced it, and the live stream
|
||||
prints the reasoning before the answer; printing it after the body made the
|
||||
answer look like it had come before the thinking behind it.
|
||||
"""
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
message = row.message
|
||||
assert isinstance(message, AssistantChatMessage)
|
||||
click.echo(row.prefix())
|
||||
if reasoning:
|
||||
_echo_reasoning(message)
|
||||
content = message.content
|
||||
if isinstance(content, str) and content.strip():
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
names = _tool_call_names(message)
|
||||
if names:
|
||||
click.secho(f" tool_calls=[{', '.join(names)}]", fg="yellow")
|
||||
if not names and not _content(message).strip():
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def echo_turn(turn: Turn, *, verbose: bool, detail: Detail) -> None:
|
||||
"""Print one turn: its header, then every row (verbose) or just its ends."""
|
||||
click.secho(turn.label(), fg="yellow" if turn.incomplete else "bright_black")
|
||||
if verbose:
|
||||
rows = turn.rows
|
||||
elif turn.last is turn.user:
|
||||
rows = (turn.user,)
|
||||
else:
|
||||
rows = (turn.user, turn.last)
|
||||
for row in rows:
|
||||
echo_row(row, detail=_row_detail(row, turn=turn, detail=detail))
|
||||
|
||||
|
||||
def _row_detail(row: Row, *, turn: Turn, detail: Detail) -> Detail:
|
||||
"""Effective detail for *row*.
|
||||
|
||||
Under ``mixed`` the two ends of a turn — the human's message and the model's
|
||||
answer — are the rows a reader actually wants, so they are never collapsed;
|
||||
the rounds between them stay one-liners.
|
||||
"""
|
||||
if detail != "mixed":
|
||||
return detail
|
||||
if row is turn.user:
|
||||
return "full"
|
||||
return "full" if row is turn.last and isinstance(row.message, AssistantChatMessage) else "preview"
|
||||
|
||||
|
||||
def echo_prelude_rows(rows: Sequence[Row], *, detail: Detail) -> None:
|
||||
"""Print rows that precede the first turn (memory seed, restart notice, …).
|
||||
|
||||
Local-only rows (the injected system prompt) are control-plane noise, so they
|
||||
only appear in a full dump (``-vv``) — numbered head rows always do.
|
||||
"""
|
||||
for row in rows:
|
||||
if row.number is None and detail != "full":
|
||||
continue
|
||||
echo_row(row, detail="preview" if detail == "mixed" else detail)
|
||||
|
||||
|
||||
def _resolve_detail(*, verbose: int, preview: bool) -> Detail:
|
||||
"""Map ``-v`` / ``-vv`` / ``--preview`` onto the detail policy."""
|
||||
if verbose > MAX_VERBOSE:
|
||||
msg = "use at most -vv"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE and preview:
|
||||
msg = "use only one of -vv / --preview"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE:
|
||||
return "full"
|
||||
return "preview" if preview else "mixed"
|
||||
|
||||
|
||||
def _echo_message(view: Transcript, head: str, number: int, *, detail: Detail) -> None:
|
||||
"""Print one addressed message in full (``--message``)."""
|
||||
row = view.row(number)
|
||||
if row is None:
|
||||
click.echo(f"error: no message {number} (messages 1-{view.messages})")
|
||||
return
|
||||
body: Detail = "preview" if detail == "preview" else "full"
|
||||
click.echo(f"{head} showing=message {number} mode={body}")
|
||||
echo_row(row, detail=body)
|
||||
owner = view.turn_of(number)
|
||||
if owner is not None:
|
||||
click.secho(f"(turn {owner.ordinal}; /history {owner.ordinal} -v to see it whole)", fg="bright_black")
|
||||
|
||||
|
||||
def _select_turns(
|
||||
view: Transcript,
|
||||
*,
|
||||
target: TurnTarget | None,
|
||||
count: int | None,
|
||||
) -> tuple[tuple[Turn, ...], str] | None:
|
||||
"""Resolve ``/history [N]`` / ``/history last [N]`` into turns to print."""
|
||||
if target is None or target == "last":
|
||||
want = count if count is not None else 1
|
||||
selected = view.turns[-want:]
|
||||
return selected, f"last {len(selected)} turn(s)"
|
||||
turn = view.turn(target)
|
||||
if turn is None:
|
||||
click.echo(f"error: no turn {target} (turns 1-{len(view.turns)})")
|
||||
return None
|
||||
return (turn,), f"turn {target}"
|
||||
|
||||
|
||||
def echo_history(
|
||||
messages: Sequence[AnyChatMessage],
|
||||
*,
|
||||
session_id: int | None,
|
||||
pending_retry_text: str | None = None,
|
||||
target: TurnTarget | None = None,
|
||||
count: int | None = None,
|
||||
message_no: int | None = None,
|
||||
verbose: int = 0,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Render ``/history``: a turn window, or one addressed message.
|
||||
|
||||
``target`` is a turn number or ``"last"``; ``count`` (only with ``"last"``)
|
||||
is how many turns; ``message_no`` prints a single row instead. Numbering is
|
||||
the display ordinal of this transcript, never a database row number.
|
||||
"""
|
||||
if message_no is not None and (target is not None or count is not None):
|
||||
msg = "use --message N alone"
|
||||
raise click.UsageError(msg)
|
||||
if message_no is not None and verbose:
|
||||
msg = "-v/-vv apply to turns: /history N"
|
||||
raise click.UsageError(msg)
|
||||
if count is not None and target != "last":
|
||||
msg = "a count applies to 'last': /history last [N]"
|
||||
raise click.UsageError(msg)
|
||||
detail = _resolve_detail(verbose=verbose, preview=preview)
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
view = build_transcript(messages, pending_retry_text=pending_retry_text)
|
||||
head = f"session={session_id} turns={len(view.turns)} messages={view.messages}"
|
||||
|
||||
if message_no is not None:
|
||||
_echo_message(view, head, message_no, detail=detail)
|
||||
return
|
||||
selection = _select_turns(view, target=target, count=count)
|
||||
if selection is None:
|
||||
return
|
||||
selected, showing = selection
|
||||
click.echo(f"{head} showing={showing} mode={detail}")
|
||||
if verbose >= 1 and view.prelude:
|
||||
echo_prelude_rows(view.prelude, detail=detail)
|
||||
if not selected:
|
||||
click.echo("(no turns yet in this session)")
|
||||
for turn in selected:
|
||||
echo_turn(turn, verbose=verbose >= 1, detail=detail)
|
||||
@@ -21,6 +21,8 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
||||
from .models import OpenAIProvider as OpenAIProvider
|
||||
from .models import PluginsConfig as PluginsConfig
|
||||
from .models import ProviderConfig as ProviderConfig
|
||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||
from .models import SkillsConfig as SkillsConfig
|
||||
from .models import compose_agent_system_content as compose_agent_system_content
|
||||
from .path import get_default_path as _get_default_path
|
||||
from .routing import EffectiveProvider as EffectiveProvider
|
||||
|
||||
@@ -13,6 +13,10 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||
type AllowPathMode = Literal["read", "write", "exec"]
|
||||
|
||||
# Built-in skill discovery sources (``[skills].discover``): our own user config
|
||||
# directory, and the skill directories other harnesses install into.
|
||||
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
||||
|
||||
# Built-in persona when ``[agent].system_prompt`` is omitted.
|
||||
# Set ``system_prompt = ""`` to omit the persona block only.
|
||||
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
|
||||
@@ -24,7 +28,7 @@ You are a professional coding agent in a workspace-bound tool environment.
|
||||
# Set ``tool_directives = ""`` to omit this block only.
|
||||
DEFAULT_TOOL_DIRECTIVES = """\
|
||||
### Workspace
|
||||
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure.
|
||||
- Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure.
|
||||
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
|
||||
- Need a path outside the workspace? Call `request_directory_access` \
|
||||
(human approves; denylists apply) instead of retrying an escaped path.
|
||||
@@ -39,11 +43,20 @@ re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete,
|
||||
since line numbers go stale.
|
||||
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
|
||||
is 0-based); `with_lineno` shows per-line numbers instead.
|
||||
- `regex_files` takes a Python `re` regex for `pattern`, not a filename glob \
|
||||
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
|
||||
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
|
||||
no file-type filter, so narrow with `path` and filter hits yourself.
|
||||
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
|
||||
with it to keep reading (chains through truncations).
|
||||
|
||||
### Commands
|
||||
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
|
||||
- A shell or interpreter (`bash` / `python` / `node` / …) always requires the user to confirm — \
|
||||
a `-c` one-liner, a script (`python x.py`), or a bare interactive shell alike — and each \
|
||||
confirm blocks the turn, so it costs time. Invoke the target command directly (argv) instead \
|
||||
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
|
||||
(`read_file` / `edit_*` / `vcs_*` / `fetch` / …) when one already does the job.
|
||||
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
|
||||
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||
@@ -206,6 +219,34 @@ class NetworkingConfig(Struct, omit_defaults=True):
|
||||
ssrf_assume_public_cidrs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillsConfig(Struct, omit_defaults=True):
|
||||
"""Skill directories the agent may discover, read, and (with a grant) edit.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md`` (YAML frontmatter with at least
|
||||
``name`` / ``description``, then a Markdown body) plus optional bundled files
|
||||
(scripts, references, templates). Roots are scanned in this order:
|
||||
|
||||
1. ``paths`` — explicit roots, highest priority
|
||||
2. ``discover`` sources in order: ``plyngent`` = our user config dir
|
||||
(``<user config>/skills``), ``claude`` = Claude Code's skill dirs
|
||||
(``~/.claude/skills``; plus ``<workspace>/.claude/skills`` when
|
||||
``include_project_roots``)
|
||||
|
||||
A skill found in several roots shadows the lower-priority copies: they stay
|
||||
listed, never hidden. ``enabled = false`` registers no skill tools at all and
|
||||
injects no catalog. Writing needs an explicit grant (a confirm per call, or
|
||||
``allow_write`` for a standing one); reads need none.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
paths: list[str] = field(default_factory=list)
|
||||
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
|
||||
include_project_roots: bool = True
|
||||
inject_catalog: bool = True
|
||||
max_catalog_skills: int = 50
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
class ModelConfig(Struct, omit_defaults=True):
|
||||
"""Capability flags and optional routing overrides for a model.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from .models import (
|
||||
NetworkingConfig,
|
||||
PluginsConfig,
|
||||
Provider,
|
||||
SkillsConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -57,6 +58,14 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
|
||||
return NetworkingConfig()
|
||||
|
||||
|
||||
def _parse_skills(raw: dict[str, object]) -> SkillsConfig:
|
||||
"""Parse the ``[skills]`` section, falling back to defaults."""
|
||||
try:
|
||||
return msgspec.convert(raw, SkillsConfig)
|
||||
except msgspec.ValidationError:
|
||||
return SkillsConfig()
|
||||
|
||||
|
||||
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||
|
||||
@@ -150,6 +159,7 @@ class ConfigStore:
|
||||
_plugins: PluginsConfig
|
||||
_networking: NetworkingConfig
|
||||
_mcp: McpConfig
|
||||
_skills: SkillsConfig
|
||||
_providers: dict[str, Provider]
|
||||
_bad_providers: dict[str, object]
|
||||
_recoverable_providers: dict[str, Provider]
|
||||
@@ -163,6 +173,7 @@ class ConfigStore:
|
||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||
|
||||
@property
|
||||
@@ -220,6 +231,13 @@ class ConfigStore:
|
||||
"""Typed MCP section (server definitions + disable list)."""
|
||||
return self._mcp
|
||||
|
||||
# -- skills (read-only) --
|
||||
|
||||
@property
|
||||
def skills_config(self) -> SkillsConfig:
|
||||
"""Typed skills section (roots, discovery sources, catalog limits)."""
|
||||
return self._skills
|
||||
|
||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||
cleaned = [name.strip() for name in names if name.strip()]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Skill discovery: roots, ``SKILL.md`` metadata, and the read/write helpers."""
|
||||
|
||||
from .discovery import SKILL_FILE as SKILL_FILE
|
||||
from .discovery import Skill as Skill
|
||||
from .discovery import SkillRoot as SkillRoot
|
||||
from .discovery import SkillStore as SkillStore
|
||||
from .discovery import build_roots as build_roots
|
||||
from .discovery import resolve_inside as resolve_inside
|
||||
from .discovery import resolve_skill_file as resolve_skill_file
|
||||
from .discovery import validate_skill_name as validate_skill_name
|
||||
from .frontmatter import FrontmatterError as FrontmatterError
|
||||
from .frontmatter import parse_frontmatter as parse_frontmatter
|
||||
|
||||
__all__ = [
|
||||
"SKILL_FILE",
|
||||
"FrontmatterError",
|
||||
"Skill",
|
||||
"SkillRoot",
|
||||
"SkillStore",
|
||||
"build_roots",
|
||||
"parse_frontmatter",
|
||||
"resolve_inside",
|
||||
"resolve_skill_file",
|
||||
"validate_skill_name",
|
||||
]
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Discover skills under a fixed set of roots.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
|
||||
(explicit ``[skills].paths`` first, then the built-in sources), so the first
|
||||
skill with a given name wins and the copies it shadows stay visible for
|
||||
reporting rather than disappearing. Parsed metadata is cached against the
|
||||
directory list and its ``SKILL.md`` timestamps, which makes a listing cheap
|
||||
while still noticing an edit made outside the chat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .frontmatter import FrontmatterError, parse_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
|
||||
SKILL_FILE = "SKILL.md"
|
||||
DEFAULT_SKILL_DIR = "skills"
|
||||
# Only the head is needed: frontmatter lives at the top, and ``skill_read``
|
||||
# reads the file itself when the model actually wants the body.
|
||||
_METADATA_READ_BYTES = 64 * 1024
|
||||
_FALLBACK_DESCRIPTION_CHARS = 200
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
_FRONTMATTER_NAME = "name"
|
||||
_FRONTMATTER_DESCRIPTION = "description"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillRoot:
|
||||
"""One directory scanned for skills.
|
||||
|
||||
``origin`` names where the root came from (``config`` for an explicit path,
|
||||
``plyngent`` for our own user directory, ``claude`` for another harness);
|
||||
``scope`` is ``user`` or ``project``. Together they label a skill's
|
||||
provenance in listings and in the injected catalog.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
origin: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""``<origin>-<scope>``, e.g. ``claude-project``."""
|
||||
return f"{self.origin}-{self.scope}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Skill:
|
||||
"""A discovered skill directory and the metadata read from its ``SKILL.md``."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
path: Path
|
||||
root: SkillRoot
|
||||
metadata: Mapping[str, object] = field(default_factory=dict[str, object])
|
||||
# Non-fatal notes (invalid declared name, missing description, …) surfaced to
|
||||
# the human in listings so a third-party skill is never repaired silently.
|
||||
issues: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def skill_file(self) -> Path:
|
||||
return self.path / SKILL_FILE
|
||||
|
||||
@property
|
||||
def declared_name(self) -> str | None:
|
||||
"""The name the frontmatter asked for (may differ from :attr:`name`)."""
|
||||
declared = self.metadata.get(_FRONTMATTER_NAME)
|
||||
return declared.strip() if isinstance(declared, str) and declared.strip() else None
|
||||
|
||||
|
||||
def build_roots(
|
||||
config: SkillsConfig,
|
||||
*,
|
||||
workspace: Path,
|
||||
user_config_dir: Path,
|
||||
home: Path | None = None,
|
||||
) -> list[SkillRoot]:
|
||||
"""Resolve ``[skills]`` into ordered roots (highest priority first).
|
||||
|
||||
Explicit ``paths`` win, then the built-in discovery sources in the order the
|
||||
config lists them. A source contributes only directories that exist *or* are
|
||||
the default write target — a missing root is not an error, it simply holds
|
||||
no skills (and may hold the next created one).
|
||||
"""
|
||||
roots: list[SkillRoot] = []
|
||||
for raw in config.paths:
|
||||
token = raw.strip()
|
||||
if token:
|
||||
roots.append(SkillRoot(path=_expand(token, home=home), origin="config", scope="user"))
|
||||
for source in config.discover:
|
||||
if source == "plyngent":
|
||||
roots.append(SkillRoot(path=user_config_dir / DEFAULT_SKILL_DIR, origin="plyngent", scope="user"))
|
||||
elif source == "claude":
|
||||
if home is not None:
|
||||
roots.append(SkillRoot(path=home / ".claude" / DEFAULT_SKILL_DIR, origin="claude", scope="user"))
|
||||
if config.include_project_roots:
|
||||
project = workspace / ".claude" / DEFAULT_SKILL_DIR
|
||||
roots.append(SkillRoot(path=project, origin="claude", scope="project"))
|
||||
return roots
|
||||
|
||||
|
||||
def _expand(token: str, *, home: Path | None) -> Path:
|
||||
"""Expand a leading ``~`` (against *home* when given) and normalize."""
|
||||
if token.startswith("~"):
|
||||
rest = token[1:].lstrip("/\\")
|
||||
base = home if home is not None else Path.home()
|
||||
return (base / rest) if rest else base
|
||||
return Path(token)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Entry:
|
||||
"""One candidate skill directory plus the timestamp that invalidates the cache."""
|
||||
|
||||
root: SkillRoot
|
||||
directory: Path
|
||||
mtime_ns: int
|
||||
|
||||
|
||||
def validate_skill_name(name: str) -> str | None:
|
||||
"""Return an error message when *name* is not usable as a skill name."""
|
||||
token = name.strip()
|
||||
if not token:
|
||||
return "skill name must not be empty"
|
||||
if not _NAME_RE.match(token):
|
||||
return f"skill name {token!r} must be lowercase letters, digits, and single hyphens (max 64 chars)"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_inside(base: Path, relative: str) -> Path | None:
|
||||
"""Resolve *relative* under *base*; ``None`` when it is absolute or escapes.
|
||||
|
||||
Symlinks are followed by the resolve, so a link pointing outside *base* is
|
||||
rejected instead of becoming a way around the boundary.
|
||||
"""
|
||||
token = relative.strip()
|
||||
if not token:
|
||||
return None
|
||||
candidate = Path(token)
|
||||
if candidate.is_absolute():
|
||||
return None
|
||||
try:
|
||||
root = base.resolve()
|
||||
target = (root / candidate).resolve()
|
||||
_ = target.relative_to(root)
|
||||
except OSError, ValueError:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def resolve_skill_file(skill: Skill, relative: str) -> Path | None:
|
||||
"""Resolve *relative* inside *skill*; ``None`` when it escapes the directory.
|
||||
|
||||
The default (empty *relative*) is the skill's own ``SKILL.md``.
|
||||
"""
|
||||
return resolve_inside(skill.path, relative.strip() or SKILL_FILE)
|
||||
|
||||
|
||||
class SkillStore:
|
||||
"""Skills discovered under *roots*, highest-priority root first."""
|
||||
|
||||
_roots: tuple[SkillRoot, ...]
|
||||
_cache: tuple[tuple[tuple[str, int], ...], list[Skill]] | None
|
||||
|
||||
def __init__(self, roots: Sequence[SkillRoot]) -> None:
|
||||
self._roots = tuple(roots)
|
||||
self._cache = None
|
||||
|
||||
@property
|
||||
def roots(self) -> tuple[SkillRoot, ...]:
|
||||
return self._roots
|
||||
|
||||
@property
|
||||
def all_skills(self) -> list[Skill]:
|
||||
"""Every discovered skill, shadowed copies included (priority order)."""
|
||||
return list(self._load())
|
||||
|
||||
@property
|
||||
def skills(self) -> list[Skill]:
|
||||
"""Visible skills by name, sorted for display (shadows removed)."""
|
||||
by_name: dict[str, Skill] = {}
|
||||
for skill in self._load():
|
||||
_ = by_name.setdefault(skill.name, skill)
|
||||
return [by_name[name] for name in sorted(by_name)]
|
||||
|
||||
def shadowed(self) -> dict[str, list[Skill]]:
|
||||
"""Skill names found in more than one root → the losing copies in order."""
|
||||
found: dict[str, list[Skill]] = {}
|
||||
for skill in self._load():
|
||||
found.setdefault(skill.name, []).append(skill)
|
||||
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
|
||||
|
||||
def find(self, name: str) -> Skill | None:
|
||||
"""Look up a visible skill by name (exact, then case-insensitive)."""
|
||||
token = name.strip()
|
||||
wanted = token.casefold()
|
||||
fallback: Skill | None = None
|
||||
for skill in self._load():
|
||||
if skill.name == token:
|
||||
return skill
|
||||
if fallback is None and skill.name.casefold() == wanted:
|
||||
fallback = skill
|
||||
return fallback
|
||||
|
||||
def default_write_root(self) -> SkillRoot | None:
|
||||
"""Root a newly created skill goes into: ours when present, else the first."""
|
||||
for root in self._roots:
|
||||
if root.origin == "plyngent":
|
||||
return root
|
||||
return self._roots[0] if self._roots else None
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Drop the cache so the next access rescans the filesystem."""
|
||||
self._cache = None
|
||||
|
||||
def _load(self) -> list[Skill]:
|
||||
entries = self._scan()
|
||||
signature = tuple((str(entry.directory), entry.mtime_ns) for entry in entries)
|
||||
if self._cache is not None and self._cache[0] == signature:
|
||||
return self._cache[1]
|
||||
skills = [skill for entry in entries if (skill := _read_skill(entry)) is not None]
|
||||
self._cache = (signature, skills)
|
||||
return skills
|
||||
|
||||
def _scan(self) -> list[_Entry]:
|
||||
return [
|
||||
_Entry(root=root, directory=directory, mtime_ns=_skill_mtime(directory))
|
||||
for root in self._roots
|
||||
for directory in _skill_dirs(root.path)
|
||||
]
|
||||
|
||||
|
||||
def _skill_dirs(root: Path) -> list[Path]:
|
||||
"""Skill directories directly under *root* (the root itself may be one)."""
|
||||
try:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
found = [root] if (root / SKILL_FILE).is_file() else []
|
||||
children = sorted(child for child in root.iterdir() if child.is_dir())
|
||||
except OSError:
|
||||
return []
|
||||
found.extend(child for child in children if (child / SKILL_FILE).is_file())
|
||||
return found
|
||||
|
||||
|
||||
def _skill_mtime(directory: Path) -> int:
|
||||
try:
|
||||
return (directory / SKILL_FILE).stat().st_mtime_ns
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
|
||||
def _read_skill(entry: _Entry) -> Skill | None:
|
||||
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
|
||||
issues: list[str] = []
|
||||
try:
|
||||
text = _read_head(entry.directory / SKILL_FILE)
|
||||
except OSError as exc:
|
||||
return Skill(
|
||||
name=entry.directory.name,
|
||||
description="",
|
||||
path=entry.directory,
|
||||
root=entry.root,
|
||||
issues=(f"cannot read {SKILL_FILE}: {exc}",),
|
||||
)
|
||||
try:
|
||||
metadata, body = parse_frontmatter(text)
|
||||
except FrontmatterError as exc:
|
||||
metadata, body = {}, text
|
||||
issues.append(f"frontmatter not parsed ({exc}); using the directory name")
|
||||
|
||||
name = entry.directory.name
|
||||
declared = metadata.get(_FRONTMATTER_NAME)
|
||||
if isinstance(declared, str) and declared.strip():
|
||||
candidate = declared.strip()
|
||||
if validate_skill_name(candidate) is None:
|
||||
name = candidate
|
||||
else:
|
||||
issues.append(f"declared name {candidate!r} is not usable; using {entry.directory.name!r}")
|
||||
elif declared is not None:
|
||||
issues.append(f"declared name is not a string ({type(declared).__name__}); using {entry.directory.name!r}")
|
||||
|
||||
description = metadata.get(_FRONTMATTER_DESCRIPTION)
|
||||
if isinstance(description, str) and description.strip():
|
||||
text_description = description.strip()
|
||||
else:
|
||||
text_description = _fallback_description(body)
|
||||
issues.append("no frontmatter description; using the first paragraph")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=_one_line(text_description),
|
||||
path=entry.directory,
|
||||
root=entry.root,
|
||||
metadata=metadata,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def _read_head(path: Path) -> str:
|
||||
"""Read the head of a file as UTF-8 (frontmatter is at the top)."""
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read(_METADATA_READ_BYTES)
|
||||
|
||||
|
||||
def _fallback_description(body: str) -> str:
|
||||
"""First plain paragraph of the body, for skills without a description."""
|
||||
for block in body.split("\n\n"):
|
||||
lines = [line.strip() for line in block.splitlines() if line.strip()]
|
||||
if not lines or lines[0].startswith("#"):
|
||||
continue
|
||||
return " ".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
def _one_line(text: str) -> str:
|
||||
"""Collapse whitespace and cap the length of a description line."""
|
||||
collapsed = " ".join(text.split())
|
||||
if len(collapsed) <= _FALLBACK_DESCRIPTION_CHARS:
|
||||
return collapsed
|
||||
return collapsed[:_FALLBACK_DESCRIPTION_CHARS].rstrip() + "…"
|
||||
@@ -0,0 +1,196 @@
|
||||
"""YAML-subset frontmatter for ``SKILL.md`` files.
|
||||
|
||||
Skills are shared with other harnesses, which write a small YAML block above the
|
||||
Markdown body. Supporting all of YAML would mean a dependency for a handful of
|
||||
scalar fields, so this module reads the subset those files actually use:
|
||||
top-level ``key: value`` pairs, quoted or plain scalars, booleans and integers,
|
||||
inline ``[a, b]`` lists, block ``- item`` lists, and ``|`` / ``>`` block scalars.
|
||||
A plain scalar folds the indented lines below it (YAML's multi-line form) and a
|
||||
nested mapping is skipped rather than guessed at; anything structurally broken
|
||||
raises :class:`FrontmatterError` so callers can fall back to "no metadata".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_FENCE = "---"
|
||||
# Closing markers accepted by the format (``...`` is the YAML document end).
|
||||
_CLOSERS = frozenset({_FENCE, "..."})
|
||||
_BLOCK_SCALAR_MARKERS = frozenset({"|", "|-", "|+", ">", ">-", ">+"})
|
||||
_TRUE = frozenset({"true", "yes", "on"})
|
||||
_FALSE = frozenset({"false", "no", "off"})
|
||||
_INT = re.compile(r"^-?\d+$")
|
||||
_ITEM = "- "
|
||||
# A quoted scalar needs at least its two quote characters.
|
||||
_QUOTED_MIN_LEN = 2
|
||||
|
||||
|
||||
class FrontmatterError(ValueError):
|
||||
"""Raised when a frontmatter block is outside the supported subset."""
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
||||
"""Return ``(metadata, body)`` for a ``SKILL.md`` document.
|
||||
|
||||
No frontmatter (or an unterminated block) yields empty metadata and the whole
|
||||
text as the body.
|
||||
|
||||
Raises:
|
||||
FrontmatterError: The block is outside the supported subset.
|
||||
"""
|
||||
block, body = split_frontmatter(text)
|
||||
if block is None:
|
||||
return {}, body
|
||||
return parse_yaml_subset(block), body.removeprefix("\n")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str | None, str]:
|
||||
"""Split *text* into its raw frontmatter block (or ``None``) and the body.
|
||||
|
||||
The block must open with a ``---`` line of its own and close with ``---`` or
|
||||
``...``; an unterminated block counts as no frontmatter, so the whole text
|
||||
stays readable as the body.
|
||||
"""
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
if not lines or lines[0].strip() != _FENCE:
|
||||
return None, normalized
|
||||
for index, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() in _CLOSERS:
|
||||
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
|
||||
return None, normalized
|
||||
|
||||
|
||||
def parse_yaml_subset(block: str) -> dict[str, object]:
|
||||
"""Parse one frontmatter block into a flat mapping.
|
||||
|
||||
Raises:
|
||||
FrontmatterError: A line is not a top-level ``key: value`` pair.
|
||||
"""
|
||||
metadata: dict[str, object] = {}
|
||||
lines = block.split("\n")
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
raw = lines[index]
|
||||
index += 1
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith(_ITEM):
|
||||
msg = f"list item without a key: {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
if _indent_of(raw) > 0:
|
||||
msg = f"unexpected indentation: {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
key, sep, value = raw.partition(":")
|
||||
if not sep or not key.strip():
|
||||
msg = f"expected 'key: value', got {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
name = key.strip()
|
||||
value = value.strip()
|
||||
consumed, index = _consume_indented(lines, index)
|
||||
if value in _BLOCK_SCALAR_MARKERS:
|
||||
metadata[name] = _join_block_scalar(consumed, folded=value.startswith(">"))
|
||||
continue
|
||||
if value:
|
||||
metadata[name] = _scalar(value, consumed)
|
||||
continue
|
||||
items = _list_items(consumed)
|
||||
if items is not None:
|
||||
metadata[name] = items
|
||||
# Empty value with nested mappings: nothing this subset needs to record.
|
||||
return metadata
|
||||
|
||||
|
||||
def _indent_of(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def _consume_indented(lines: list[str], index: int) -> tuple[list[str], int]:
|
||||
"""Consume lines indented deeper than the key line; return them and the next index."""
|
||||
consumed: list[str] = []
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line.strip() and _indent_of(line) == 0:
|
||||
break
|
||||
consumed.append(line)
|
||||
index += 1
|
||||
return consumed, index
|
||||
|
||||
|
||||
def _dedent(lines: list[str]) -> list[str]:
|
||||
"""Strip the common indentation and trim blank edges."""
|
||||
indents = [_indent_of(line) for line in lines if line.strip()]
|
||||
if not indents:
|
||||
return []
|
||||
width = min(indents)
|
||||
dedented = [line[width:] if len(line) >= width else "" for line in lines]
|
||||
while dedented and not dedented[0].strip():
|
||||
_ = dedented.pop(0)
|
||||
while dedented and not dedented[-1].strip():
|
||||
_ = dedented.pop()
|
||||
return dedented
|
||||
|
||||
|
||||
def _join_block_scalar(lines: list[str], *, folded: bool) -> str:
|
||||
"""Join a ``|`` (literal) or ``>`` (folded) block scalar's content."""
|
||||
body = _dedent(lines)
|
||||
if not body:
|
||||
return ""
|
||||
return " ".join(line.strip() for line in body) if folded else "\n".join(body)
|
||||
|
||||
|
||||
def _list_items(lines: list[str]) -> list[object] | None:
|
||||
"""Parse a block ``- item`` list; ``None`` when *lines* is not one."""
|
||||
meaningful = [line for line in lines if line.strip()]
|
||||
if not meaningful or any(not line.strip().startswith(_ITEM) for line in meaningful):
|
||||
return None
|
||||
items: list[object] = []
|
||||
for line in meaningful:
|
||||
item = line.strip()[len(_ITEM) :].strip()
|
||||
if item:
|
||||
items.append(_coerce(item))
|
||||
return items
|
||||
|
||||
|
||||
def _unquote(text: str) -> str | None:
|
||||
"""Return the contents of a quoted scalar, or ``None`` when it is unquoted."""
|
||||
if len(text) >= _QUOTED_MIN_LEN and text[0] == text[-1] and text[0] in {'"', "'"}:
|
||||
return text[1:-1]
|
||||
return None
|
||||
|
||||
|
||||
def _scalar(value: str, continuation: list[str]) -> object:
|
||||
"""Parse one scalar; a plain scalar folds its indented continuation lines."""
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
return _inline_list(value)
|
||||
unquoted = _unquote(value)
|
||||
if unquoted is not None:
|
||||
return unquoted
|
||||
folded = [value]
|
||||
folded.extend(line.strip() for line in continuation if line.strip())
|
||||
return _coerce(" ".join(folded))
|
||||
|
||||
|
||||
def _inline_list(value: str) -> list[object]:
|
||||
"""Parse ``[a, "b", 3]`` (no nested collections in this subset)."""
|
||||
inner = value[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [_coerce(part.strip()) for part in inner.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _coerce(text: str) -> object:
|
||||
"""Coerce a plain scalar to bool / int / str (YAML's implicit typing)."""
|
||||
unquoted = _unquote(text)
|
||||
if unquoted is not None:
|
||||
return unquoted
|
||||
lowered = text.lower()
|
||||
if lowered in _TRUE:
|
||||
return True
|
||||
if lowered in _FALSE:
|
||||
return False
|
||||
if _INT.match(text):
|
||||
return int(text)
|
||||
return text
|
||||
@@ -37,10 +37,10 @@ from .file import delete_path as delete_path
|
||||
from .file import edit_lineno as edit_lineno
|
||||
from .file import edit_replace as edit_replace
|
||||
from .file import glob_paths as glob_paths
|
||||
from .file import grep_files as grep_files
|
||||
from .file import listdir as listdir
|
||||
from .file import move_path as move_path
|
||||
from .file import read_file as read_file
|
||||
from .file import regex_files as regex_files
|
||||
from .file import tree as tree
|
||||
from .file import write_file as write_file
|
||||
from .net import NET_TOOLS as NET_TOOLS
|
||||
@@ -60,6 +60,13 @@ from .process import run_argv as run_argv
|
||||
from .process import run_argv_batch as run_argv_batch
|
||||
from .process import write_pty as write_pty
|
||||
from .process import write_pty_keys as write_pty_keys
|
||||
from .skills import SKILL_TOOL_NAMES as SKILL_TOOL_NAMES
|
||||
from .skills import SKILL_TOOLS as SKILL_TOOLS
|
||||
from .skills import format_skill_table as format_skill_table
|
||||
from .skills import get_skill_store as get_skill_store
|
||||
from .skills import skill_list as skill_list
|
||||
from .skills import skill_read as skill_read
|
||||
from .skills import skill_search as skill_search
|
||||
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
|
||||
from .temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||
from .todo import TODO_TOOLS as TODO_TOOLS
|
||||
@@ -106,5 +113,6 @@ from .workspace import set_command_denylist as set_command_denylist
|
||||
from .workspace import set_path_denylist as set_path_denylist
|
||||
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
|
||||
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
|
||||
from .workspace import set_static_read_roots as set_static_read_roots
|
||||
from .workspace import set_workspace_root as set_workspace_root
|
||||
from .workspace import within_workspace_roots as within_workspace_roots
|
||||
|
||||
@@ -223,6 +223,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
from plyngent.tools.file import FILE_TOOLS
|
||||
from plyngent.tools.net import NET_TOOLS
|
||||
from plyngent.tools.process import PROCESS_TOOLS
|
||||
from plyngent.tools.skills import SKILL_TOOLS
|
||||
from plyngent.tools.todo import TODO_TOOLS
|
||||
from plyngent.tools.vcs import VCS_TOOLS
|
||||
|
||||
@@ -233,6 +234,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
*VCS_TOOLS,
|
||||
*CHAT_TOOLS,
|
||||
*TODO_TOOLS,
|
||||
*SKILL_TOOLS,
|
||||
*NET_TOOLS,
|
||||
):
|
||||
if catalog.get(definition.name) is None:
|
||||
@@ -254,6 +256,7 @@ def register_builtin_tools(*, force: bool = False) -> ToolCatalog:
|
||||
import plyngent.tools.file as _file_tools
|
||||
import plyngent.tools.net as _net_tools
|
||||
import plyngent.tools.process as _process_tools
|
||||
import plyngent.tools.skills as _skills_tools
|
||||
import plyngent.tools.temp_workspace as _temp_workspace_tools
|
||||
import plyngent.tools.todo as _todo_tools
|
||||
import plyngent.tools.vcs as _vcs_tools
|
||||
@@ -263,6 +266,7 @@ def register_builtin_tools(*, force: bool = False) -> ToolCatalog:
|
||||
_file_tools,
|
||||
_net_tools,
|
||||
_process_tools,
|
||||
_skills_tools,
|
||||
_temp_workspace_tools,
|
||||
_todo_tools,
|
||||
_vcs_tools,
|
||||
|
||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
|
||||
|
||||
|
||||
@@ -40,6 +41,8 @@ class InstanceState:
|
||||
# Path/command policy bag for this host (preferred over process globals).
|
||||
workspace: WorkspacePolicy = field(default_factory=_default_workspace_policy)
|
||||
data: PersistentDataView[Any] = field(default_factory=_default_instance_data)
|
||||
# Discovered skills for this host (None when the feature is off).
|
||||
skills: SkillStore | None = None
|
||||
# Ephemeral process maps (PTY etc.) may hang here later.
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -83,6 +86,9 @@ class SessionState:
|
||||
# Directory-access grants for this session (resolved path → mode). Config /
|
||||
# process-scoped grants live on WorkspacePolicy; see plyngent.tools.access.
|
||||
access_grants: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Skill directories the human allowed this chat to write (resolved paths);
|
||||
# the standing grant lives in [skills].allow_write (tools/skills.py).
|
||||
skill_write_roots: set[Path] = field(default_factory=set)
|
||||
|
||||
def has_grant(self, tool_name: str) -> bool:
|
||||
return bool(self.grants.get(tool_name))
|
||||
|
||||
@@ -7,9 +7,9 @@ from .fs_ops import delete_path as delete_path
|
||||
from .fs_ops import move_path as move_path
|
||||
from .get_truncated import get_truncated as get_truncated
|
||||
from .glob_paths import glob_paths as glob_paths
|
||||
from .grep_files import grep_files as grep_files
|
||||
from .listdir import listdir as listdir
|
||||
from .read import read_file as read_file
|
||||
from .regex_files import regex_files as regex_files
|
||||
from .tree import tree as tree
|
||||
from .write import write_file as write_file
|
||||
|
||||
@@ -19,7 +19,7 @@ FILE_TOOLS = [
|
||||
listdir,
|
||||
tree,
|
||||
glob_paths,
|
||||
grep_files,
|
||||
regex_files,
|
||||
edit_replace,
|
||||
edit_lineno,
|
||||
copy_path,
|
||||
|
||||
@@ -103,7 +103,9 @@ def _match_lines(
|
||||
try:
|
||||
rel = str(file_path.resolve().relative_to(root))
|
||||
except ValueError:
|
||||
return
|
||||
# Outside the primary root (a grant / temp workspace / skill directory):
|
||||
# keep the absolute path so the hit stays addressable.
|
||||
rel = str(file_path.resolve())
|
||||
found = 0
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if found >= remaining:
|
||||
@@ -128,7 +130,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def grep_files(
|
||||
async def regex_files(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
*,
|
||||
@@ -138,8 +140,22 @@ async def grep_files(
|
||||
) -> str:
|
||||
"""Search file contents under the workspace with a regular expression.
|
||||
|
||||
Returns ``path:line: content`` lines (paths relative to workspace root).
|
||||
Skips VCS/hidden dirs by default and ignores likely-binary files.
|
||||
``pattern`` is a **Python** ``re`` regex matched per line (``re.MULTILINE``),
|
||||
**not** a filename glob — ``*.py`` / ``**/*.py`` are invalid regexes and fail
|
||||
with ``error: invalid regex``. There is no file-type filter either: narrow with
|
||||
``path`` (a file or a directory) and filter unrelated hits yourself.
|
||||
``case_insensitive`` (or an inline ``(?i)``) ignores case.
|
||||
|
||||
``path`` is a **literal** file or directory (default ``.`` = workspace root);
|
||||
globs are not expanded, so ``src/**/*.py`` fails with ``error: path does not
|
||||
exist`` — pass ``src`` instead.
|
||||
|
||||
Returns ``<path>:<line>: <content>`` lines (workspace-relative, absolute outside
|
||||
the workspace), long lines clipped with ``…``, capped at ``max_matches``
|
||||
(default 100, plus a ``...[truncated at N matches]`` marker). VCS/hidden dirs are
|
||||
skipped unless ``skip_hidden_dirs=False``; files over 1 MB and likely-binary
|
||||
files are ignored. Returns ``(no matches)`` when nothing matched; an invalid
|
||||
``pattern`` or a missing ``path`` returns an ``error: ...`` line.
|
||||
"""
|
||||
if max_matches < 1:
|
||||
return "error: max_matches must be >= 1"
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Skill tools: discover, read, search, create, and edit skill directories.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md`` (frontmatter + Markdown body) plus
|
||||
optional bundled files — scripts, references, templates. Discovery, parsing, and
|
||||
path rules live in :mod:`plyngent.skills`; this module is the model-facing shell.
|
||||
|
||||
Reading needs no grant: the host registers the roots as static read-only roots,
|
||||
so ``read_file`` / ``regex_files`` see them too. Writing goes through the skill
|
||||
tools' own grant (a per-call confirm, or ``[skills].allow_write``) and is always
|
||||
confined to a skill root — that is why these tools never inherit the
|
||||
directory-access path of ``request_directory_access``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.skills import (
|
||||
SKILL_FILE,
|
||||
FrontmatterError,
|
||||
Skill,
|
||||
SkillStore,
|
||||
parse_frontmatter,
|
||||
resolve_inside,
|
||||
resolve_skill_file,
|
||||
validate_skill_name,
|
||||
)
|
||||
from plyngent.tools.file.read import read_file as _read_file
|
||||
from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES
|
||||
from plyngent.tools.file.regex_files import regex_files as _regex_files
|
||||
from plyngent.tools.workspace import (
|
||||
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS,
|
||||
WorkspaceError,
|
||||
get_workspace_root,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from plyngent.tools.context import InstanceState, SessionState
|
||||
|
||||
# Keeps a listing readable when a third-party root holds a pile of skills.
|
||||
_MAX_LISTED_ISSUES = 8
|
||||
_SKILL_WRITE_POLICY_KEY = "skill_write_policy"
|
||||
# ``path:line: content`` as ``regex_files`` formats it (the path is non-greedy so
|
||||
# a Windows drive letter or a colon in a file name cannot split it wrongly).
|
||||
_HIT_RE = re.compile(r"^(?P<path>.+?):(?P<line>\d+): ?(?P<content>.*)$")
|
||||
|
||||
|
||||
def get_skill_store() -> SkillStore | None:
|
||||
"""The skill store bound on the host instance, if any."""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
instance = get_instance()
|
||||
return instance.skills if instance is not None else None
|
||||
|
||||
|
||||
def _store_or_error() -> SkillStore | str:
|
||||
store = get_skill_store()
|
||||
if store is None:
|
||||
return "error: skills are not available in this session"
|
||||
return store
|
||||
|
||||
|
||||
def format_skill_table(store: SkillStore) -> str:
|
||||
"""Model- and human-facing listing: visible skills, shadows, roots, notes.
|
||||
|
||||
Shadowed copies and per-skill notes are printed rather than hidden: a skill
|
||||
that lost a name collision, or whose frontmatter had to be repaired, must
|
||||
stay visible to whoever can fix it.
|
||||
"""
|
||||
skills = store.skills
|
||||
lines = [f"skills: {len(skills)}"]
|
||||
width = max((len(skill.name) for skill in skills), default=0)
|
||||
for skill in skills:
|
||||
description = skill.description or "(no description)"
|
||||
lines.append(f"{skill.name.ljust(width)} [{skill.root.label}] {skill.path} — {description}")
|
||||
|
||||
shadows = store.shadowed()
|
||||
if shadows:
|
||||
parts: list[str] = []
|
||||
for name, copies in shadows.items():
|
||||
others = ", ".join(copy.root.label for copy in copies[1:])
|
||||
parts.append(f"{name} — {copies[0].root.label} wins (also {others})")
|
||||
lines.append("shadowed: " + "; ".join(parts))
|
||||
|
||||
notes = [f"{skill.name}: {issue}" for skill in skills for issue in skill.issues]
|
||||
if notes:
|
||||
lines.append("notes: " + "; ".join(notes[:_MAX_LISTED_ISSUES]))
|
||||
if len(notes) > _MAX_LISTED_ISSUES:
|
||||
lines.append(f"notes: … and {len(notes) - _MAX_LISTED_ISSUES} more")
|
||||
|
||||
absent = [root for root in store.roots if not root.path.is_dir()]
|
||||
if absent:
|
||||
lines.append("roots without a directory: " + ", ".join(f"{root.path} ({root.label})" for root in absent))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _unknown_skill(name: str) -> str:
|
||||
return f"error: unknown skill {name!r}; call skill_list to see the discovered skills"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_list() -> str:
|
||||
"""List discovered skills: name, source root, directory, and description.
|
||||
|
||||
Skills come from the roots in ``[skills]`` (our own user directory and other
|
||||
harnesses' skill directories by default); the first copy of a name wins and
|
||||
the copies it shadows are listed too. A skill's ``SKILL.md`` is the
|
||||
instruction set — read one with ``skill_read`` before following it, and use
|
||||
``skill_search`` to find a skill by its content.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
return format_skill_table(store)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_read(
|
||||
name: str,
|
||||
file: str = SKILL_FILE,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
with_lineno: bool = False,
|
||||
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> str:
|
||||
"""Read a skill's ``SKILL.md`` (default) or one of its bundled files.
|
||||
|
||||
``name`` is the skill name from ``skill_list``; ``file`` is a path inside
|
||||
that skill (a script or reference it mentions). ``offset`` is a 0-based line
|
||||
start, ``with_lineno`` prefixes 1-based line numbers, and ``max_chars`` caps
|
||||
the slice with a ``truncate_token`` for ``get_truncated`` to continue.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
skill = store.find(name)
|
||||
if skill is None:
|
||||
return _unknown_skill(name)
|
||||
target = resolve_skill_file(skill, file)
|
||||
if target is None:
|
||||
return f"error: file {file!r} is outside skill {skill.name!r}"
|
||||
if not target.is_file():
|
||||
return f"error: no such file in skill {skill.name!r}: {file}"
|
||||
return await _read_file.handler(
|
||||
str(target),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
with_lineno=with_lineno,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
|
||||
|
||||
def _hit_relative(skill: Skill, path: str) -> str:
|
||||
"""Rebase one ``regex_files`` hit path onto its skill directory.
|
||||
|
||||
The search reports paths relative to the workspace root for files inside it and
|
||||
absolute paths for files outside, so a skill directory can arrive either way
|
||||
(a project-scope skill root lives inside the workspace). Normalizing to an
|
||||
absolute path first covers both without guessing from the string alone.
|
||||
"""
|
||||
candidate = Path(path)
|
||||
if not candidate.is_absolute():
|
||||
try:
|
||||
candidate = get_workspace_root() / candidate
|
||||
except WorkspaceError:
|
||||
return path
|
||||
try:
|
||||
return str(candidate.resolve().relative_to(skill.path.resolve()))
|
||||
except OSError, ValueError:
|
||||
return path
|
||||
|
||||
|
||||
def _skill_hits(skill: Skill, result: str) -> list[str]:
|
||||
"""Attribute one skill's search lines to that skill."""
|
||||
hits: list[str] = []
|
||||
for line in result.splitlines():
|
||||
match = _HIT_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
relative = _hit_relative(skill, match["path"])
|
||||
hits.append(f"{skill.name}/{relative}:{match['line']}: {match['content']}")
|
||||
return hits
|
||||
|
||||
|
||||
def _search_targets(store: SkillStore, skill: str) -> list[Skill] | str:
|
||||
"""Every visible skill, or the single named one."""
|
||||
if not skill.strip():
|
||||
return store.skills
|
||||
found = store.find(skill)
|
||||
if found is None:
|
||||
return _unknown_skill(skill)
|
||||
return [found]
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_search(
|
||||
pattern: str,
|
||||
skill: str = "",
|
||||
*,
|
||||
case_insensitive: bool = False,
|
||||
max_matches: int = DEFAULT_MAX_MATCHES,
|
||||
) -> str:
|
||||
"""Search skill contents with a regular expression.
|
||||
|
||||
Returns ``<skill>/<file>:<line>: content`` lines across every visible skill
|
||||
(or only *skill* when given), covering ``SKILL.md`` and any bundled files.
|
||||
Use ``skill_list`` for names and descriptions, and ``glob_paths`` on a
|
||||
skill's directory when you are looking for a file rather than its content.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
targets = _search_targets(store, skill)
|
||||
if isinstance(targets, str):
|
||||
return targets
|
||||
if not targets:
|
||||
return "(no skills to search)"
|
||||
|
||||
hits: list[str] = []
|
||||
for target in targets:
|
||||
remaining = max_matches - len(hits)
|
||||
if remaining <= 0:
|
||||
break
|
||||
result = await _regex_files.handler(
|
||||
pattern,
|
||||
str(target.path),
|
||||
case_insensitive=case_insensitive,
|
||||
max_matches=remaining,
|
||||
)
|
||||
if result.startswith("error:") or result == "(no matches)":
|
||||
if result.startswith("error:"):
|
||||
return result
|
||||
continue
|
||||
hits.extend(_skill_hits(target, result))
|
||||
|
||||
if not hits:
|
||||
return "(no matches)"
|
||||
body = "\n".join(hits[:max_matches])
|
||||
if len(hits) >= max_matches:
|
||||
body += f"\n...[truncated at {max_matches} matches]"
|
||||
return body
|
||||
|
||||
|
||||
# -- writing (needs a grant) -------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillWriteDecision:
|
||||
"""A human's answer to a skill write prompt: allow, and for how long."""
|
||||
|
||||
allow: bool
|
||||
# True keeps the grant for the rest of this chat session (the standing
|
||||
# alternative is ``[skills].allow_write`` in the config).
|
||||
session: bool = False
|
||||
|
||||
|
||||
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillWritePolicy:
|
||||
"""How this host authorizes writes to skill directories.
|
||||
|
||||
``allow_all`` is the standing grant (``[skills].allow_write``). Otherwise the
|
||||
host's ``confirm`` hook is asked per skill directory; a host that installs no
|
||||
hook denies, which keeps the library default safe.
|
||||
"""
|
||||
|
||||
allow_all: bool = False
|
||||
confirm: SkillWriteConfirmHook | None = None
|
||||
timeout: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def set_skill_write_policy(policy: SkillWritePolicy | None, *, instance: InstanceState | None = None) -> None:
|
||||
"""Install (or clear) the host's skill-write policy on the bound instance."""
|
||||
from plyngent.tools.context import require_instance
|
||||
|
||||
inst = instance if instance is not None else require_instance()
|
||||
if policy is None:
|
||||
_ = inst.extras.pop(_SKILL_WRITE_POLICY_KEY, None)
|
||||
return
|
||||
inst.extras[_SKILL_WRITE_POLICY_KEY] = policy
|
||||
|
||||
|
||||
def get_skill_write_policy() -> SkillWritePolicy | None:
|
||||
"""The policy installed on the bound instance, if any."""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
instance = get_instance()
|
||||
return cast("SkillWritePolicy | None", instance.extras.get(_SKILL_WRITE_POLICY_KEY)) if instance else None
|
||||
|
||||
|
||||
def _denied_by_policy(*paths: Path) -> str | None:
|
||||
"""Hard path denylist for a write target (never skippable by a grant)."""
|
||||
from plyngent.tools.workspace import denylist_match
|
||||
|
||||
for path in paths:
|
||||
for candidate, directory in ((path, path.is_dir()), (path.parent, True)):
|
||||
matched = denylist_match(candidate, directory=directory)
|
||||
if matched is not None:
|
||||
return f"error: path denied by policy (matched {matched!r}): {path}"
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_write(
|
||||
skill_dir: Path,
|
||||
*,
|
||||
action: str,
|
||||
detail: str,
|
||||
policy: SkillWritePolicy,
|
||||
session: SessionState | None,
|
||||
) -> str | None:
|
||||
"""Ask the host's hook and record a session grant when the human chose one."""
|
||||
hook = policy.confirm
|
||||
if hook is None:
|
||||
return None
|
||||
try:
|
||||
decision = hook(skill_dir, action, detail, policy.timeout)
|
||||
except Exception as exc: # noqa: BLE001 — surface a failing prompt to the model
|
||||
return f"error: skill write confirm failed: {exc}"
|
||||
allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow)
|
||||
if not allowed:
|
||||
return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)"
|
||||
if isinstance(decision, SkillWriteDecision) and decision.session and session is not None:
|
||||
session.skill_write_roots.add(skill_dir)
|
||||
return None
|
||||
|
||||
|
||||
def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str) -> str | None:
|
||||
"""``None`` when a write to *target* inside *skill_dir* is authorized.
|
||||
|
||||
Grants are per **skill directory** — that is the unit the human is asked
|
||||
about, and one answer covers every file of that skill. The denylist is
|
||||
checked first and always wins; then a session grant, then
|
||||
``[skills].allow_write``, then the host's confirm hook. Without a hook the
|
||||
write is denied rather than silently allowed.
|
||||
"""
|
||||
denied = _denied_by_policy(target, skill_dir)
|
||||
if denied is not None:
|
||||
return denied
|
||||
from plyngent.tools.context import get_session
|
||||
|
||||
session = get_session()
|
||||
if session is not None and skill_dir in session.skill_write_roots:
|
||||
return None
|
||||
policy = get_skill_write_policy()
|
||||
if policy is None or (policy.confirm is None and not policy.allow_all):
|
||||
return (
|
||||
"error: writing skills needs a grant (no confirm hook is installed; denied); "
|
||||
"set [skills].allow_write = true to allow skill writes"
|
||||
)
|
||||
if policy.allow_all:
|
||||
return None
|
||||
return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session)
|
||||
|
||||
|
||||
def _frontmatter_scalar(text: str) -> str:
|
||||
"""One-line, quoted-if-needed frontmatter value for generated SKILL.md files."""
|
||||
one_line = " ".join(text.split())
|
||||
if not one_line:
|
||||
return '""'
|
||||
if any(char in one_line for char in ":#{}[]\"'"):
|
||||
return '"' + one_line.replace('"', "'") + '"'
|
||||
return one_line
|
||||
|
||||
|
||||
def _render_skill_md(name: str, description: str, body: str) -> str:
|
||||
"""Compose a minimal ``SKILL.md``: name, description, then the body."""
|
||||
front = f"---\nname: {name}\ndescription: {_frontmatter_scalar(description)}\n---\n\n"
|
||||
return front + body.strip() + "\n"
|
||||
|
||||
|
||||
def _skill_notes(skill_file: Path) -> str:
|
||||
"""Non-fatal notes after editing a ``SKILL.md`` (parse errors, missing fields)."""
|
||||
try:
|
||||
metadata, _body = parse_frontmatter(skill_file.read_text(encoding="utf-8", errors="replace"))
|
||||
except (FrontmatterError, OSError) as exc:
|
||||
return f"\nnotes: SKILL.md frontmatter not parsed ({exc}); it will use the directory name"
|
||||
description = metadata.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
return "\nnotes: SKILL.md has no frontmatter description; the first paragraph will be used"
|
||||
return ""
|
||||
|
||||
|
||||
def _write_file(target: Path, content: str) -> None:
|
||||
"""Write *content* under a skill, creating parent directories."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
_ = target.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _create_target(store: SkillStore, name: str) -> tuple[Path, str] | str:
|
||||
"""Resolve the directory a new *name* goes into; error string when unusable."""
|
||||
problem = validate_skill_name(name)
|
||||
if problem is not None:
|
||||
return f"error: {problem}"
|
||||
root = store.default_write_root()
|
||||
if root is None:
|
||||
return "error: no skill root is configured; set [skills].paths or [skills].discover"
|
||||
token = name.strip()
|
||||
target = root.path / token
|
||||
if (target / SKILL_FILE).is_file():
|
||||
return f"error: skill {token!r} already exists at {target}; use skill_edit to change it"
|
||||
if store.find(token) is not None:
|
||||
return f"error: skill {token!r} is already visible from another root; edit it there instead"
|
||||
return target, token
|
||||
|
||||
|
||||
def _bundled_targets(target: Path, files: dict[str, str], *, name: str) -> dict[Path, str] | str:
|
||||
"""Resolve every bundled file path before anything is written."""
|
||||
resolved: dict[Path, str] = {}
|
||||
for relative, content in files.items():
|
||||
path = resolve_inside(target, relative)
|
||||
if path is None:
|
||||
return f"error: bundled file {relative!r} is outside skill {name!r}"
|
||||
resolved[path] = content
|
||||
return resolved
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
||||
async def skill_create(
|
||||
name: str,
|
||||
description: str,
|
||||
body: str,
|
||||
*,
|
||||
files: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a new skill: a directory with a ``SKILL.md`` and optional files.
|
||||
|
||||
``name`` is the skill name (lowercase letters, digits, hyphens); the skill
|
||||
is written under the default write root (our own user skills directory
|
||||
unless ``[skills].paths`` puts an explicit one first). ``body`` is the
|
||||
instruction text, and ``files`` maps paths inside the skill to their content
|
||||
(scripts, references, templates). Writing needs a grant: the human is asked
|
||||
once per skill, or the whole feature is opened with ``[skills].allow_write``.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
resolution = _create_target(store, name)
|
||||
if isinstance(resolution, str):
|
||||
return resolution
|
||||
target, token = resolution
|
||||
bundled = _bundled_targets(target, files or {}, name=token)
|
||||
if isinstance(bundled, str):
|
||||
return bundled
|
||||
|
||||
detail = f"create skill {token!r} at {target} (SKILL.md"
|
||||
detail += f" + {len(bundled)} file(s)" if bundled else ""
|
||||
detail += ")"
|
||||
denial = _authorize_write(target, target, action="create", detail=detail)
|
||||
if denial is not None:
|
||||
return denial
|
||||
|
||||
try:
|
||||
_write_file(target / SKILL_FILE, _render_skill_md(token, description, body))
|
||||
for path, content in bundled.items():
|
||||
_write_file(path, content)
|
||||
except OSError as exc:
|
||||
return f"error: failed to create skill {token!r}: {exc}"
|
||||
store.reload()
|
||||
return f"created skill {token!r} at {target}{_skill_notes(target / SKILL_FILE)}"
|
||||
|
||||
|
||||
def _edit_target(store: SkillStore, name: str, file: str) -> tuple[Skill, Path, str] | str:
|
||||
"""Look up the skill and the file to edit inside it."""
|
||||
skill = store.find(name)
|
||||
if skill is None:
|
||||
return _unknown_skill(name)
|
||||
target = resolve_skill_file(skill, file)
|
||||
if target is None:
|
||||
return f"error: file {file!r} is outside skill {skill.name!r}"
|
||||
return skill, target, file.strip() or SKILL_FILE
|
||||
|
||||
|
||||
def _edit_mode_error(*, old_string: str, new_string: str, append: str) -> str | None:
|
||||
"""Reject an edit request that names no form, or two at once."""
|
||||
if not (old_string or new_string or append):
|
||||
return "error: nothing to write; pass old_string + new_string, append, or new_string alone"
|
||||
if old_string and append:
|
||||
return "error: use either old_string + new_string or append, not both"
|
||||
return None
|
||||
|
||||
|
||||
def _apply_text_edit(
|
||||
existing: str | None,
|
||||
*,
|
||||
relative: str,
|
||||
skill_name: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
append: str,
|
||||
replace_all: bool,
|
||||
) -> tuple[str, str] | str:
|
||||
"""Compute the new file text; ``(text, detail)`` or an error string."""
|
||||
mode_error = _edit_mode_error(old_string=old_string, new_string=new_string, append=append)
|
||||
if mode_error is not None:
|
||||
return mode_error
|
||||
if old_string:
|
||||
text = existing or ""
|
||||
found = text.count(old_string) # non-overlapping, like str.replace
|
||||
if found == 0:
|
||||
return f"error: old_string not found in {relative} of skill {skill_name!r}"
|
||||
limit = found if replace_all else 1
|
||||
detail = f"{relative}: replaced {limit} of {found} occurrence(s)"
|
||||
return text.replace(old_string, new_string, limit), detail
|
||||
if append:
|
||||
if existing is None:
|
||||
return f"error: no such file in skill {skill_name!r}: {relative}"
|
||||
separator = "" if not existing or existing.endswith("\n") else "\n"
|
||||
detail = f"{relative}: appended {len(append)} characters"
|
||||
return f"{existing}{separator}{append}", detail
|
||||
return new_string, f"{relative}: rewrote {len(new_string)} characters"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
||||
async def skill_edit(
|
||||
name: str,
|
||||
*,
|
||||
file: str = SKILL_FILE,
|
||||
old_string: str = "",
|
||||
new_string: str = "",
|
||||
append: str = "",
|
||||
replace_all: bool = False,
|
||||
) -> str:
|
||||
"""Change one file of an existing skill (``SKILL.md`` by default).
|
||||
|
||||
Three mutually exclusive forms:
|
||||
|
||||
- ``old_string`` + ``new_string``: literal replacement of the first match,
|
||||
or of every match with ``replace_all``;
|
||||
- ``append``: add text at the end of the file;
|
||||
- ``new_string`` alone: rewrite the whole file (a missing file is created,
|
||||
parent directories included).
|
||||
|
||||
Writing needs a grant for that skill (asked once, or standing via
|
||||
``[skills].allow_write``).
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
resolution = _edit_target(store, name, file)
|
||||
if isinstance(resolution, str):
|
||||
return resolution
|
||||
skill, target, relative = resolution
|
||||
existing = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else None
|
||||
outcome = _apply_text_edit(
|
||||
existing,
|
||||
relative=relative,
|
||||
skill_name=skill.name,
|
||||
old_string=old_string,
|
||||
new_string=new_string,
|
||||
append=append,
|
||||
replace_all=replace_all,
|
||||
)
|
||||
if isinstance(outcome, str):
|
||||
return outcome
|
||||
updated, detail = outcome
|
||||
|
||||
denial = _authorize_write(skill.path, target, action="update", detail=f"{skill.name}: {detail}")
|
||||
if denial is not None:
|
||||
return denial
|
||||
try:
|
||||
_write_file(target, updated)
|
||||
except OSError as exc:
|
||||
return f"error: failed to write {relative} of skill {skill.name!r}: {exc}"
|
||||
store.reload()
|
||||
notes = _skill_notes(target) if target.name == SKILL_FILE else ""
|
||||
return f"updated skill {skill.name!r} ({detail}){notes}"
|
||||
|
||||
|
||||
SKILL_TOOLS = [
|
||||
skill_list,
|
||||
skill_read,
|
||||
skill_search,
|
||||
skill_create,
|
||||
skill_edit,
|
||||
]
|
||||
|
||||
# Fed to the catalog selection filter: ``[skills].enabled = false`` drops these
|
||||
# from the model-visible registry (definitions still exist in the catalog).
|
||||
SKILL_TOOL_NAMES = frozenset(definition.name for definition in SKILL_TOOLS)
|
||||
@@ -98,6 +98,10 @@ class WorkspacePolicy:
|
||||
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
|
||||
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Host-contributed read-only roots (skill directories): every path tool may
|
||||
# read them, nothing may write them. Not a grant, so they stay out of
|
||||
# ``/grants``; writes go through the owning tool's own confirm.
|
||||
static_read: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
def _bound_instance() -> InstanceState | None:
|
||||
@@ -285,7 +289,7 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
|
||||
|
||||
|
||||
def granted_access_mode(resolved: Path) -> AccessMode | None:
|
||||
"""Highest config / session / process mode covering *resolved*, if any."""
|
||||
"""Highest config / session / process / static-read mode covering *resolved*."""
|
||||
policy = active_workspace_policy()
|
||||
session = _bound_session()
|
||||
buckets: list[Mapping[Path, AccessMode]] = [policy.config_allow, policy.yolo_allow]
|
||||
@@ -296,9 +300,36 @@ def granted_access_mode(resolved: Path) -> AccessMode | None:
|
||||
for root, granted in grants.items():
|
||||
if (resolved == root or resolved.is_relative_to(root)) and (best is None or granted > best):
|
||||
best = granted
|
||||
for root in policy.static_read:
|
||||
if (resolved == root or resolved.is_relative_to(root)) and (best is None or best < AccessMode.READ):
|
||||
best = AccessMode.READ
|
||||
return best
|
||||
|
||||
|
||||
def set_static_read_roots(
|
||||
roots: Sequence[Path | str],
|
||||
*,
|
||||
instance: InstanceState | None = None,
|
||||
) -> list[Path]:
|
||||
"""Install host-contributed read-only roots (resolved); returns them.
|
||||
|
||||
Replaces any previous list. Roots that are not existing directories are
|
||||
dropped: an absent skill directory holds nothing to read, and a missing
|
||||
entry must never turn into an implicit write permission later.
|
||||
"""
|
||||
inst = instance if instance is not None else require_bound_instance()
|
||||
resolved: list[Path] = []
|
||||
for root in roots:
|
||||
try:
|
||||
path = Path(root).expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if path.is_dir():
|
||||
resolved.append(path)
|
||||
inst.workspace.static_read[:] = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _path_grant_covers(resolved: Path, required: AccessMode) -> bool:
|
||||
"""Whether a config / session / process grant covers *resolved*."""
|
||||
granted = granted_access_mode(resolved)
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
|
||||
),
|
||||
AssistantFunctionToolCall(
|
||||
id="call_02_ABCD",
|
||||
function=AssistantFunctionTool(name="grep_files", arguments='{"pattern":"todo"}'),
|
||||
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+433
-10
@@ -4,6 +4,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
||||
from plyngent.cli.display import (
|
||||
_clear_streamed_lines,
|
||||
_line_count_for_clear,
|
||||
_pretty_line_for,
|
||||
get_markdown_enabled,
|
||||
markdown_render_available,
|
||||
print_markdown,
|
||||
@@ -65,7 +68,7 @@ async def test_flush_markdown_on_source_change(
|
||||
|
||||
call = AssistantFunctionToolCall(
|
||||
id="1",
|
||||
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||
function=AssistantFunctionTool(name="acme_ping", arguments='{"host": "example.com"}'),
|
||||
)
|
||||
await render_events(
|
||||
_aiter(
|
||||
@@ -183,15 +186,16 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
|
||||
async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Tools with no pretty line (plugins, unknown names) keep the ``[tool]`` style."""
|
||||
call = ToolCallEvent(
|
||||
tool_call=AssistantFunctionToolCall(
|
||||
id="3",
|
||||
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||
function=AssistantFunctionTool(name="acme_ping", arguments='{"host": "example.com"}'),
|
||||
)
|
||||
)
|
||||
await render_events(_aiter([call, _result("diff --git a/x b/x")]))
|
||||
await render_events(_aiter([call, _result("pong")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "[tool] vcs_diff(" in out
|
||||
assert "[tool] acme_ping(" in out
|
||||
assert "[tool ok]" in out
|
||||
|
||||
|
||||
@@ -346,17 +350,17 @@ async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None
|
||||
assert "* Glob '*' in '.' (2 paths)" in out
|
||||
|
||||
|
||||
async def test_pretty_grep(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
async def test_pretty_regex_files(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = "src/a.py:3: x = 1\nsrc/a.py:9: x = 2\nsrc/b.py:1: y = 3\n"
|
||||
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "x"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("regex_files", '{"pattern": "x"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Grep 'x' in '.' (3 matches in 2 files)" in out
|
||||
assert "* Search 'x' in '.' (3 matches in 2 files)" in out
|
||||
|
||||
|
||||
async def test_pretty_grep_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
|
||||
async def test_pretty_regex_files_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("regex_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Grep 'zzz' in '.' (no matches)" in out
|
||||
assert "* Search 'zzz' in '.' (no matches)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_success(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
@@ -567,6 +571,338 @@ async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert "* Wait (error: duration must not be negative)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_open_and_read(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
opened = "session_id=3\nalive=true\nexit_code=\ncmd=ls -la"
|
||||
await render_events(_aiter([_pretty_call("open_pty", '{"command": ["ls", "-la"]}'), _result(opened)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Open $ ls -la (session 3)" in out
|
||||
|
||||
read = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=false\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nhello\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(read)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (alive, 1 line)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_read_exited_and_matched(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An exited session reports the code; ``until`` reports that it matched."""
|
||||
exited = (
|
||||
"session_id=3\nalive=false\nexit_code=0\nmatched=false\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nbye\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(exited)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (exited 0)" in out
|
||||
|
||||
matched = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=true\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nprompt\n$ \n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3, "until": "$ "}'), _result(matched)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (matched, 2 lines)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_read_empty_and_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
empty = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=false\ntruncated=false\nbudget_exhausted=false\n--- data ---\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(empty)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (alive, no output)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter([_pretty_call("read_pty", '{"session_id": 9}'), _result("error: unknown PTY session 9")])
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 9 (error: unknown PTY session 9)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_write_and_keys(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=5"
|
||||
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Write 3 (5 chars)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'),
|
||||
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Keys 3 (1 char)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The result only says the answer was written; the payload stays local."""
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=true\nsecret=true\nsubmit=true\nsource=human"
|
||||
await render_events(
|
||||
_aiter([_pretty_call("ask_into_pty", '{"session_id": 3, "message": "sudo password"}'), _result(status)])
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Ask 3 (answer sent, secret)" in out
|
||||
assert "wrote=true" not in out
|
||||
|
||||
|
||||
async def test_pretty_close_pty(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
closed = "session_id=3\nclosed=true\nalive=false\nexit_code=0\nmessage=closed"
|
||||
await render_events(_aiter([_pretty_call("close_pty", '{"session_id": 3}'), _result(closed)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Close 3 (closed)" in out
|
||||
|
||||
already = "session_id=3\nclosed=false\nalive=false\nexit_code=0\nmessage=already closed"
|
||||
await render_events(_aiter([_pretty_call("close_pty", '{"session_id": 3}'), _result(already)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Close 3 (already closed)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_line_and_choice(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("ask_user_line", '{"question": "Which port?"}'), _result("8080")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask 'Which port?' (answered)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_choice", '{"question": "Pick one", "options": ["alpha", "beta"]}'),
|
||||
_result("beta"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask Choose 'Pick one' (beta)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_form_counts_answers(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_form", '{"title": "Setup", "fields": [{"name": "port", "prompt": "Port?"}]}'),
|
||||
_result('{"port": 8080, "host": "localhost"}'),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask Form 'Setup' (2 answers)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_line", '{"question": "Port?"}'),
|
||||
_result("error: interactive prompts are unavailable (stdin is not a TTY)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask 'Port?' (error: interactive prompts are unavailable (stdin is not a TTY))" in out
|
||||
|
||||
|
||||
async def test_pretty_request_directory_access(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/data", "mode": "write"}'),
|
||||
_result("access granted: /data (write, session)\nnote: the path denylist still applies"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Access '/data' (write) (granted)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/data"}'),
|
||||
_result("already accessible: /data is inside the workspace"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Access '/data' (read) (already accessible)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/root"}'),
|
||||
_result("error: access to /root denied (declined or timed out after 30s)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "(error: access to /root denied (declined or timed out after 30s))" in out
|
||||
|
||||
|
||||
async def test_pretty_new_temporary_workspace(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
content = "temporary_workspace=/tmp/plyngent-ws-abc\nnote: project workspace unchanged"
|
||||
await render_events(_aiter([_pretty_call("new_temporary_workspace", '{"prefix": "ws"}'), _result(content)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Temp Dir (/tmp/plyngent-ws-abc)" in out
|
||||
|
||||
|
||||
async def test_pretty_get_truncated(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("get_truncated", '{"token": "abc"}'), _result("L81-160\nmore\n")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Resume Truncated L81-160 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("get_truncated", '{"token": "abc"}'),
|
||||
_result("error: truncate token expired (no longer in memory); re-run the tool"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "(error: truncate token expired (no longer in memory); re-run the tool)" in out
|
||||
|
||||
|
||||
async def test_pretty_vcs_kind_branch_and_diff(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("vcs_kind", "{}"), _result("git")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Kind (git)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("vcs_branch", "{}"), _result("main")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Branch (main)" in out
|
||||
|
||||
diff = (
|
||||
"diff --git a/x.py b/x.py\nindex 111..222 100644\n--- a/x.py\n+++ b/x.py\n@@ -1 +1,2 @@\n-old\n+new\n+extra\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("vcs_diff", '{"path": ""}'), _result(diff)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Diff (1 file, +2/-1)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("vcs_diff", '{"staged": true}'), _result("(no diff)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Diff (staged) (no diff)" in out
|
||||
|
||||
|
||||
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
stack = " [ ] a1: T1\n [x] a2: T2"
|
||||
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo List:" in out
|
||||
assert " [ ] a1: T1" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (a1:T1)\n{stack}")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo Pop:" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("todo_clear", "{}"), _result("cleared 3 item(s)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo Clear:" in out
|
||||
assert "cleared 3 item(s)" in out
|
||||
|
||||
|
||||
async def test_pretty_mcp_tools_use_one_generic_line(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Namespaced MCP tools render as ``* MCP <server>:<tool> ``."""
|
||||
await render_events(_aiter([_pretty_call("mcp__docs__search", '{"query": "x"}'), _result("found 1 page")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* MCP docs:search (done)" in out
|
||||
assert "[tool]" not in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("mcp__docs__search", '{"query": "x"}'), _result("error: server died")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* MCP docs:search (error: server died)" in out
|
||||
|
||||
|
||||
def test_every_builtin_tool_has_a_pretty_line() -> None:
|
||||
"""A new builtin tool must ship a renderer, or it silently loses the syntax."""
|
||||
from plyngent.tools.catalog import default_tool_definitions
|
||||
|
||||
missing = [d.name for d in default_tool_definitions() if _pretty_line_for(d.name) is None]
|
||||
assert missing == []
|
||||
|
||||
|
||||
async def test_pretty_skill_tools(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[_pretty_call("skill_list", "{}"), _result("skills: 3\npdf-processing [plyngent-user] /x — Extract PDFs")]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skills (3 skills)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_read", '{"name": "pdf-processing"}'),
|
||||
_result("L1-40\n# PDF processing\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill 'pdf-processing' L1-40 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_read", '{"name": "pdf-processing", "file": "scripts/run.sh"}'),
|
||||
_result("L1-3\n#!/bin/sh\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill 'pdf-processing' scripts/run.sh L1-3 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_search", '{"pattern": "pdftotext"}'),
|
||||
_result("alpha/SKILL.md:3: Use pdftotext.\nbeta/notes.md:1: pdftotext again"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Search 'pdftotext' (2 matches in 2 skills)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("skill_search", '{"pattern": "nope"}'), _result("(no matches)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Search 'nope' (no matches)" in out
|
||||
|
||||
|
||||
async def test_pretty_skill_create_and_edit(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_create", '{"name": "acme", "description": "d", "body": "b"}'),
|
||||
_result("created skill 'acme' at /skills/acme\nnotes: SKILL.md has no frontmatter description"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Create 'acme' (created) — SKILL.md has no frontmatter description" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_edit", '{"name": "acme"}'),
|
||||
_result("updated skill 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Edit 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_edit", '{"name": "acme", "file": "scripts/run.sh"}'),
|
||||
_result("error: skill write to /skills/acme denied (declined or timed out after 30s)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Edit 'acme' scripts/run.sh (error: skill write to /skills/acme denied" in out
|
||||
|
||||
|
||||
async def test_pretty_prefix_shown_while_call_runs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -637,6 +973,93 @@ async def test_pretty_parallel_batch_erases_open_prefix(
|
||||
assert "* Read 'b.txt' L1-9 (done)\n" in out
|
||||
|
||||
|
||||
async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression: erasing the open prefix must not eat the line above it.
|
||||
|
||||
Reasoning streams without a trailing newline, so the prefix line sits right
|
||||
below the reasoning text; the erase used to clear one row too many and wiped
|
||||
that reasoning line out of the screen.
|
||||
"""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
ReasoningDeltaEvent(content="think"),
|
||||
_pretty_call("read_file", '{"path": "a.txt"}'),
|
||||
_pretty_call("read_file", '{"path": "b.txt"}'),
|
||||
_result("L1-4\none\n"),
|
||||
_result("L1-9\ntwo\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert out == (
|
||||
"\nreasoning:\nthink"
|
||||
"\n* Read 'a.txt' "
|
||||
"\r\x1b[2K\x1b[1A"
|
||||
"\n* Read 'a.txt' L1-4 (done)\n"
|
||||
"\n* Read 'b.txt' L1-9 (done)\n"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_clear_streamed_lines_clears_exactly_the_given_rows(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Two rows means two clears; the cursor lands on the topmost cleared row."""
|
||||
_clear_streamed_lines(2)
|
||||
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A\r\x1b[2K"
|
||||
|
||||
|
||||
def test_clear_streamed_lines_resume_above_steps_back_one_row(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``resume_above`` clears the cursor row only, then steps above it."""
|
||||
_clear_streamed_lines(1, resume_above=True)
|
||||
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A"
|
||||
|
||||
|
||||
def test_clear_streamed_lines_ignores_non_positive_counts(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
_clear_streamed_lines(0)
|
||||
_clear_streamed_lines(-1)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_line_count_for_clear_counts_wrapped_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A streamed line wider than the terminal owns more than one row."""
|
||||
monkeypatch.setenv("COLUMNS", "20")
|
||||
assert _line_count_for_clear("assistant:", "x" * 20) == 2 # label + full row
|
||||
assert _line_count_for_clear("assistant:", "x" * 21) == 3 # label + two rows
|
||||
assert _line_count_for_clear("assistant:", "a\nb") == 3
|
||||
assert _line_count_for_clear("", "") == 0
|
||||
|
||||
|
||||
async def test_flush_markdown_erases_wrapped_assistant_rows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A wrapped assistant segment is fully erased before the markdown re-render."""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
|
||||
monkeypatch.setenv("COLUMNS", "20")
|
||||
set_markdown_enabled(True)
|
||||
try:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
TextDeltaEvent(content="x" * 21),
|
||||
_pretty_call("read_file", '{"path": "a.txt"}'),
|
||||
_result("L1-4\none\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_markdown_enabled(True)
|
||||
out = capsys.readouterr().out
|
||||
# label + two wrapped body rows + the blank separator above the label.
|
||||
assert out.count("\x1b[2K") == 4
|
||||
assert out.count("\x1b[1A") == 3
|
||||
|
||||
|
||||
async def test_pretty_prefix_closed_before_error_line(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""``/history``: turn grouping, display numbering, verbose levels, addressing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.agent import ChatAgent
|
||||
from plyngent.cli.slash import complete_slash_args, handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.cli.transcript import build_transcript, one_line
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
|
||||
class StubClient:
|
||||
"""No model calls happen while rendering history."""
|
||||
|
||||
async def chat_completions(self, param: object, *, stream: bool = False) -> object:
|
||||
del param, stream
|
||||
raise AssertionError("history must not call the model")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||
config.providers = {"local": provider}
|
||||
st = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=False,
|
||||
)
|
||||
st.client = StubClient()
|
||||
st.agent = ChatAgent(st.client, model=st.model, memory=st.memory, session_id=None)
|
||||
await st.new_session("t")
|
||||
yield st
|
||||
await memory.close()
|
||||
|
||||
|
||||
def _tool_call(message_id: str, name: str, args: str = "{}") -> AssistantChatMessage:
|
||||
call = AssistantFunctionToolCall(id=message_id, function=AssistantFunctionTool(name=name, arguments=args))
|
||||
return AssistantChatMessage(content="", tool_calls=[call])
|
||||
|
||||
|
||||
def _two_turns(*, system: bool = False) -> list[AnyChatMessage]:
|
||||
"""Two settled turns; turn 1 has two tool rounds, turn 2 one."""
|
||||
messages: list[AnyChatMessage] = []
|
||||
if system:
|
||||
messages.append(SystemChatMessage(content="You are plyngent. " + "rules. " * 30))
|
||||
messages += [
|
||||
UserChatMessage(content="read src/a.py and fix the typo"),
|
||||
_tool_call("c1", "read_file", '{"path": "src/a.py"}'),
|
||||
ToolChatMessage(tool_call_id="c1", content="L1-30\n" + "print('hi')\n" * 25 + "print('bye')\n"),
|
||||
DeveloperChatMessage(content="[DIRECTIVE CHECKPOINT band=1 tokens≈100000 source=estimate]\nplaybook…"),
|
||||
_tool_call("c2", "edit_replace", '{"path": "src/a.py"}'),
|
||||
ToolChatMessage(tool_call_id="c2", content="replaced 1 occurrence"),
|
||||
AssistantChatMessage(content="Fixed the typo in `src/a.py`.\n\n- line 12 changed"),
|
||||
UserChatMessage(content="now run the tests"),
|
||||
_tool_call("c3", "run_argv", '{"argv": ["pytest", "-q"]}'),
|
||||
ToolChatMessage(tool_call_id="c3", content="12 passed in 3.2s"),
|
||||
AssistantChatMessage(content="Tests pass: **12 passed** in 3.2s."),
|
||||
]
|
||||
return messages
|
||||
|
||||
|
||||
async def test_default_shows_the_last_turns_two_ends(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turns=2 messages=11 showing=last 1 turn(s) mode=mixed" in out
|
||||
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||
# Both ends are printed in full, not as one-line previews.
|
||||
assert "8. user:\nnow run the tests" in out
|
||||
assert "11. assistant:" in out
|
||||
assert "Tests pass: **12 passed** in 3.2s." in out
|
||||
assert "tool(c3)" not in out
|
||||
assert "turn 1" not in out
|
||||
|
||||
|
||||
async def test_verbose_expands_every_row_of_the_turn(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history -v 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||
# The ends stay full (the human's own words, the model's answer) …
|
||||
assert "1. user:\nread src/a.py and fix the typo" in out
|
||||
assert "7. assistant:" in out
|
||||
assert "- line 12 changed" in out
|
||||
# … while the rounds in between are one-liners.
|
||||
assert "2. assistant: tool_calls=[read_file]" in out
|
||||
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||
assert "4. developer: [DIRECTIVE CHECKPOINT band=1" in out
|
||||
assert "6. tool(c2): replaced 1 occurrence" in out
|
||||
assert "print('bye')" not in out
|
||||
|
||||
|
||||
async def test_double_verbose_prints_full_bodies(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns(system=True)
|
||||
assert await handle_slash(state, "/history -vv 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
# A tool result past the 200-char preview window is printed in full.
|
||||
assert "3. tool(c1):" in out
|
||||
assert "print('bye')" in out
|
||||
# -vv is also what surfaces the local-only system prompt.
|
||||
assert "local. system:" in out
|
||||
assert "You are plyngent." in out
|
||||
|
||||
|
||||
async def test_reasoning_prints_before_the_answer(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A reasoning trace leads the answer it produced, as it does live."""
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="why is the sky blue?"),
|
||||
AssistantChatMessage(content="Rayleigh scattering.", reasoning_content="Light scatters off air."),
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "2. assistant:" in out
|
||||
assert "reasoning:\nLight scatters off air." in out
|
||||
assert out.index("reasoning:") < out.index("Rayleigh scattering.")
|
||||
assert out.index("2. assistant:") < out.index("reasoning:")
|
||||
|
||||
|
||||
async def test_reasoning_is_absent_without_one(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = [UserChatMessage(content="hi"), AssistantChatMessage(content="hello")]
|
||||
assert await handle_slash(state, "/history -vv") is True
|
||||
assert "reasoning:" not in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_preview_never_prints_reasoning(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="why?"),
|
||||
AssistantChatMessage(content="Because.", reasoning_content="Because the docs say so."),
|
||||
]
|
||||
assert await handle_slash(state, "/history --preview") is True
|
||||
assert "Because the docs say so." not in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_verbose_keeps_local_rows_out_of_the_way(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns(system=True)
|
||||
assert await handle_slash(state, "/history -v 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "local. system:" not in out
|
||||
# Numbering is over conversation rows only, so the user row is #1.
|
||||
assert "1. user:\nread src/a.py and fix the typo" in out
|
||||
|
||||
|
||||
async def test_long_user_message_is_not_collapsed(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression: both ends of a turn print in full, however long they are."""
|
||||
body = "please review this: " + "x" * 400
|
||||
state.agent.messages = [UserChatMessage(content=body), AssistantChatMessage(content="ok")]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert body in out
|
||||
assert "1. user:\n" in out
|
||||
assert "…" not in out.split("2. assistant:")[0]
|
||||
|
||||
|
||||
async def test_preview_collapses_every_row(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history last 2 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=last 2 turn(s) mode=preview" in out
|
||||
assert "7. assistant: Fixed the typo in `src/a.py`." in out
|
||||
assert "11. assistant: Tests pass: **12 passed** in 3.2s." in out
|
||||
|
||||
|
||||
async def test_last_n_turns(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history last 2") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||
|
||||
|
||||
async def test_turn_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history 9") is True
|
||||
assert "error: no turn 9 (turns 1-2)" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_message_number_prints_one_row_in_full(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 3") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=message 3 mode=full" in out
|
||||
assert "3. tool(c1):" in out
|
||||
assert "print('bye')" in out
|
||||
assert "(turn 1; /history 1 -v to see it whole)" in out
|
||||
|
||||
|
||||
async def test_message_number_preview(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 3 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=message 3 mode=preview" in out
|
||||
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||
|
||||
|
||||
async def test_message_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 99") is True
|
||||
assert "error: no message 99 (messages 1-11)" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "message"),
|
||||
[
|
||||
("/history 1 2", "a count applies to 'last'"),
|
||||
("/history --message 3 -v", "-v/-vv apply to turns"),
|
||||
("/history -vv --preview", "use only one of -vv / --preview"),
|
||||
("/history -vvv", "use at most -vv"),
|
||||
],
|
||||
)
|
||||
async def test_usage_errors(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
command: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, command) is True # the REPL keeps running
|
||||
assert message in capsys.readouterr().err
|
||||
|
||||
|
||||
async def test_incomplete_turn_is_marked(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A retryable turn is flagged; no duplicate ``(pending retry)`` line."""
|
||||
state.agent.messages = [
|
||||
*_two_turns(),
|
||||
UserChatMessage(content="also update the changelog"),
|
||||
_tool_call("c4", "read_file", '{"path": "CHANGELOG.md"}'),
|
||||
ToolChatMessage(tool_call_id="c4", content="L1-4\n# Changelog"),
|
||||
]
|
||||
assert state.agent.pending_retry_text == "also update the changelog"
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 3 (msgs 12-14, rounds=1) [incomplete — /retry continues]" in out
|
||||
assert "pending retry" not in out
|
||||
|
||||
|
||||
async def test_no_turns_yet_shows_the_prelude(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A compacted session starts with a seed assistant row, no user turn."""
|
||||
state.agent.messages = [
|
||||
SystemChatMessage(content="You are plyngent."),
|
||||
AssistantChatMessage(content="Summary of session 4: …"),
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
assert "(no turns yet in this session)" in capsys.readouterr().out
|
||||
assert await handle_slash(state, "/history -v") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "1. assistant: Summary of session 4: …" in out
|
||||
assert "local system" not in out.replace(".", " ")
|
||||
|
||||
|
||||
async def test_empty_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = []
|
||||
assert await handle_slash(state, "/history") is True
|
||||
assert "(no messages in this session)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_turn_and_row_numbering() -> None:
|
||||
messages = _two_turns()
|
||||
view = build_transcript(messages)
|
||||
assert len(view.turns) == 2
|
||||
assert view.turns[0].ordinal == 1
|
||||
assert view.messages == 11
|
||||
assert view.turn(2) is not None
|
||||
assert view.turn(3) is None
|
||||
row = view.row(11)
|
||||
assert row is not None
|
||||
assert view.turn_of(11) is not None
|
||||
assert view.row(12) is None
|
||||
|
||||
|
||||
def test_one_line_preview() -> None:
|
||||
assert one_line(None) == ""
|
||||
assert one_line("single") == "single"
|
||||
assert one_line("first\nsecond\nthird") == "first (+2 lines)"
|
||||
assert one_line("x" * 250).endswith("…")
|
||||
assert len(one_line("x" * 250)) == 201
|
||||
|
||||
|
||||
def test_row_prefix_is_styled_once() -> None:
|
||||
"""Rows style their own prefix; renderers must not wrap it a second time."""
|
||||
view = build_transcript(_two_turns())
|
||||
row = view.row(1)
|
||||
assert row is not None
|
||||
prefix = row.prefix()
|
||||
assert prefix.count("\x1b[32m") == 1 # green user, exactly one style open
|
||||
assert prefix.startswith("\x1b[32m")
|
||||
assert "1. user:" in prefix
|
||||
local = build_transcript([SystemChatMessage(content="sys prompt")]).prelude[0]
|
||||
assert "local. system:" in local.prefix()
|
||||
|
||||
|
||||
async def test_slash_completion_offers_last_and_verbose(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||
assert "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||
assert "2" in complete_slash_args(state, "/history", "2")
|
||||
capsys.readouterr()
|
||||
@@ -186,7 +186,8 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||
assert "push" in complete_slash_args(state, "/todos", "p")
|
||||
assert "pop" in complete_slash_args(state, "/todos", "p")
|
||||
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
||||
assert "--full" in complete_slash_args(state, "/history", "--f")
|
||||
assert "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
|
||||
@@ -134,8 +134,8 @@ async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.Cap
|
||||
assert await handle_slash(state, "/help history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage: /history" in out
|
||||
# Real flags for full/preview are documented; Click's meta --help is not.
|
||||
assert "--full" in out
|
||||
# Real flags are documented; Click's meta --help is not.
|
||||
assert "--verbose" in out
|
||||
assert "--preview" in out
|
||||
assert " --help" not in out
|
||||
|
||||
@@ -498,70 +498,6 @@ async def test_resume(state: ReplState) -> None:
|
||||
assert await handle_slash(state, f"/resume {sid}") is True
|
||||
|
||||
|
||||
async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="hello"),
|
||||
AssistantChatMessage(content="hi there"),
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=preview" in out
|
||||
assert "user: hello" in out
|
||||
assert "assistant: hi there" in out
|
||||
|
||||
|
||||
async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
long_body = "# Title\n\n" + ("paragraph text. " * 20)
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="hello"),
|
||||
AssistantChatMessage(content=long_body),
|
||||
]
|
||||
assert await handle_slash(state, "/history last") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert "assistant:" in out
|
||||
# Full mode should not use the 200-char preview ellipsis on the body alone
|
||||
assert long_body[:50] in out or "Title" in out
|
||||
|
||||
|
||||
async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||
|
||||
state.agent.messages = [UserChatMessage(content="only me")]
|
||||
assert await handle_slash(state, "/history 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert "only me" in out
|
||||
|
||||
|
||||
async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||
|
||||
state.agent.messages = [UserChatMessage(content="x" * 250)]
|
||||
assert await handle_slash(state, "/history 1 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=preview" in out
|
||||
assert "…" in out
|
||||
|
||||
|
||||
async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
body = "full " * 100
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="u"),
|
||||
AssistantChatMessage(content=body),
|
||||
]
|
||||
assert await handle_slash(state, "/history 2 --full") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert body[:40] in out
|
||||
|
||||
|
||||
async def test_rounds(state: ReplState) -> None:
|
||||
assert await handle_slash(state, "/rounds 40") is True
|
||||
assert state.max_rounds == 40
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""CLI surface for skills: ``/skills``, the system-prompt catalog, and writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.cli.slash import handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.skills import SKILL_FILE
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||
lines = ["[skills]", "discover = []"]
|
||||
if paths:
|
||||
rendered = ", ".join(f'"{path}"' for path in paths)
|
||||
lines.append(f"paths = [{rendered}]")
|
||||
if extra:
|
||||
lines.append(extra)
|
||||
return tomlkit.parse("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
async def _state(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
skills_dir: Path | None = None,
|
||||
tools_enabled: bool = False,
|
||||
extra: str = "",
|
||||
) -> ReplState:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
document = _config_doc(tmp_path, paths=[skills_dir] if skills_dir is not None else None, extra=extra)
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
|
||||
config.providers = {"local": provider}
|
||||
# The skills root sits outside the workspace: reading it works through the
|
||||
# static read-only roots, writing must stay denied by the path policy.
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=workspace,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=tools_enabled,
|
||||
quiet=True,
|
||||
)
|
||||
await state.new_session("t")
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def skills_state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack", body="Use pdftotext.")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
yield state
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_status_counts_skills(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert await handle_slash(skills_state, "/status") is True
|
||||
assert "skills=1" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_skills_are_discovered_from_a_configured_root(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy")
|
||||
state = await _state(tmp_path, skills_dir=root)
|
||||
try:
|
||||
assert state.skills is not None
|
||||
assert [skill.name for skill in state.skills.skills] == ["acme-deploy"]
|
||||
assert state.instance_state.skills is state.skills
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_lists_search_and_reads(
|
||||
skills_state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert await handle_slash(skills_state, "/skills") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "skills: 1" in out
|
||||
assert "acme-deploy" in out
|
||||
assert "Deploy the acme stack" in out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills search pdftotext") is True
|
||||
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills read acme-deploy") is True
|
||||
assert "description: Deploy the acme stack" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills read acme-deploy --file SKILL.md") is True
|
||||
assert "acme-deploy" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_slash_skills_reload_picks_up_a_new_skill(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
try:
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "skills: 0" in capsys.readouterr().out
|
||||
_ = _write_skill(root, "added-later")
|
||||
assert await handle_slash(state, "/skills reload") is True
|
||||
assert "skills reloaded: 1 visible" in capsys.readouterr().out
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "added-later" in capsys.readouterr().out
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_reports_a_disabled_feature(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state = await _state(tmp_path, extra="enabled = false")
|
||||
try:
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "skills: disabled" in capsys.readouterr().out
|
||||
assert state.skills is None
|
||||
assert state.instance_state.skills is None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
for command in ("/skills search", "/skills read"):
|
||||
assert await handle_slash(skills_state, command) is True
|
||||
assert "requires" in capsys.readouterr().err
|
||||
|
||||
|
||||
async def test_skill_tools_are_offered_and_can_read_skill_files(
|
||||
skills_state: ReplState,
|
||||
) -> None:
|
||||
"""The roots are read-only roots: path tools read them, writes are denied."""
|
||||
assert skills_state.agent.tools is not None
|
||||
assert skills_state.agent.tools.get("skill_list") is not None
|
||||
|
||||
skill_file = skills_state.skills.skills[0].skill_file if skills_state.skills else None
|
||||
assert skill_file is not None
|
||||
read = await skills_state.agent.tools.execute("read_file", f'{{"path": "{skill_file}"}}')
|
||||
assert "Deploy the acme stack" in read
|
||||
|
||||
denied = await skills_state.agent.tools.execute(
|
||||
"write_file",
|
||||
f'{{"path": "{skill_file}", "content": "x"}}',
|
||||
)
|
||||
assert denied.startswith("error:")
|
||||
assert "escapes workspace root" in denied
|
||||
assert "Deploy the acme stack" in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = false")
|
||||
try:
|
||||
assert state.agent.tools is not None
|
||||
assert state.agent.tools.get("skill_list") is None
|
||||
assert state.agent.tools.get("read_file") is not None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
try:
|
||||
prompt = state.agent.system_prompt or ""
|
||||
assert "### Skills" in prompt
|
||||
assert "- acme-deploy (config-user): Deploy the acme stack" in prompt
|
||||
assert "`skill_read`" in prompt
|
||||
# The body stays on disk: only the catalog goes into the prompt.
|
||||
assert "Body." not in prompt
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_respects_the_cap(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
for name in ("alpha", "beta", "gamma"):
|
||||
_ = _write_skill(root, name)
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="max_catalog_skills = 1")
|
||||
try:
|
||||
prompt = state.agent.system_prompt or ""
|
||||
assert "alpha" in prompt
|
||||
assert "beta" not in prompt
|
||||
assert "… and 2 more" in prompt
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_can_be_switched_off(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="inject_catalog = false")
|
||||
try:
|
||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||
# The tools are still offered; only the prompt block is gone.
|
||||
assert state.agent.tools is not None
|
||||
assert state.agent.tools.get("skill_list") is not None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_needs_tools(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||
try:
|
||||
assert state.agent.tools is None
|
||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||
try:
|
||||
assert state.tools_enabled is False
|
||||
assert await handle_slash(state, "/skills list") is True
|
||||
assert "tools are off" in capsys.readouterr().out
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
def test_prompt_skill_write_confirm_noninteractive_denies() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
decision = prompt_skill_write_confirm(Path("/skills/acme"), "create", "create skill", 1.0)
|
||||
assert decision.allow is False
|
||||
|
||||
|
||||
def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
|
||||
target = Path("/skills/acme")
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
|
||||
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
|
||||
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
||||
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
||||
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
assert (once.allow, once.session) == (True, False)
|
||||
assert (session.allow, session.session) == (True, True)
|
||||
assert denied.allow is False
|
||||
assert timed_out.allow is False
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_skills_section_defaults(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("", encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.skills_config.enabled is True
|
||||
assert store.skills_config.paths == []
|
||||
assert store.skills_config.discover == ["plyngent", "claude"]
|
||||
assert store.skills_config.include_project_roots is True
|
||||
assert store.skills_config.inject_catalog is True
|
||||
assert store.skills_config.max_catalog_skills == 50
|
||||
assert store.skills_config.allow_write is False
|
||||
|
||||
|
||||
def test_skills_section_parse(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = false
|
||||
paths = ["~/team-skills", "/srv/skills"]
|
||||
discover = ["plyngent"]
|
||||
include_project_roots = false
|
||||
inject_catalog = false
|
||||
max_catalog_skills = 5
|
||||
allow_write = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load(path).skills_config
|
||||
assert cfg.enabled is False
|
||||
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
||||
assert cfg.discover == ["plyngent"]
|
||||
assert cfg.include_project_roots is False
|
||||
assert cfg.inject_catalog is False
|
||||
assert cfg.max_catalog_skills == 5
|
||||
assert cfg.allow_write is True
|
||||
|
||||
|
||||
def test_skills_section_invalid_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown discovery source or a bad type degrades to the defaults."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
discover = ["codex"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path).skills_config.discover == ["plyngent", "claude"]
|
||||
|
||||
path2 = tmp_path / "d.toml"
|
||||
_ = path2.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = "yes"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path2).skills_config.enabled is True
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
from plyngent.skills import SKILL_FILE, Skill, SkillStore, build_roots, validate_skill_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
front = "---\n"
|
||||
if description is not None:
|
||||
front += f"name: {name}\ndescription: {description}\n"
|
||||
front += "---\n\n"
|
||||
_ = (directory / SKILL_FILE).write_text(front + body + "\n", encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_build_roots_orders_explicit_paths_first(tmp_path: Path) -> None:
|
||||
config = SkillsConfig(paths=["~/team-skills", "/srv/skills"])
|
||||
roots = build_roots(
|
||||
config,
|
||||
workspace=tmp_path / "proj",
|
||||
user_config_dir=tmp_path / "config",
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
assert [(r.origin, r.scope, str(r.path)) for r in roots] == [
|
||||
("config", "user", str(tmp_path / "home" / "team-skills")),
|
||||
("config", "user", "/srv/skills"),
|
||||
("plyngent", "user", str(tmp_path / "config" / "skills")),
|
||||
("claude", "user", str(tmp_path / "home" / ".claude" / "skills")),
|
||||
("claude", "project", str(tmp_path / "proj" / ".claude" / "skills")),
|
||||
]
|
||||
|
||||
|
||||
def test_build_roots_respects_discover_and_project_switch(tmp_path: Path) -> None:
|
||||
config = SkillsConfig(discover=["plyngent"], include_project_roots=False)
|
||||
roots = build_roots(config, workspace=tmp_path, user_config_dir=tmp_path / "cfg", home=tmp_path)
|
||||
assert [r.origin for r in roots] == ["plyngent"]
|
||||
|
||||
|
||||
def test_store_discovers_skills_and_labels_their_root(tmp_path: Path) -> None:
|
||||
user = tmp_path / "config" / "skills"
|
||||
_ = _write_skill(user, "pdf-processing", description="Extract text from PDFs")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path / "config")
|
||||
)
|
||||
skills = store.skills
|
||||
assert [(s.name, s.description, s.root.label) for s in skills] == [
|
||||
("pdf-processing", "Extract text from PDFs", "plyngent-user"),
|
||||
]
|
||||
assert skills[0].skill_file.name == SKILL_FILE
|
||||
|
||||
|
||||
def test_store_lists_skills_from_several_roots_sorted(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
_ = _write_skill(config_dir / "skills", "zeta")
|
||||
_ = _write_skill(tmp_path / "home" / ".claude" / "skills", "alpha")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=config_dir, home=tmp_path / "home")
|
||||
)
|
||||
assert [s.name for s in store.skills] == ["alpha", "zeta"]
|
||||
assert [s.root.origin for s in store.skills] == ["claude", "plyngent"]
|
||||
|
||||
|
||||
def test_explicit_path_wins_and_shadowed_copy_stays_listed(tmp_path: Path) -> None:
|
||||
explicit = tmp_path / "team"
|
||||
config_dir = tmp_path / "config"
|
||||
_ = _write_skill(explicit, "shared", description="Team copy")
|
||||
_ = _write_skill(config_dir / "skills", "shared", description="User copy")
|
||||
config = SkillsConfig(paths=[str(explicit)], discover=["plyngent"])
|
||||
store = SkillStore(build_roots(config, workspace=tmp_path, user_config_dir=config_dir))
|
||||
|
||||
found = store.find("shared")
|
||||
assert found is not None
|
||||
assert found.description == "Team copy"
|
||||
assert [s.description for s in store.shadowed()["shared"]] == ["Team copy", "User copy"]
|
||||
assert len(store.all_skills) == 2
|
||||
|
||||
|
||||
def test_find_is_case_insensitive_fallback(tmp_path: Path) -> None:
|
||||
_ = _write_skill(tmp_path / "skills", "pdf-processing")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert store.find("PDF-Processing") is not None
|
||||
assert store.find("nope") is None
|
||||
|
||||
|
||||
def test_root_itself_may_be_a_skill(tmp_path: Path) -> None:
|
||||
single = tmp_path / "my-skill"
|
||||
single.mkdir()
|
||||
_ = (single / SKILL_FILE).write_text("---\nname: my-skill\ndescription: One-off\n---\n\nBody.\n", encoding="utf-8")
|
||||
store = SkillStore(build_roots(SkillsConfig(paths=[str(single)]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert [s.name for s in store.skills] == ["my-skill"]
|
||||
|
||||
|
||||
def test_missing_roots_are_not_an_error(tmp_path: Path) -> None:
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=tmp_path / "nope", home=tmp_path)
|
||||
)
|
||||
assert store.skills == []
|
||||
assert store.shadowed() == {}
|
||||
|
||||
|
||||
def test_description_falls_back_to_the_first_paragraph(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "no-desc"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
"# Heading\n\nFirst paragraph of the skill.\n\nSecond paragraph.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.description == "First paragraph of the skill."
|
||||
assert skill.declared_name is None
|
||||
assert any("no frontmatter description" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_unusable_declared_name_falls_back_to_the_directory(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "dir-name"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
'---\nname: "PDF Skills"\ndescription: Mixed case\n---\n\nBody.\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.name == "dir-name"
|
||||
assert skill.declared_name == "PDF Skills"
|
||||
assert any("not usable" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_broken_frontmatter_still_yields_a_listable_skill(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "broken"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text("---\nname: broken\nbroken line\n---\n\nBody text.\n", encoding="utf-8")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.name == "broken"
|
||||
assert skill.description # falls back to the body
|
||||
assert any("frontmatter not parsed" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_cache_picks_up_an_edit_and_reload_forces_a_rescan(tmp_path: Path) -> None:
|
||||
root = tmp_path / "skills"
|
||||
directory = _write_skill(root, "cached", description="Before")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert store.skills[0].description == "Before"
|
||||
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
"---\nname: cached\ndescription: After\n---\n\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert store.skills[0].description == "After"
|
||||
|
||||
_ = _write_skill(root, "added")
|
||||
store.reload()
|
||||
assert [s.name for s in store.skills] == ["added", "cached"]
|
||||
|
||||
|
||||
def test_default_write_root_prefers_our_own_directory(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
store = SkillStore(build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=config_dir, home=tmp_path))
|
||||
root = store.default_write_root()
|
||||
assert root is not None
|
||||
assert root.origin == "plyngent"
|
||||
assert root.path == config_dir / "skills"
|
||||
|
||||
explicit = SkillStore(
|
||||
build_roots(SkillsConfig(paths=["/srv/skills"], discover=[]), workspace=tmp_path, user_config_dir=config_dir)
|
||||
)
|
||||
fallback = explicit.default_write_root()
|
||||
assert fallback is not None and fallback.origin == "config"
|
||||
assert SkillStore([]).default_write_root() is None
|
||||
|
||||
|
||||
def test_validate_skill_name() -> None:
|
||||
assert validate_skill_name("pdf-processing") is None
|
||||
assert validate_skill_name("a1") is None
|
||||
assert validate_skill_name("") is not None
|
||||
assert validate_skill_name("PDF") is not None
|
||||
assert validate_skill_name("with_underscore") is not None
|
||||
assert validate_skill_name("x" * 65) is not None
|
||||
|
||||
|
||||
def test_skill_is_a_frozen_record(tmp_path: Path) -> None:
|
||||
directory = _write_skill(tmp_path / "skills", "frozen")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert isinstance(skill, Skill)
|
||||
assert skill.path == directory
|
||||
assert skill.metadata["name"] == "frozen"
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.skills.frontmatter import (
|
||||
FrontmatterError,
|
||||
parse_frontmatter,
|
||||
parse_yaml_subset,
|
||||
split_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_frontmatter_basic() -> None:
|
||||
text = (
|
||||
"---\n"
|
||||
"name: pdf-processing\n"
|
||||
'description: "Extract text and tables from PDFs"\n'
|
||||
"version: 2\n"
|
||||
"license: MIT\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# PDF processing\n"
|
||||
"\n"
|
||||
"Use pdftotext first.\n"
|
||||
)
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {
|
||||
"name": "pdf-processing",
|
||||
"description": "Extract text and tables from PDFs",
|
||||
"version": 2,
|
||||
"license": "MIT",
|
||||
}
|
||||
assert body.startswith("# PDF processing")
|
||||
assert "pdftotext" in body
|
||||
|
||||
|
||||
def test_parse_frontmatter_without_block_keeps_the_whole_text() -> None:
|
||||
text = "# Just a skill\n\nNo frontmatter here.\n"
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {}
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_parse_frontmatter_unterminated_block_is_not_frontmatter() -> None:
|
||||
text = "---\nname: x\n\nbody without a closing fence\n"
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {}
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_frontmatter_closing_dots_are_accepted() -> None:
|
||||
block, body = split_frontmatter("---\nname: x\n...\nbody\n")
|
||||
assert block == "name: x"
|
||||
assert body == "body\n"
|
||||
|
||||
|
||||
def test_yaml_subset_types_lists_and_booleans() -> None:
|
||||
block = "\n".join(
|
||||
[
|
||||
"name: acme",
|
||||
"read_only: true",
|
||||
"confirm: no",
|
||||
"retries: 3",
|
||||
"allowed-tools: [read_file, regex_files]",
|
||||
"tags:",
|
||||
" - alpha",
|
||||
" - beta",
|
||||
]
|
||||
)
|
||||
assert parse_yaml_subset(block) == {
|
||||
"name": "acme",
|
||||
"read_only": True,
|
||||
"confirm": False,
|
||||
"retries": 3,
|
||||
"allowed-tools": ["read_file", "regex_files"],
|
||||
"tags": ["alpha", "beta"],
|
||||
}
|
||||
|
||||
|
||||
def test_yaml_subset_plain_scalar_folds_continuation_lines() -> None:
|
||||
block = "description: A long description\n that continues on the next line\n"
|
||||
assert parse_yaml_subset(block) == {
|
||||
"description": "A long description that continues on the next line",
|
||||
}
|
||||
|
||||
|
||||
def test_yaml_subset_block_scalars() -> None:
|
||||
literal = "description: |\n first\n second\n"
|
||||
folded = "description: >\n first\n second\n"
|
||||
assert parse_yaml_subset(literal) == {"description": "first\nsecond"}
|
||||
assert parse_yaml_subset(folded) == {"description": "first second"}
|
||||
|
||||
|
||||
def test_yaml_subset_nested_mapping_is_skipped() -> None:
|
||||
"""Unsupported nesting must not lose the flat fields around it."""
|
||||
block = "name: acme\nmetadata:\n author: someone\n homepage: https://example.com\nlicense: MIT\n"
|
||||
assert parse_yaml_subset(block) == {"name": "acme", "license": "MIT"}
|
||||
|
||||
|
||||
def test_yaml_subset_comments_and_blank_lines() -> None:
|
||||
block = "# a comment\n\nname: acme\n"
|
||||
assert parse_yaml_subset(block) == {"name": "acme"}
|
||||
|
||||
|
||||
def test_yaml_subset_rejects_broken_lines() -> None:
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset("name: acme\nnot a pair\n")
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset("- orphan item\n")
|
||||
|
||||
|
||||
def test_yaml_subset_rejects_indented_top_level() -> None:
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset(" name: acme\n")
|
||||
@@ -12,6 +12,7 @@ from plyngent.tools.chat import CHAT_TOOLS
|
||||
from plyngent.tools.file import FILE_TOOLS
|
||||
from plyngent.tools.net import NET_TOOLS
|
||||
from plyngent.tools.process import PROCESS_TOOLS
|
||||
from plyngent.tools.skills import SKILL_TOOLS
|
||||
from plyngent.tools.todo import TODO_TOOLS
|
||||
from plyngent.tools.vcs import VCS_TOOLS
|
||||
|
||||
@@ -19,7 +20,16 @@ from plyngent.tools.vcs import VCS_TOOLS
|
||||
def test_default_tool_names_match_group_lists() -> None:
|
||||
register_builtin_tools()
|
||||
selected = default_tool_definitions(surface="local")
|
||||
groups = [*ACCESS_TOOLS, *FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
|
||||
groups = [
|
||||
*ACCESS_TOOLS,
|
||||
*FILE_TOOLS,
|
||||
*PROCESS_TOOLS,
|
||||
*VCS_TOOLS,
|
||||
*CHAT_TOOLS,
|
||||
*TODO_TOOLS,
|
||||
*SKILL_TOOLS,
|
||||
*NET_TOOLS,
|
||||
]
|
||||
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
|
||||
assert len(selected) == len(groups)
|
||||
|
||||
@@ -51,7 +61,7 @@ def test_read_only_classification() -> None:
|
||||
"read_file",
|
||||
"get_truncated",
|
||||
"glob_paths",
|
||||
"grep_files",
|
||||
"regex_files",
|
||||
"listdir",
|
||||
"tree",
|
||||
"todo_list",
|
||||
@@ -65,6 +75,9 @@ def test_read_only_classification() -> None:
|
||||
"vcs_log",
|
||||
"vcs_branch",
|
||||
"fetch",
|
||||
"skill_list",
|
||||
"skill_read",
|
||||
"skill_search",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,46 +2,46 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.file.grep_files import grep_files
|
||||
from plyngent.tools.file.regex_files import regex_files
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
def test_grep_files_basic(workspace: object) -> None:
|
||||
def test_regex_files_basic(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "a.py").write_text("hello world\nfoo\n", encoding="utf-8")
|
||||
_ = (workspace / "b.py").write_text("nope\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "hello")
|
||||
out = call_sync(regex_files, "hello")
|
||||
assert "a.py:1:" in out
|
||||
assert "hello world" in out
|
||||
assert "b.py" not in out
|
||||
|
||||
|
||||
def test_grep_files_case_insensitive(workspace: object) -> None:
|
||||
def test_regex_files_case_insensitive(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "t.txt").write_text("Hello\n", encoding="utf-8")
|
||||
assert "no matches" in call_sync(grep_files, "hello")
|
||||
out = call_sync(grep_files, "hello", case_insensitive=True)
|
||||
assert "no matches" in call_sync(regex_files, "hello")
|
||||
out = call_sync(regex_files, "hello", case_insensitive=True)
|
||||
assert "t.txt:1:" in out
|
||||
|
||||
|
||||
def test_grep_files_invalid_regex(workspace: object) -> None:
|
||||
def test_regex_files_invalid_regex(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "invalid regex" in call_sync(grep_files, "[")
|
||||
assert "invalid regex" in call_sync(regex_files, "[")
|
||||
|
||||
|
||||
def test_grep_files_skip_git(workspace: object) -> None:
|
||||
def test_regex_files_skip_git(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / ".git").mkdir()
|
||||
_ = (workspace / ".git" / "x").write_text("secret\n", encoding="utf-8")
|
||||
_ = (workspace / "ok.txt").write_text("secret\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "secret")
|
||||
out = call_sync(regex_files, "secret")
|
||||
assert "ok.txt" in out
|
||||
assert ".git" not in out
|
||||
|
||||
|
||||
def test_grep_files_max_matches(workspace: object) -> None:
|
||||
def test_regex_files_max_matches(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
lines = "\n".join(f"match {i}" for i in range(10))
|
||||
_ = (workspace / "m.txt").write_text(lines + "\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "match", max_matches=3)
|
||||
out = call_sync(regex_files, "match", max_matches=3)
|
||||
assert "truncated" in out
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
from plyngent.skills import SKILL_FILE, SkillStore, build_roots
|
||||
from plyngent.tools.context import InstanceState, SessionState, bind_instance, bind_session
|
||||
from plyngent.tools.skills import (
|
||||
SKILL_TOOL_NAMES,
|
||||
SkillWriteDecision,
|
||||
SkillWritePolicy,
|
||||
format_skill_table,
|
||||
set_skill_write_policy,
|
||||
skill_create,
|
||||
skill_edit,
|
||||
skill_list,
|
||||
skill_read,
|
||||
skill_search,
|
||||
)
|
||||
from plyngent.tools.workspace import set_path_denylist, set_static_read_roots, set_workspace_root
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
front = "---\n"
|
||||
if description is not None:
|
||||
front += f"name: {name}\ndescription: {description}\n"
|
||||
front += "---\n\n"
|
||||
_ = (directory / SKILL_FILE).write_text(front + body + "\n", encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _bound(
|
||||
tmp_path: Path,
|
||||
*roots: Path,
|
||||
skills: SkillStore | None = None,
|
||||
session: bool = True,
|
||||
policy: SkillWritePolicy | None = None,
|
||||
) -> Generator[InstanceState]:
|
||||
"""Bind an instance whose skill store scans *roots*, like the CLI does."""
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
store = (
|
||||
skills
|
||||
if skills is not None
|
||||
else SkillStore(
|
||||
build_roots(
|
||||
SkillsConfig(paths=[str(r) for r in roots], discover=[]), workspace=workspace, user_config_dir=tmp_path
|
||||
)
|
||||
)
|
||||
)
|
||||
instance = InstanceState(workspace_root=workspace, skills=store)
|
||||
session_state = SessionState(session_id=1) if session else None
|
||||
with bind_instance(instance), bind_session(session_state):
|
||||
_ = set_workspace_root(workspace)
|
||||
_ = set_static_read_roots(roots)
|
||||
if policy is not None:
|
||||
set_skill_write_policy(policy, instance=instance)
|
||||
yield instance
|
||||
|
||||
|
||||
def _write_root(tmp_path: Path) -> Path:
|
||||
"""An empty user-level skills directory to create skills in."""
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _allow(*_args: object) -> SkillWriteDecision:
|
||||
return SkillWriteDecision(allow=True)
|
||||
|
||||
|
||||
def _deny(*_args: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def test_skill_list_without_a_store_returns_an_error(tmp_path: Path) -> None:
|
||||
with _bound(tmp_path, skills=None) as instance:
|
||||
instance.skills = None
|
||||
assert await skill_list.handler() == "error: skills are not available in this session"
|
||||
|
||||
|
||||
async def test_skill_list_reports_skills_roots_and_absent_dirs(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||
missing = tmp_path / "missing"
|
||||
with _bound(tmp_path, root, missing):
|
||||
out = await skill_list.handler()
|
||||
assert "skills: 1" in out
|
||||
assert "acme-deploy [config-user]" in out
|
||||
assert "Deploy the acme stack" in out
|
||||
assert str(root / "acme-deploy") in out
|
||||
assert "roots without a directory" in out
|
||||
assert str(missing) in out
|
||||
|
||||
|
||||
async def test_skill_list_marks_shadowed_copies(tmp_path: Path) -> None:
|
||||
high = tmp_path / "team"
|
||||
low = tmp_path / "personal"
|
||||
_ = _write_skill(high, "shared", description="Team copy")
|
||||
_ = _write_skill(low, "shared", description="Personal copy")
|
||||
with _bound(tmp_path, high, low):
|
||||
out = await skill_list.handler()
|
||||
assert "Team copy" in out
|
||||
assert "Personal copy" not in out
|
||||
assert "shadowed: shared — config-user wins (also config-user)" in out
|
||||
|
||||
|
||||
async def test_skill_list_shows_notes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
directory = root / "no-desc"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text("# Heading\n\nBody paragraph.\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_list.handler()
|
||||
assert "notes: no-desc: no frontmatter description; using the first paragraph" in out
|
||||
|
||||
|
||||
async def test_skill_read_defaults_to_skill_md(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme", body="Step one.")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme")
|
||||
assert out.startswith("L1-")
|
||||
assert "Step one." in out
|
||||
|
||||
|
||||
async def test_skill_read_bundled_file_with_line_numbers(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
directory = _write_skill(root, "acme")
|
||||
(directory / "scripts").mkdir()
|
||||
_ = (directory / "scripts" / "run.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme", "scripts/run.sh", with_lineno=True)
|
||||
assert " 1|#!/bin/sh" in out
|
||||
|
||||
|
||||
async def test_skill_read_refuses_to_escape_the_skill(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
_ = (tmp_path / "outside.txt").write_text("not yours\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
assert await skill_read.handler("acme", "../outside.txt") == (
|
||||
"error: file '../outside.txt' is outside skill 'acme'"
|
||||
)
|
||||
assert await skill_read.handler("acme", "/etc/hostname") == (
|
||||
"error: file '/etc/hostname' is outside skill 'acme'"
|
||||
)
|
||||
|
||||
|
||||
async def test_skill_read_unknown_skill_and_missing_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root):
|
||||
assert (
|
||||
await skill_read.handler("nope")
|
||||
== "error: unknown skill 'nope'; call skill_list to see the discovered skills"
|
||||
)
|
||||
assert await skill_read.handler("acme", "missing.md") == "error: no such file in skill 'acme': missing.md"
|
||||
|
||||
|
||||
async def test_skill_read_truncates_with_a_continuation_token(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme", body="x" * 500)
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme", max_chars=120)
|
||||
assert "truncate_token=" in out
|
||||
|
||||
|
||||
async def test_skill_search_attributes_hits_to_their_skill(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext for extraction.")
|
||||
beta = _write_skill(root, "beta", body="No match in the body.")
|
||||
_ = (beta / "notes.md").write_text("pdftotext again\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("pdftotext")
|
||||
assert "alpha/SKILL.md:" in out
|
||||
assert "beta/notes.md:" in out
|
||||
|
||||
|
||||
async def test_skill_search_single_skill_and_errors(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext.")
|
||||
_ = _write_skill(root, "beta", body="Nothing to see.")
|
||||
with _bound(tmp_path, root):
|
||||
assert await skill_search.handler("pdftotext", skill="beta") == "(no matches)"
|
||||
assert (await skill_search.handler("pdftotext", skill="nope")).startswith("error: unknown skill")
|
||||
assert (await skill_search.handler("[")).startswith("error: invalid regex")
|
||||
|
||||
|
||||
async def test_skill_search_inside_the_workspace_rebases_the_hit(tmp_path: Path) -> None:
|
||||
"""A project-scope skill root lives inside the workspace: one path, once.
|
||||
|
||||
The search reports in-workspace files relative to the workspace root and outside
|
||||
ones absolutely, so the skill name must never be glued onto a path that
|
||||
already carries the root.
|
||||
"""
|
||||
root = tmp_path / "project" / ".claude" / "skills"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext here.")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("pdftotext")
|
||||
assert out.count("alpha/SKILL.md:") == 1
|
||||
assert "project/.claude/skills/alpha" not in out
|
||||
assert "alpha/alpha/SKILL.md" not in out
|
||||
|
||||
|
||||
async def test_skill_search_without_skills(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
with _bound(tmp_path, empty):
|
||||
assert await skill_search.handler("anything") == "(no skills to search)"
|
||||
|
||||
|
||||
async def test_skill_search_respects_max_matches(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="hit\nhit\nhit\nhit\n")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("hit", max_matches=2)
|
||||
assert out.count("alpha/SKILL.md:") == 2
|
||||
assert out.endswith("...[truncated at 2 matches]")
|
||||
|
||||
|
||||
def test_format_skill_table_lists_roots_in_priority_order(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(paths=[str(root)], discover=[]), workspace=tmp_path, user_config_dir=tmp_path)
|
||||
)
|
||||
table = format_skill_table(store)
|
||||
assert "skills: 1" in table
|
||||
assert "acme [config-user]" in table
|
||||
|
||||
|
||||
def test_skill_tool_names_cover_the_group() -> None:
|
||||
expected = ("skill_list", "skill_read", "skill_search", "skill_create", "skill_edit")
|
||||
for name in expected:
|
||||
assert name in SKILL_TOOL_NAMES
|
||||
assert len(SKILL_TOOL_NAMES) == len(expected)
|
||||
|
||||
|
||||
async def test_skill_create_writes_skill_md_and_bundled_files(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_create.handler(
|
||||
"acme-deploy",
|
||||
"Deploy the acme stack",
|
||||
"# Steps\n\n1. Build.\n",
|
||||
files={"scripts/deploy.sh": "#!/bin/sh\necho deploy\n"},
|
||||
)
|
||||
assert out.startswith("created skill 'acme-deploy' at ")
|
||||
assert (root / "acme-deploy" / SKILL_FILE).is_file()
|
||||
assert (root / "acme-deploy" / "scripts" / "deploy.sh").read_text(encoding="utf-8").startswith("#!/bin/sh")
|
||||
# The new skill is visible right away, without a restart.
|
||||
assert "acme-deploy [config-user]" in await skill_list.handler()
|
||||
read = await skill_read.handler("acme-deploy")
|
||||
assert "description: Deploy the acme stack" in read
|
||||
assert "1. Build." in read
|
||||
|
||||
|
||||
async def test_skill_create_quotes_a_frontmatter_description_that_needs_it(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
_ = await skill_create.handler("quoted", "Use: colons, {braces}", "Body.")
|
||||
skill = SkillStore(
|
||||
build_roots(SkillsConfig(paths=[str(root)], discover=[]), workspace=tmp_path, user_config_dir=tmp_path)
|
||||
).skills[0]
|
||||
assert skill.description == "Use: colons, {braces}"
|
||||
|
||||
|
||||
async def test_skill_create_rejects_a_bad_name_and_a_taken_one(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "taken")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
assert (await skill_create.handler("Not-A-Name", "d", "b")).startswith("error: skill name")
|
||||
assert "already exists" in await skill_create.handler("taken", "d", "b")
|
||||
assert (await skill_create.handler("ok-name", "d", "b")).startswith("created skill")
|
||||
|
||||
|
||||
async def test_skill_create_rejects_a_bundled_file_outside_the_skill(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_create.handler("acme", "d", "b", files={"../escape.sh": "x"})
|
||||
assert out == "error: bundled file '../escape.sh' is outside skill 'acme'"
|
||||
assert not (tmp_path / "escape.sh").exists()
|
||||
|
||||
|
||||
async def test_skill_write_needs_a_grant(tmp_path: Path) -> None:
|
||||
"""Without a hook the write is denied, and the message says how to open it."""
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_create.handler("acme", "d", "b")
|
||||
assert out == (
|
||||
"error: writing skills needs a grant (no confirm hook is installed; denied); "
|
||||
"set [skills].allow_write = true to allow skill writes"
|
||||
)
|
||||
assert not (root / "acme").exists()
|
||||
|
||||
|
||||
async def test_skill_write_prompt_denial_is_reported(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Before.")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=_deny, timeout=3.0)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.startswith("error: skill write to ")
|
||||
assert out.endswith("denied (declined or timed out after 3s)")
|
||||
assert "After." not in (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_skill_write_confirm_sees_the_action_and_the_skill(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
seen: list[tuple[Path, str, str, float]] = []
|
||||
|
||||
def hook(target: Path, action: str, detail: str, timeout: float) -> bool:
|
||||
seen.append((target, action, detail, timeout))
|
||||
return True
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=hook, timeout=5.0)):
|
||||
_ = await skill_create.handler("acme", "d", "b")
|
||||
assert seen == [(root / "acme", "create", f"create skill 'acme' at {root / 'acme'} (SKILL.md)", 5.0)]
|
||||
|
||||
|
||||
async def test_skill_write_session_grant_is_asked_once(tmp_path: Path) -> None:
|
||||
"""A 'session' answer covers the same skill for the rest of the chat."""
|
||||
root = _write_root(tmp_path)
|
||||
calls: list[Path] = []
|
||||
|
||||
def hook(target: Path, action: str, detail: str, timeout: float) -> SkillWriteDecision:
|
||||
calls.append(target)
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=hook)):
|
||||
_ = await skill_create.handler("acme", "d", "b")
|
||||
_ = await skill_edit.handler("acme", append="\nMore.\n")
|
||||
_ = await skill_edit.handler("acme", file="notes.md", new_string="note\n")
|
||||
# One prompt for the skill directory, covering SKILL.md and later files.
|
||||
assert calls == [root / "acme"]
|
||||
assert (root / "acme" / "notes.md").read_text(encoding="utf-8") == "note\n"
|
||||
|
||||
|
||||
async def test_skill_write_denylist_wins_over_a_grant(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
set_path_denylist(["acme"])
|
||||
try:
|
||||
out = await skill_create.handler("acme", "d", "b")
|
||||
finally:
|
||||
set_path_denylist(())
|
||||
assert out.startswith("error: path denied by policy (matched 'acme')")
|
||||
assert not (root / "acme").exists()
|
||||
|
||||
|
||||
async def test_skill_edit_forms(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Line one.\nLine two.")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_edit.handler("acme", old_string="Line two.", new_string="Line two, edited.")
|
||||
assert out.startswith("updated skill 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))")
|
||||
|
||||
out = await skill_edit.handler("acme", append="\nAppended tail.")
|
||||
assert "appended 15 characters" in out
|
||||
|
||||
out = await skill_edit.handler("acme", file="scripts/run.sh", new_string="#!/bin/sh\n")
|
||||
assert "rewrote 10 characters" in out
|
||||
assert (root / "acme" / "scripts" / "run.sh").is_file()
|
||||
text = (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
|
||||
assert "Line two, edited." in text
|
||||
assert text.endswith("Appended tail.")
|
||||
|
||||
|
||||
async def test_skill_edit_replace_all_and_missing_match(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
directory = _write_skill(root, "acme", body="hit\nhit\nhit")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_edit.handler("acme", old_string="hit", new_string="done", replace_all=True)
|
||||
assert "replaced 3 of 3 occurrence(s)" in out
|
||||
missing = await skill_edit.handler("acme", old_string="nope", new_string="x")
|
||||
assert missing == "error: old_string not found in SKILL.md of skill 'acme'"
|
||||
assert (directory / SKILL_FILE).read_text(encoding="utf-8").count("done") == 3
|
||||
|
||||
|
||||
async def test_skill_edit_rejects_conflicting_forms(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
assert (await skill_edit.handler("acme")).startswith("error: nothing to write")
|
||||
assert (await skill_edit.handler("acme", old_string="a", new_string="b", append="c")).startswith(
|
||||
"error: use either"
|
||||
)
|
||||
assert (await skill_edit.handler("acme", file="nope.md", append="x")) == (
|
||||
"error: no such file in skill 'acme': nope.md"
|
||||
)
|
||||
assert (await skill_edit.handler("acme", file="../x.md", new_string="x")).startswith("error: file")
|
||||
|
||||
|
||||
async def test_skill_edit_notes_a_rewritten_frontmatter(tmp_path: Path) -> None:
|
||||
"""A SKILL.md that loses its description (or breaks) is reported, not repaired."""
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
dropped = await skill_edit.handler("acme", new_string="Only a body, no frontmatter.\n")
|
||||
broken = await skill_edit.handler("acme", new_string="---\nname: acme\nbroken line\n---\n\nBody.\n")
|
||||
assert dropped.endswith("notes: SKILL.md has no frontmatter description; the first paragraph will be used")
|
||||
assert "notes: SKILL.md frontmatter not parsed" in broken
|
||||
|
||||
|
||||
async def test_skill_tools_reject_writes_when_no_session_is_bound(tmp_path: Path) -> None:
|
||||
"""A host without session state still denies instead of crashing on the grant."""
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Before.")
|
||||
with _bound(tmp_path, root, session=False, policy=SkillWritePolicy(confirm=_allow)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.startswith("updated skill 'acme'")
|
||||
Reference in New Issue
Block a user