kidwit 0.1.0rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kidwit-0.1.0rc1/CHANGELOG.md +25 -0
- kidwit-0.1.0rc1/LICENSE +21 -0
- kidwit-0.1.0rc1/MANIFEST.in +6 -0
- kidwit-0.1.0rc1/PKG-INFO +172 -0
- kidwit-0.1.0rc1/README.md +144 -0
- kidwit-0.1.0rc1/evals/__init__.py +0 -0
- kidwit-0.1.0rc1/evals/run_evals.py +146 -0
- kidwit-0.1.0rc1/evals/tasks.py +126 -0
- kidwit-0.1.0rc1/pyproject.toml +45 -0
- kidwit-0.1.0rc1/scripts/check.sh +56 -0
- kidwit-0.1.0rc1/setup.cfg +4 -0
- kidwit-0.1.0rc1/src/kidwit.egg-info/PKG-INFO +172 -0
- kidwit-0.1.0rc1/src/kidwit.egg-info/SOURCES.txt +17 -0
- kidwit-0.1.0rc1/src/kidwit.egg-info/dependency_links.txt +1 -0
- kidwit-0.1.0rc1/src/kidwit.egg-info/entry_points.txt +2 -0
- kidwit-0.1.0rc1/src/kidwit.egg-info/top_level.txt +1 -0
- kidwit-0.1.0rc1/src/kidwit.py +684 -0
- kidwit-0.1.0rc1/tests/__init__.py +0 -0
- kidwit-0.1.0rc1/tests/test_kidwit.py +1397 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to kidwit are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Until 1.0, a minor version may change behaviour.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - not yet released
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
### Known limitations
|
|
14
|
+
- Tested live only with the OpenAI-compatible adapter (DeepSeek) on macOS with Python 3.14.
|
|
15
|
+
- The Anthropic adapter is covered by offline tests but has not been run against the live API.
|
|
16
|
+
- Windows is untested. Linux and Python 3.9 to 3.13 are untested until CI runs.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- A coding agent in one file of standard-library Python, with Anthropic and OpenAI-compatible providers.
|
|
20
|
+
- Six tools: `list_files`, `search`, `read_file`, `write_file`, `edit_file` and `run`.
|
|
21
|
+
- Safety: a path jail for the file tools, an approval prompt before every write, edit and command,
|
|
22
|
+
`--read-only`, a guard that always asks for dangerous commands, `/undo`, and an audit log.
|
|
23
|
+
- `--json` event protocol for building a UI on top of the engine.
|
|
24
|
+
- Context management: old tool output is dropped and, if needed, the older conversation is summarized.
|
|
25
|
+
- A small eval suite in `evals/`.
|
kidwit-0.1.0rc1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SM Sabbir Amin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kidwit-0.1.0rc1/PKG-INFO
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kidwit
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: A minimal coding agent in one file of standard-library Python. It works inside one directory and asks before it changes anything.
|
|
5
|
+
Author-email: SM Sabbir Amin <sabbiramin.cse11ruet@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://kidwit.sabbiramin.dev
|
|
8
|
+
Project-URL: Source, https://github.com/sabbiramin113008/kidwit
|
|
9
|
+
Project-URL: Issues, https://github.com/sabbiramin113008/kidwit/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/sabbiramin113008/kidwit/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: coding-agent,ai-agent,llm,cli,anthropic,openai,deepseek,ollama
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# kidwit
|
|
30
|
+
|
|
31
|
+
A complete coding-agent engine in one file of standard-library Python, around 500 lines of code. It works inside one directory and asks before it changes anything.
|
|
32
|
+
|
|
33
|
+
kidwit reads your code, edits files, runs commands and reports back, using native tool calling with Anthropic or any OpenAI-compatible API. It is a from-scratch build and a case study in how little a coding agent needs. It is not a replacement for mature agents.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
You need Python 3.9 or newer. kidwit has no dependencies. From a clone of this repository:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pipx install . # or: pip install .
|
|
41
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
42
|
+
kidwit "fix the failing test"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The whole engine is one file, so you can also run it with no install at all:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
python3 src/kidwit.py "fix the failing test"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without a task, kidwit starts an interactive session and, on a color terminal, shows a small banner. The banner never appears with `--json`, with a task on the command line, or when output is piped. `/clear` resets the conversation, `/verbose` toggles detailed output, and `/exit` (or `/quit`, or end of input) leaves.
|
|
52
|
+
|
|
53
|
+
## Status
|
|
54
|
+
|
|
55
|
+
kidwit is early software. This is what has and has not been tested:
|
|
56
|
+
|
|
57
|
+
1. **Tested live:** the OpenAI-compatible adapter with DeepSeek (`deepseek-chat`) on macOS with Python 3.14. The 8 tasks in `evals/` all pass end to end.
|
|
58
|
+
2. **Not yet run against the real service:** the Anthropic adapter. It is covered by the offline tests, but nobody has run it against the live Anthropic API yet.
|
|
59
|
+
3. **Untested:** Windows. The command runner uses Python's default shell there (`cmd.exe`), and killing a timed-out command's child processes is only verified on macOS and Linux-style systems. Linux and Python 3.9 to 3.13 are also untested for now.
|
|
60
|
+
|
|
61
|
+
If you try it somewhere on this list, please open an issue with what happened.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Environment variables:
|
|
66
|
+
|
|
67
|
+
| Variable | Meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `ANTHROPIC_API_KEY` | Key for the Anthropic Messages API. If set, Anthropic is the default provider. |
|
|
70
|
+
| `OPENAI_API_KEY` | Key for an OpenAI-compatible API. Optional for local servers. |
|
|
71
|
+
| `AGENT_PROVIDER` | `anthropic` or `openai`. |
|
|
72
|
+
| `AGENT_MODEL` | Model name. Defaults to `claude-sonnet-5-5` for Anthropic. Required for `openai`. |
|
|
73
|
+
| `AGENT_BASE_URL` | API base URL, for OpenRouter, Ollama, LM Studio, vLLM or a proxy. |
|
|
74
|
+
|
|
75
|
+
Provider choice follows this order: `--provider`, then `AGENT_PROVIDER`, then Anthropic if `ANTHROPIC_API_KEY` is set, then OpenAI-compatible.
|
|
76
|
+
|
|
77
|
+
Local model example:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
AGENT_BASE_URL=http://localhost:11434/v1 AGENT_MODEL=qwen2.5-coder kidwit "add a docstring to main()"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Flags:
|
|
84
|
+
|
|
85
|
+
| Flag | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `--root DIR` | Project directory. Default: the current directory. |
|
|
88
|
+
| `--provider anthropic\|openai` | Choose the API. |
|
|
89
|
+
| `--model M`, `--base-url URL` | Override `AGENT_MODEL` and `AGENT_BASE_URL`. |
|
|
90
|
+
| `--yes` | Never ask for approval. |
|
|
91
|
+
| `--read-only` | Offer only the read tools. Writes and commands are not available. |
|
|
92
|
+
| `--json` | Print JSON-lines events instead of text. |
|
|
93
|
+
| `--verbose` | Show live command output and full tool arguments. By default kidwit prints one short dim line per tool call and then the answer. |
|
|
94
|
+
| `--no-log` | Do not write `.agent/audit.jsonl`. |
|
|
95
|
+
| `--version` | Print the version. |
|
|
96
|
+
|
|
97
|
+
A task given on the command line runs once. The exit code is 0 on success and 1 on error, so it works in scripts and benchmarks.
|
|
98
|
+
|
|
99
|
+
## Reading the terminal
|
|
100
|
+
|
|
101
|
+
On a color terminal each kind of text looks different. Your prompt is a bold green `>>`. The model's replies have a magenta `▌` gutter. Tool calls are one dim line with the tool name in cyan. Permission requests start with a yellow `┌ permission needed` line, followed by the diff or command and a yellow `└ allow?` prompt. Errors are red. With `NO_COLOR`, piped output or `--json`, none of this styling is printed.
|
|
102
|
+
|
|
103
|
+
## Tools
|
|
104
|
+
|
|
105
|
+
The model gets six tools: `list_files`, `search`, `read_file`, `write_file`, `edit_file` and `run`. Inside a git repository, `list_files` and `search` honour `.gitignore`, so build output and similar files stay out of the way. Edits are exact-string replacement: a match that differs only in trailing whitespace is accepted, and when nothing matches the error names the closest line so the model can correct itself. `old_text` must match exactly once unless `replace_all` is set, and files with Windows line endings keep them.
|
|
106
|
+
|
|
107
|
+
If the project root has an `AGENTS.md` (or else a `CLAUDE.md`), its first 20,000 characters are added to the system prompt.
|
|
108
|
+
|
|
109
|
+
## Approval modes
|
|
110
|
+
|
|
111
|
+
1. `ask` (default). Before every `write_file`, `edit_file` and `run`, kidwit shows the diff or command and waits for `y`, `a`, `n`, or any other text. Other text is sent to the model as the reason for the denial. `a` (always) approves that kind of action for the rest of the session: file changes, or commands, but not both.
|
|
112
|
+
2. `auto` (`--yes`). No prompts, except for dangerous commands.
|
|
113
|
+
3. `read-only` (`--read-only`). The write tools are not offered to the model, and are refused if called anyway.
|
|
114
|
+
|
|
115
|
+
Some things are handled for you in every mode:
|
|
116
|
+
|
|
117
|
+
1. In `ask` mode, harmless commands run without a prompt: `ls`, `pwd`, `git status`, `git diff`, `git log`, `git show`, `git branch`, and test runners such as `python3 -m unittest`, `pytest`, `npm test`, `go test` and `cargo test`. They must use plain relative arguments with no shell operators. `cat`, `grep` and `head` are not on the list on purpose: the path jail does not cover `run`, so they could read files outside the project.
|
|
118
|
+
2. Dangerous commands always ask, even with `--yes` or after `a`: `rm -rf`, `sudo`, `curl ... | sh`, `git reset --hard`, `git clean`, `git push`, `chmod -R` and similar. With no one to answer (for example `--json` with closed stdin) they are denied.
|
|
119
|
+
3. `/undo` in an interactive session reverts the last file write or edit. It does not undo commands.
|
|
120
|
+
4. If the model's reply is cut off at the token limit, the half-written tool call is not run. The model is told to do less per call.
|
|
121
|
+
|
|
122
|
+
Every write, edit and command is appended to `<root>/.agent/audit.jsonl`. kidwit creates `.agent/.gitignore` so the log stays out of git.
|
|
123
|
+
|
|
124
|
+
## Limits you should know about
|
|
125
|
+
|
|
126
|
+
The path jail covers the file tools only. `read_file`, `write_file` and `edit_file` refuse paths outside the project root, including `../` and symlinks that lead out, and `list_files` and `search` skip such symlinks. `run` executes real shell commands, and those can reach anything your user account can reach. The protections in v1 are the approval prompt and `--read-only`. There is no OS-level sandbox, so review each command before you approve it, and avoid `--yes` on a machine or directory you care about.
|
|
127
|
+
|
|
128
|
+
Long sessions are kept within the model's context in two steps. First, old tool output is dropped. If the history is still too large (over about 200,000 characters), kidwit asks the model to summarize the older half of the conversation and continues from that summary.
|
|
129
|
+
|
|
130
|
+
Other deliberate omissions: no streaming of model responses (command output does stream), no sub-agents, no MCP, no saved sessions, and no UI beyond plain terminal text.
|
|
131
|
+
|
|
132
|
+
## Event protocol
|
|
133
|
+
|
|
134
|
+
With `--json`, kidwit writes one JSON object per line on stdout. Tasks and approval answers are read from stdin, one line each. End of input during an approval counts as "no". A UI can drive the engine through this protocol.
|
|
135
|
+
|
|
136
|
+
| `type` | Fields |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `info` | `message` |
|
|
139
|
+
| `text` | `text` |
|
|
140
|
+
| `tool` | `id`, `name`, `input` |
|
|
141
|
+
| `output` | `line` (live command output) |
|
|
142
|
+
| `approval` | `action` (`write`, `edit` or `run`), `target`, `detail` (diff or command) |
|
|
143
|
+
| `result` | `id`, `output`, `is_error` |
|
|
144
|
+
| `done` | `turns`, `input_tokens`, `output_tokens` |
|
|
145
|
+
| `error` | `message` |
|
|
146
|
+
|
|
147
|
+
## Tests
|
|
148
|
+
|
|
149
|
+
The tests use only `unittest` and need no API key. A fake local HTTP server stands in for both APIs.
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
scripts/check.sh # everything below in one go
|
|
153
|
+
python3 -m unittest discover -s tests -t . # just the tests
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`scripts/check.sh` checks the Python version, compiles `src/kidwit.py`, confirms it imports only the standard library, runs the tests, and checks the size (`grep -vcE '^\s*(#.*)?$' src/kidwit.py`). Over 500 lines prints a warning, and over 700 fails.
|
|
157
|
+
|
|
158
|
+
## Evals
|
|
159
|
+
|
|
160
|
+
`evals/` holds 8 small tasks, each a tiny repo with a failing check: an off-by-one, a name error, implementing a function, a root cause in a second file, a rename across files, a CRLF file, a missing config default, and a new CLI flag. They show whether a change to kidwit makes it better or worse.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
python3 evals/run_evals.py --selftest # checks the tasks themselves, no model needed
|
|
164
|
+
python3 evals/run_evals.py # runs every task against your configured model
|
|
165
|
+
python3 evals/run_evals.py --task crlf-file -- --provider openai --model deepseek-chat
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The runner prints pass or fail, turns, tokens and time per task, and saves the results to `evals/results/`. Be aware that it runs the agent with `--yes`, so the model's commands run as you with no sandbox. Dangerous commands still need approval, and because the runner closes stdin they are denied. Run it only where that is acceptable.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# kidwit
|
|
2
|
+
|
|
3
|
+
A complete coding-agent engine in one file of standard-library Python, around 500 lines of code. It works inside one directory and asks before it changes anything.
|
|
4
|
+
|
|
5
|
+
kidwit reads your code, edits files, runs commands and reports back, using native tool calling with Anthropic or any OpenAI-compatible API. It is a from-scratch build and a case study in how little a coding agent needs. It is not a replacement for mature agents.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
You need Python 3.9 or newer. kidwit has no dependencies. From a clone of this repository:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pipx install . # or: pip install .
|
|
13
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
14
|
+
kidwit "fix the failing test"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The whole engine is one file, so you can also run it with no install at all:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
python3 src/kidwit.py "fix the failing test"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Without a task, kidwit starts an interactive session and, on a color terminal, shows a small banner. The banner never appears with `--json`, with a task on the command line, or when output is piped. `/clear` resets the conversation, `/verbose` toggles detailed output, and `/exit` (or `/quit`, or end of input) leaves.
|
|
24
|
+
|
|
25
|
+
## Status
|
|
26
|
+
|
|
27
|
+
kidwit is early software. This is what has and has not been tested:
|
|
28
|
+
|
|
29
|
+
1. **Tested live:** the OpenAI-compatible adapter with DeepSeek (`deepseek-chat`) on macOS with Python 3.14. The 8 tasks in `evals/` all pass end to end.
|
|
30
|
+
2. **Not yet run against the real service:** the Anthropic adapter. It is covered by the offline tests, but nobody has run it against the live Anthropic API yet.
|
|
31
|
+
3. **Untested:** Windows. The command runner uses Python's default shell there (`cmd.exe`), and killing a timed-out command's child processes is only verified on macOS and Linux-style systems. Linux and Python 3.9 to 3.13 are also untested for now.
|
|
32
|
+
|
|
33
|
+
If you try it somewhere on this list, please open an issue with what happened.
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
Environment variables:
|
|
38
|
+
|
|
39
|
+
| Variable | Meaning |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `ANTHROPIC_API_KEY` | Key for the Anthropic Messages API. If set, Anthropic is the default provider. |
|
|
42
|
+
| `OPENAI_API_KEY` | Key for an OpenAI-compatible API. Optional for local servers. |
|
|
43
|
+
| `AGENT_PROVIDER` | `anthropic` or `openai`. |
|
|
44
|
+
| `AGENT_MODEL` | Model name. Defaults to `claude-sonnet-5-5` for Anthropic. Required for `openai`. |
|
|
45
|
+
| `AGENT_BASE_URL` | API base URL, for OpenRouter, Ollama, LM Studio, vLLM or a proxy. |
|
|
46
|
+
|
|
47
|
+
Provider choice follows this order: `--provider`, then `AGENT_PROVIDER`, then Anthropic if `ANTHROPIC_API_KEY` is set, then OpenAI-compatible.
|
|
48
|
+
|
|
49
|
+
Local model example:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
AGENT_BASE_URL=http://localhost:11434/v1 AGENT_MODEL=qwen2.5-coder kidwit "add a docstring to main()"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Flags:
|
|
56
|
+
|
|
57
|
+
| Flag | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `--root DIR` | Project directory. Default: the current directory. |
|
|
60
|
+
| `--provider anthropic\|openai` | Choose the API. |
|
|
61
|
+
| `--model M`, `--base-url URL` | Override `AGENT_MODEL` and `AGENT_BASE_URL`. |
|
|
62
|
+
| `--yes` | Never ask for approval. |
|
|
63
|
+
| `--read-only` | Offer only the read tools. Writes and commands are not available. |
|
|
64
|
+
| `--json` | Print JSON-lines events instead of text. |
|
|
65
|
+
| `--verbose` | Show live command output and full tool arguments. By default kidwit prints one short dim line per tool call and then the answer. |
|
|
66
|
+
| `--no-log` | Do not write `.agent/audit.jsonl`. |
|
|
67
|
+
| `--version` | Print the version. |
|
|
68
|
+
|
|
69
|
+
A task given on the command line runs once. The exit code is 0 on success and 1 on error, so it works in scripts and benchmarks.
|
|
70
|
+
|
|
71
|
+
## Reading the terminal
|
|
72
|
+
|
|
73
|
+
On a color terminal each kind of text looks different. Your prompt is a bold green `>>`. The model's replies have a magenta `▌` gutter. Tool calls are one dim line with the tool name in cyan. Permission requests start with a yellow `┌ permission needed` line, followed by the diff or command and a yellow `└ allow?` prompt. Errors are red. With `NO_COLOR`, piped output or `--json`, none of this styling is printed.
|
|
74
|
+
|
|
75
|
+
## Tools
|
|
76
|
+
|
|
77
|
+
The model gets six tools: `list_files`, `search`, `read_file`, `write_file`, `edit_file` and `run`. Inside a git repository, `list_files` and `search` honour `.gitignore`, so build output and similar files stay out of the way. Edits are exact-string replacement: a match that differs only in trailing whitespace is accepted, and when nothing matches the error names the closest line so the model can correct itself. `old_text` must match exactly once unless `replace_all` is set, and files with Windows line endings keep them.
|
|
78
|
+
|
|
79
|
+
If the project root has an `AGENTS.md` (or else a `CLAUDE.md`), its first 20,000 characters are added to the system prompt.
|
|
80
|
+
|
|
81
|
+
## Approval modes
|
|
82
|
+
|
|
83
|
+
1. `ask` (default). Before every `write_file`, `edit_file` and `run`, kidwit shows the diff or command and waits for `y`, `a`, `n`, or any other text. Other text is sent to the model as the reason for the denial. `a` (always) approves that kind of action for the rest of the session: file changes, or commands, but not both.
|
|
84
|
+
2. `auto` (`--yes`). No prompts, except for dangerous commands.
|
|
85
|
+
3. `read-only` (`--read-only`). The write tools are not offered to the model, and are refused if called anyway.
|
|
86
|
+
|
|
87
|
+
Some things are handled for you in every mode:
|
|
88
|
+
|
|
89
|
+
1. In `ask` mode, harmless commands run without a prompt: `ls`, `pwd`, `git status`, `git diff`, `git log`, `git show`, `git branch`, and test runners such as `python3 -m unittest`, `pytest`, `npm test`, `go test` and `cargo test`. They must use plain relative arguments with no shell operators. `cat`, `grep` and `head` are not on the list on purpose: the path jail does not cover `run`, so they could read files outside the project.
|
|
90
|
+
2. Dangerous commands always ask, even with `--yes` or after `a`: `rm -rf`, `sudo`, `curl ... | sh`, `git reset --hard`, `git clean`, `git push`, `chmod -R` and similar. With no one to answer (for example `--json` with closed stdin) they are denied.
|
|
91
|
+
3. `/undo` in an interactive session reverts the last file write or edit. It does not undo commands.
|
|
92
|
+
4. If the model's reply is cut off at the token limit, the half-written tool call is not run. The model is told to do less per call.
|
|
93
|
+
|
|
94
|
+
Every write, edit and command is appended to `<root>/.agent/audit.jsonl`. kidwit creates `.agent/.gitignore` so the log stays out of git.
|
|
95
|
+
|
|
96
|
+
## Limits you should know about
|
|
97
|
+
|
|
98
|
+
The path jail covers the file tools only. `read_file`, `write_file` and `edit_file` refuse paths outside the project root, including `../` and symlinks that lead out, and `list_files` and `search` skip such symlinks. `run` executes real shell commands, and those can reach anything your user account can reach. The protections in v1 are the approval prompt and `--read-only`. There is no OS-level sandbox, so review each command before you approve it, and avoid `--yes` on a machine or directory you care about.
|
|
99
|
+
|
|
100
|
+
Long sessions are kept within the model's context in two steps. First, old tool output is dropped. If the history is still too large (over about 200,000 characters), kidwit asks the model to summarize the older half of the conversation and continues from that summary.
|
|
101
|
+
|
|
102
|
+
Other deliberate omissions: no streaming of model responses (command output does stream), no sub-agents, no MCP, no saved sessions, and no UI beyond plain terminal text.
|
|
103
|
+
|
|
104
|
+
## Event protocol
|
|
105
|
+
|
|
106
|
+
With `--json`, kidwit writes one JSON object per line on stdout. Tasks and approval answers are read from stdin, one line each. End of input during an approval counts as "no". A UI can drive the engine through this protocol.
|
|
107
|
+
|
|
108
|
+
| `type` | Fields |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `info` | `message` |
|
|
111
|
+
| `text` | `text` |
|
|
112
|
+
| `tool` | `id`, `name`, `input` |
|
|
113
|
+
| `output` | `line` (live command output) |
|
|
114
|
+
| `approval` | `action` (`write`, `edit` or `run`), `target`, `detail` (diff or command) |
|
|
115
|
+
| `result` | `id`, `output`, `is_error` |
|
|
116
|
+
| `done` | `turns`, `input_tokens`, `output_tokens` |
|
|
117
|
+
| `error` | `message` |
|
|
118
|
+
|
|
119
|
+
## Tests
|
|
120
|
+
|
|
121
|
+
The tests use only `unittest` and need no API key. A fake local HTTP server stands in for both APIs.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
scripts/check.sh # everything below in one go
|
|
125
|
+
python3 -m unittest discover -s tests -t . # just the tests
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`scripts/check.sh` checks the Python version, compiles `src/kidwit.py`, confirms it imports only the standard library, runs the tests, and checks the size (`grep -vcE '^\s*(#.*)?$' src/kidwit.py`). Over 500 lines prints a warning, and over 700 fails.
|
|
129
|
+
|
|
130
|
+
## Evals
|
|
131
|
+
|
|
132
|
+
`evals/` holds 8 small tasks, each a tiny repo with a failing check: an off-by-one, a name error, implementing a function, a root cause in a second file, a rename across files, a CRLF file, a missing config default, and a new CLI flag. They show whether a change to kidwit makes it better or worse.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
python3 evals/run_evals.py --selftest # checks the tasks themselves, no model needed
|
|
136
|
+
python3 evals/run_evals.py # runs every task against your configured model
|
|
137
|
+
python3 evals/run_evals.py --task crlf-file -- --provider openai --model deepseek-chat
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The runner prints pass or fail, turns, tokens and time per task, and saves the results to `evals/results/`. Be aware that it runs the agent with `--yes`, so the model's commands run as you with no sandbox. Dangerous commands still need approval, and because the runner closes stdin they are denied. Run it only where that is acceptable.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT.
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Score kidwit on small coding tasks (standard library only).
|
|
3
|
+
|
|
4
|
+
python3 evals/run_evals.py --selftest check the tasks themselves, no model needed
|
|
5
|
+
python3 evals/run_evals.py run every task against the configured model
|
|
6
|
+
python3 evals/run_evals.py --task off-by-one --task crlf-file
|
|
7
|
+
python3 evals/run_evals.py -- --provider openai --model deepseek-chat --base-url URL
|
|
8
|
+
|
|
9
|
+
Arguments after `--` go to kidwit. The agent runs with --yes inside a throwaway directory,
|
|
10
|
+
but its commands are not sandboxed: they run as you. Dangerous commands still need approval,
|
|
11
|
+
and stdin is closed, so they are denied. Run this only on a machine where that is acceptable.
|
|
12
|
+
Results are printed and saved to evals/results/<timestamp>.json.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
HERE = Path(__file__).resolve().parent
|
|
25
|
+
sys.path.insert(0, str(HERE.parent))
|
|
26
|
+
from evals.tasks import TASKS # noqa: E402
|
|
27
|
+
|
|
28
|
+
AGENT = HERE.parent / "src" / "kidwit.py"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_files(root, files):
|
|
32
|
+
for name, content in files.items():
|
|
33
|
+
(root / name).parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
(root / name).write_bytes(content.encode("utf-8")) # bytes keep CRLF exactly
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify(root, task):
|
|
38
|
+
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} # a stale .pyc must not hide a fix
|
|
39
|
+
proc = subprocess.run(task["verify"], shell=True, cwd=root, capture_output=True, text=True, timeout=120, env=env)
|
|
40
|
+
return proc.returncode == 0, (proc.stdout + proc.stderr).strip()[-400:]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def selftest(tasks):
|
|
44
|
+
"""Every task must fail as shipped and pass once its reference solution is applied."""
|
|
45
|
+
bad = 0
|
|
46
|
+
for task in tasks:
|
|
47
|
+
root = Path(tempfile.mkdtemp(prefix="kidwit-selftest-"))
|
|
48
|
+
try:
|
|
49
|
+
write_files(root, task["files"])
|
|
50
|
+
before, _ = verify(root, task)
|
|
51
|
+
write_files(root, task["solution"])
|
|
52
|
+
after, detail = verify(root, task)
|
|
53
|
+
ok = (not before) and after
|
|
54
|
+
bad += not ok
|
|
55
|
+
print(f"{'ok ' if ok else 'FAIL'} {task['id']:<24} fails before: {not before} passes after: {after}")
|
|
56
|
+
if not ok and detail:
|
|
57
|
+
print(" " + detail.replace("\n", "\n "))
|
|
58
|
+
finally:
|
|
59
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
60
|
+
return 1 if bad else 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_task(task, agent_args, timeout, keep):
|
|
64
|
+
root = Path(tempfile.mkdtemp(prefix=f"kidwit-eval-{task['id']}-"))
|
|
65
|
+
write_files(root, task["files"])
|
|
66
|
+
cmd = [sys.executable, str(AGENT), "--root", str(root), "--yes", "--no-log", "--json",
|
|
67
|
+
*agent_args, task["prompt"]]
|
|
68
|
+
result = {"id": task["id"], "passed": False, "turns": 0, "input_tokens": 0, "output_tokens": 0,
|
|
69
|
+
"tool_calls": 0, "tool_errors": 0, "error": None, "seconds": 0.0,
|
|
70
|
+
"calls": [], "tool_error_messages": [], "approvals_asked": []}
|
|
71
|
+
start = time.time()
|
|
72
|
+
try:
|
|
73
|
+
proc = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout)
|
|
74
|
+
for line in proc.stdout.splitlines():
|
|
75
|
+
try:
|
|
76
|
+
event = json.loads(line)
|
|
77
|
+
except ValueError:
|
|
78
|
+
continue
|
|
79
|
+
if event["type"] == "tool":
|
|
80
|
+
result["tool_calls"] += 1
|
|
81
|
+
result["calls"].append(event["name"])
|
|
82
|
+
elif event["type"] == "approval": # in --yes mode only dangerous commands ask, and stdin is closed
|
|
83
|
+
result["approvals_asked"].append(f"{event['action']}: {event['detail'][:150]}")
|
|
84
|
+
elif event["type"] == "result" and event["is_error"]:
|
|
85
|
+
result["tool_errors"] += 1
|
|
86
|
+
result["tool_error_messages"].append(event["output"][:200])
|
|
87
|
+
elif event["type"] == "done":
|
|
88
|
+
result.update(turns=event["turns"], input_tokens=event["input_tokens"],
|
|
89
|
+
output_tokens=event["output_tokens"])
|
|
90
|
+
elif event["type"] == "error":
|
|
91
|
+
result["error"] = event["message"][:300]
|
|
92
|
+
if proc.returncode != 0 and not result["error"]:
|
|
93
|
+
result["error"] = (proc.stderr or "agent exited with an error")[-300:]
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
result["error"] = f"timed out after {timeout}s"
|
|
96
|
+
result["seconds"] = round(time.time() - start, 1)
|
|
97
|
+
result["passed"] = verify(root, task)[0]
|
|
98
|
+
if keep:
|
|
99
|
+
result["kept"] = str(root)
|
|
100
|
+
else:
|
|
101
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv=None):
|
|
106
|
+
ap = argparse.ArgumentParser(description="Run kidwit on small coding tasks.")
|
|
107
|
+
ap.add_argument("--task", action="append", help="task id to run (repeatable); default: all")
|
|
108
|
+
ap.add_argument("--selftest", action="store_true", help="check the tasks themselves; no model is called")
|
|
109
|
+
ap.add_argument("--timeout", type=int, default=600, help="seconds allowed per task")
|
|
110
|
+
ap.add_argument("--keep", action="store_true", help="keep the working directories")
|
|
111
|
+
ap.add_argument("--out", help="results file (default: evals/results/<timestamp>.json)")
|
|
112
|
+
args, agent_args = ap.parse_known_args(argv)
|
|
113
|
+
agent_args = [a for a in agent_args if a != "--"]
|
|
114
|
+
tasks = [t for t in TASKS if not args.task or t["id"] in args.task]
|
|
115
|
+
unknown = set(args.task or []) - {t["id"] for t in TASKS}
|
|
116
|
+
if unknown:
|
|
117
|
+
print("unknown task: " + ", ".join(sorted(unknown)), file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
if args.selftest:
|
|
120
|
+
return selftest(tasks)
|
|
121
|
+
results = []
|
|
122
|
+
for task in tasks:
|
|
123
|
+
r = run_task(task, agent_args, args.timeout, args.keep)
|
|
124
|
+
results.append(r)
|
|
125
|
+
note = f" {r['error']}" if r["error"] else ""
|
|
126
|
+
print(f"{'PASS' if r['passed'] else 'FAIL'} {r['id']:<24} {r['turns']:>2} turns "
|
|
127
|
+
f"{r['input_tokens']:>7} in {r['output_tokens']:>6} out {r['seconds']:>6.1f}s{note}", flush=True)
|
|
128
|
+
print(f" calls: {' > '.join(r['calls']) or '(none)'}", flush=True)
|
|
129
|
+
for asked in r["approvals_asked"]:
|
|
130
|
+
print(f" asked for approval: {asked}", flush=True)
|
|
131
|
+
for message in r["tool_error_messages"]:
|
|
132
|
+
print(f" tool error: {message}", flush=True)
|
|
133
|
+
passed = sum(r["passed"] for r in results)
|
|
134
|
+
total = {k: sum(r[k] for r in results) for k in ("turns", "input_tokens", "output_tokens", "tool_errors")}
|
|
135
|
+
print(f"\n{passed}/{len(results)} passed | {total['turns']} turns | {total['input_tokens']} in / "
|
|
136
|
+
f"{total['output_tokens']} out tokens | {total['tool_errors']} tool errors")
|
|
137
|
+
out = Path(args.out) if args.out else HERE / "results" / (time.strftime("%Y%m%d-%H%M%S") + ".json")
|
|
138
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
out.write_text(json.dumps({"agent_args": agent_args, "passed": passed, "total": len(results),
|
|
140
|
+
"results": results}, indent=2) + "\n")
|
|
141
|
+
print(f"saved {out}")
|
|
142
|
+
return 0 if passed == len(results) else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Eval tasks for kidwit. Each task is a tiny repo with a failing check.
|
|
2
|
+
|
|
3
|
+
A task has: id, prompt, files (the starting repo), verify (a shell command that must exit 0
|
|
4
|
+
when the task is solved) and solution (files that replace the starting ones, used only to
|
|
5
|
+
check that the task itself is sound: `python3 evals/run_evals.py --selftest`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
UNITTEST = "python3 -m unittest -q"
|
|
9
|
+
|
|
10
|
+
TASKS = [
|
|
11
|
+
{
|
|
12
|
+
"id": "off-by-one",
|
|
13
|
+
"prompt": "The tests fail. Find the bug and fix it.",
|
|
14
|
+
"files": {
|
|
15
|
+
"calc.py": "def add(a, b):\n return a - b\n",
|
|
16
|
+
"test_calc.py": ("import unittest\nfrom calc import add\n\n\nclass T(unittest.TestCase):\n"
|
|
17
|
+
" def test_add(self):\n self.assertEqual(add(2, 3), 5)\n"),
|
|
18
|
+
},
|
|
19
|
+
"verify": f"{UNITTEST} test_calc",
|
|
20
|
+
"solution": {"calc.py": "def add(a, b):\n return a + b\n"},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "name-error",
|
|
24
|
+
"prompt": "test_stats crashes. Fix the code, not the test.",
|
|
25
|
+
"files": {
|
|
26
|
+
"stats.py": "def mean(values):\n total = sum(values)\n return totl / len(values)\n",
|
|
27
|
+
"test_stats.py": ("import unittest\nfrom stats import mean\n\n\nclass T(unittest.TestCase):\n"
|
|
28
|
+
" def test_mean(self):\n self.assertEqual(mean([2, 4, 6]), 4)\n"),
|
|
29
|
+
},
|
|
30
|
+
"verify": f"{UNITTEST} test_stats",
|
|
31
|
+
"solution": {"stats.py": "def mean(values):\n total = sum(values)\n return total / len(values)\n"},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "implement-slugify",
|
|
35
|
+
"prompt": "Implement slugify() in slug.py so the tests in test_slug.py pass.",
|
|
36
|
+
"files": {
|
|
37
|
+
"slug.py": "def slugify(text):\n raise NotImplementedError\n",
|
|
38
|
+
"test_slug.py": ("import unittest\nfrom slug import slugify\n\n\nclass T(unittest.TestCase):\n"
|
|
39
|
+
" def test_basic(self):\n self.assertEqual(slugify('Hello World'), 'hello-world')\n"
|
|
40
|
+
" def test_punctuation(self):\n"
|
|
41
|
+
" self.assertEqual(slugify(' Hello, World! '), 'hello-world')\n"
|
|
42
|
+
" def test_runs(self):\n self.assertEqual(slugify('a -- b'), 'a-b')\n"),
|
|
43
|
+
},
|
|
44
|
+
"verify": f"{UNITTEST} test_slug",
|
|
45
|
+
"solution": {"slug.py": ("import re\n\n\ndef slugify(text):\n"
|
|
46
|
+
" return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')\n")},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "root-cause-two-files",
|
|
50
|
+
"prompt": "test_cart fails. Find the root cause and fix it.",
|
|
51
|
+
"files": {
|
|
52
|
+
"pricing.py": "def apply_discount(price, percent):\n return price * percent / 100\n",
|
|
53
|
+
"cart.py": ("from pricing import apply_discount\n\n\ndef total(prices, percent=0):\n"
|
|
54
|
+
" return sum(apply_discount(p, percent) if percent else p for p in prices)\n"),
|
|
55
|
+
"test_cart.py": ("import unittest\nfrom cart import total\n\n\nclass T(unittest.TestCase):\n"
|
|
56
|
+
" def test_plain(self):\n self.assertEqual(total([10, 20]), 30)\n"
|
|
57
|
+
" def test_discount(self):\n self.assertEqual(total([100, 50], 10), 135)\n"),
|
|
58
|
+
},
|
|
59
|
+
"verify": f"{UNITTEST} test_cart",
|
|
60
|
+
"solution": {"pricing.py": "def apply_discount(price, percent):\n return price * (100 - percent) / 100\n"},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "rename-everywhere",
|
|
64
|
+
"prompt": "Rename the function fetch to load, and update every place that uses it.",
|
|
65
|
+
"files": {
|
|
66
|
+
"utils.py": "def fetch(key):\n return {'a': 1, 'b': 2}[key]\n",
|
|
67
|
+
"one.py": "from utils import fetch\n\n\ndef first():\n return fetch('a')\n",
|
|
68
|
+
"two.py": "import utils\n\n\ndef second():\n return utils.fetch('b')\n",
|
|
69
|
+
"test_rename.py": ("import unittest\nimport one\nimport two\nfrom utils import load\n\n\n"
|
|
70
|
+
"class T(unittest.TestCase):\n def test_all(self):\n"
|
|
71
|
+
" self.assertEqual((one.first(), two.second(), load('a')), (1, 2, 1))\n"),
|
|
72
|
+
},
|
|
73
|
+
"verify": f"{UNITTEST} test_rename && ! grep -rw fetch --include=*.py utils.py one.py two.py",
|
|
74
|
+
"solution": {
|
|
75
|
+
"utils.py": "def load(key):\n return {'a': 1, 'b': 2}[key]\n",
|
|
76
|
+
"one.py": "from utils import load\n\n\ndef first():\n return load('a')\n",
|
|
77
|
+
"two.py": "import utils\n\n\ndef second():\n return utils.load('b')\n",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "crlf-file",
|
|
82
|
+
"prompt": "test_greet fails. Fix greet.py and keep its Windows line endings.",
|
|
83
|
+
"files": {
|
|
84
|
+
"greet.py": "def greet(name):\r\n return 'Hello ' + nme\r\n",
|
|
85
|
+
"test_greet.py": ("import unittest\nfrom greet import greet\n\n\nclass T(unittest.TestCase):\n"
|
|
86
|
+
" def test_greet(self):\n self.assertEqual(greet('Ana'), 'Hello Ana')\n"),
|
|
87
|
+
},
|
|
88
|
+
"verify": (f"{UNITTEST} test_greet && python3 -c \"d = open('greet.py', 'rb').read(); "
|
|
89
|
+
"assert b'\\r\\n' in d and d.count(b'\\n') == d.count(b'\\r\\n')\""),
|
|
90
|
+
"solution": {"greet.py": "def greet(name):\r\n return 'Hello ' + name\r\n"},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "default-config",
|
|
94
|
+
"prompt": "load_retries() crashes when config.json has no 'retries'. It should return 3 in that case.",
|
|
95
|
+
"files": {
|
|
96
|
+
"config.json": "{\"timeout\": 30}\n",
|
|
97
|
+
"app.py": ("import json\n\n\ndef load_retries(path='config.json'):\n"
|
|
98
|
+
" with open(path) as f:\n return json.load(f)['retries']\n"),
|
|
99
|
+
"test_app.py": ("import unittest\nfrom app import load_retries\n\n\nclass T(unittest.TestCase):\n"
|
|
100
|
+
" def test_default(self):\n self.assertEqual(load_retries(), 3)\n"),
|
|
101
|
+
},
|
|
102
|
+
"verify": f"{UNITTEST} test_app",
|
|
103
|
+
"solution": {"app.py": ("import json\n\n\ndef load_retries(path='config.json'):\n"
|
|
104
|
+
" with open(path) as f:\n return json.load(f).get('retries', 3)\n")},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "add-cli-flag",
|
|
108
|
+
"prompt": "Add a --shout flag to greet_cli.py that prints the greeting in upper case. Keep the default unchanged.",
|
|
109
|
+
"files": {
|
|
110
|
+
"greet_cli.py": ("import sys\n\nname = sys.argv[1] if len(sys.argv) > 1 else 'world'\n"
|
|
111
|
+
"print(f'Hello, {name}!')\n"),
|
|
112
|
+
"test_cli.py": ("import subprocess\nimport sys\nimport unittest\n\n\n"
|
|
113
|
+
"def run(*args):\n return subprocess.run([sys.executable, 'greet_cli.py', *args],\n"
|
|
114
|
+
" capture_output=True, text=True).stdout.strip()\n\n\n"
|
|
115
|
+
"class T(unittest.TestCase):\n def test_default(self):\n"
|
|
116
|
+
" self.assertEqual(run('Ana'), 'Hello, Ana!')\n"
|
|
117
|
+
" def test_shout(self):\n self.assertEqual(run('Ana', '--shout'), 'HELLO, ANA!')\n"
|
|
118
|
+
" self.assertEqual(run('--shout', 'Ana'), 'HELLO, ANA!')\n"),
|
|
119
|
+
},
|
|
120
|
+
"verify": f"{UNITTEST} test_cli",
|
|
121
|
+
"solution": {"greet_cli.py": ("import sys\n\nargs = [a for a in sys.argv[1:] if a != '--shout']\n"
|
|
122
|
+
"name = args[0] if args else 'world'\n"
|
|
123
|
+
"text = f'Hello, {name}!'\n"
|
|
124
|
+
"print(text.upper() if '--shout' in sys.argv else text)\n")},
|
|
125
|
+
},
|
|
126
|
+
]
|