nanoPyCodeAgent 0.3.0__tar.gz → 0.4.0__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.
Files changed (45) hide show
  1. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/release/SKILL.md +8 -2
  2. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/release/scripts/verify-release.sh +6 -1
  3. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/PKG-INFO +1 -1
  4. nanopycodeagent-0.4.0/docs/changelogs/0.4.x.md +34 -0
  5. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/README.md +5 -0
  6. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.2.x.md +1 -1
  7. nanopycodeagent-0.4.0/docs/dev_notes/en/0.4.x.md +68 -0
  8. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.2.x.md +1 -1
  9. nanopycodeagent-0.4.0/docs/dev_notes/zh-CN/0.4.x.md +49 -0
  10. nanopycodeagent-0.4.0/src/nanopycodeagent/agent.py +116 -0
  11. nanopycodeagent-0.4.0/src/nanopycodeagent/bash_tool.py +77 -0
  12. nanopycodeagent-0.4.0/src/nanopycodeagent/settings.py +35 -0
  13. nanopycodeagent-0.4.0/src/nanopycodeagent/terminal.py +29 -0
  14. nanopycodeagent-0.4.0/tests/conftest.py +31 -0
  15. nanopycodeagent-0.4.0/tests/helpers.py +107 -0
  16. nanopycodeagent-0.4.0/tests/test_agent.py +193 -0
  17. nanopycodeagent-0.4.0/tests/test_bash_tool.py +62 -0
  18. nanopycodeagent-0.4.0/tests/test_settings.py +102 -0
  19. nanopycodeagent-0.4.0/tests/test_terminal.py +39 -0
  20. nanopycodeagent-0.3.0/src/nanopycodeagent/agent.py +0 -181
  21. nanopycodeagent-0.3.0/tests/test_agent.py +0 -512
  22. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/land-pr/SKILL.md +0 -0
  23. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.github/workflows/release.yml +0 -0
  24. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.gitignore +0 -0
  25. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.python-version +0 -0
  26. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/AGENTS.md +0 -0
  27. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/CLAUDE.md +0 -0
  28. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/LICENSE +0 -0
  29. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/README.md +0 -0
  30. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/README.zh-CN.md +0 -0
  31. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/RELEASING.md +0 -0
  32. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.1.x.md +0 -0
  33. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.2.x.md +0 -0
  34. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.3.x.md +0 -0
  35. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/README.md +0 -0
  36. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.1.x.md +0 -0
  37. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.3.x.md +0 -0
  38. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
  39. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.3.x.md +0 -0
  40. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
  41. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
  42. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
  43. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/pyproject.toml +0 -0
  44. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/src/nanopycodeagent/__init__.py +0 -0
  45. {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/uv.lock +0 -0
@@ -50,12 +50,18 @@ Read the entries under `## [Unreleased]` and propose the next semver `X.Y.Z`:
50
50
  **0.x caveat:** on `0.y.z` a minor may still contain breaking changes; treat the
51
51
  proposal as a suggestion. The maintainer makes the final call at the gate (step 5).
52
52
 
53
- ### 4. Cut the changelog
53
+ ### 4. Cut the changelog and date the dev notes
54
54
  In `<series>`:
55
55
  - rename `## [Unreleased]` to `## [X.Y.Z] - <today YYYY-MM-DD>`, keeping its entries;
56
56
  - add a fresh empty `## [Unreleased]` section above it.
57
57
 
58
- Touch no other file — dev notes and README were synced per-PR by `land-pr`.
58
+ In `docs/dev_notes/zh-CN/<series>.md` and `docs/dev_notes/en/<series>.md`,
59
+ replace the `YYYY.MM.DD` placeholder in the `## X.Y.Z - YYYY.MM.DD` heading
60
+ with today's date (dot-separated). Skip if the dev notes have no section for
61
+ this version.
62
+
63
+ Touch no other file — dev notes content and README were synced per-PR by
64
+ `land-pr`.
59
65
 
60
66
  ### 5. ⛔ Confirmation gate (mandatory)
61
67
  Present to the maintainer: the proposed **version** `X.Y.Z` and the **changelog
@@ -60,9 +60,14 @@ grep -q '\.tar\.gz$' <<<"$assets" || fail "Release ${tag} missing an sdist (.tar
60
60
  printf 'OK: GitHub Release %s has wheel + sdist\n' "$tag"
61
61
 
62
62
  # 3. uvx smoke test (official index, with retries) ------------------------
63
+ # --default-index (not --index): --index only *adds* an index, and uv's
64
+ # first-index strategy would stop at a locally configured mirror that has the
65
+ # package but not the new version yet. --refresh: uv's cached simple-index
66
+ # metadata can be stale right after a publish. </dev/null: the entry point
67
+ # reads stdin; EOF makes it exit immediately instead of hanging interactively.
63
68
  log "Smoke-testing uvx ${PACKAGE}@${version}"
64
69
  deadline=$(( $(date +%s) + POLL_TIMEOUT ))
65
- until uvx --index "$PYPI_INDEX" --from "${PACKAGE}@${version}" "$PACKAGE" >/dev/null 2>&1; do
70
+ until uvx --refresh --default-index "$PYPI_INDEX" --from "${PACKAGE}@${version}" "$PACKAGE" >/dev/null 2>&1 </dev/null; do
66
71
  (( $(date +%s) >= deadline )) && fail "uvx smoke test failed for ${PACKAGE}@${version} after ${POLL_TIMEOUT}s"
67
72
  printf 'uvx not ready (index propagation?); retrying in %ss...\n' "$POLL_INTERVAL"; sleep "$POLL_INTERVAL"
68
73
  done
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanoPyCodeAgent
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A nano code agent built from scratch in pure Python.
5
5
  Project-URL: Homepage, https://github.com/minixalpha/nanoPyCodeAgent
6
6
  Project-URL: Repository, https://github.com/minixalpha/nanoPyCodeAgent
@@ -0,0 +1,34 @@
1
+ # Changelog — 0.4.x
2
+
3
+ All notable changes in the **0.4.x** release series are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.4.0] - 2026-07-14
8
+
9
+ ### Added
10
+ - A `bash` tool with a tool-use loop: the model can now run shell commands and
11
+ read their output. Each command and its result are echoed to the terminal as
12
+ they happen; long output is truncated and hung commands are killed after a
13
+ timeout.
14
+ - Tool commands and output are shaded with a background color so they read
15
+ apart from the agent's own text (skipped when stdout is not a terminal or
16
+ `NO_COLOR` is set).
17
+
18
+ ### Changed
19
+ - The agent loop now handles only the happy path: anything unexpected — a
20
+ network error, a Ctrl-C mid-turn, a malformed config file — crashes the
21
+ session, and restarting it is the recovery. The previous hardened variant is
22
+ preserved at the `hardened-agent-loop` tag.
23
+
24
+ <!--
25
+ When cutting a release, copy the relevant items from [Unreleased] into a new
26
+ version section above it, e.g.:
27
+
28
+ ## [0.4.0] - YYYY-MM-DD
29
+
30
+ ### Added
31
+ ### Changed
32
+ ### Fixed
33
+ ### Removed
34
+ -->
@@ -11,3 +11,8 @@ Notes are bilingual, split by language:
11
11
  These are free-form notes — insights, takeaways, and design decisions gathered
12
12
  while developing each series, with no fixed structure. User-facing changes live
13
13
  in [`../changelogs/`](../changelogs/).
14
+
15
+ The date in a `## X.Y.Z - YYYY.MM.DD` section heading is the **release date**
16
+ of that version (the same date as the git tag and the changelog entry). While
17
+ the version is unreleased, keep the literal `YYYY.MM.DD` placeholder — it is
18
+ filled in by the release process.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Generated from the Chinese source [`../zh-CN/0.2.x.md`](../zh-CN/0.2.x.md). Do not edit by hand.
4
4
 
5
- ## 0.2.0 - 2026.06.29
5
+ ## 0.2.0 - 2026.07.09
6
6
 
7
7
  This release implements a minimally functional chatbot that holds a multi-turn conversation with the LLM through the [Messages](https://platform.claude.com/docs/en/api/python/messages/create) API.
8
8
 
@@ -0,0 +1,68 @@
1
+ # Development Notes — 0.4.x
2
+
3
+ > Generated from the Chinese source [`../zh-CN/0.4.x.md`](../zh-CN/0.4.x.md). Do not edit by hand.
4
+
5
+ ## 0.4.0 - 2026.07.14
6
+
7
+ ### The bash tool
8
+
9
+ This version adds tool use, starting with a single bash tool. The bash tool can do almost anything — powerful yet simple — and it makes a good base to gradually split other tools out of, which will make it easy to compare having more tools against having just the one bash tool.
10
+
11
+ Also, with no test environment for the other systems, Linux is the only platform that can be fully tested. Mac's bash is probably fairly similar to Linux's?
12
+
13
+ ### Background color for tool output
14
+
15
+ I tried giving tool output a background color so it stands apart from the agent's text output, similar to the pi code agent's implementation.
16
+
17
+ Along the way I learned how changing the terminal background color actually works:
18
+
19
+ ```
20
+ 1. Terminal colors are "in-band control": styling is mixed into the data stream
21
+
22
+ Between a program and the terminal there is only one byte stream — no separate
23
+ styling channel. To change how things look, you insert control sequences
24
+ starting with ESC (\x1b) into the stream. The \x1b[<params>m family is called
25
+ SGR (Select Graphic Rendition); it changes the terminal's current "brush"
26
+ state — every character printed afterwards is written into its screen cell
27
+ with the current brush attributes, until the brush changes again. The byte
28
+ stream for each output line is really:
29
+
30
+ \x1b[48;5;236m [bash]$ ls ... \x1b[K \x1b[0m \n
31
+ └─ set bg color ─┘ └── text ──┘ └erase to EOL┘ └reset┘
32
+
33
+ 2. 48;5;236: dark gray from the 256-color palette
34
+
35
+ 48;5;N means "background color = entry N of the 256-color palette". The
36
+ palette has three segments: 0–15 basic colors, 16–231 the color cube, 232–255
37
+ the grayscale ramp. Entry 236 is a dark gray (about #303030). Picking the
38
+ grayscale ramp over the basic background colors 41–47 is deliberate: basic
39
+ colors get remapped by the user's terminal theme and can be harsh on the eyes,
40
+ while grays stay "just a bit lighter than the base color" under any theme —
41
+ exactly right for visual layering.
42
+
43
+ 3. \x1b[K makes the color block fill the whole line
44
+
45
+ Background color only applies to cells where characters were actually
46
+ printed; the blank space at the end of a line keeps the default background,
47
+ so the block hugs the text with a ragged right edge. \x1b[K (Erase in Line)
48
+ erases from the cursor to the end of the line, and mainstream terminals
49
+ implement BCE (Background Color Erase): erased cells are filled with the
50
+ current background color. So emitting \x1b[K before resetting the brush
51
+ amounts to "painting the rest of this line dark gray" — which is why the
52
+ blocks in the screenshot are clean rectangles.
53
+ ```
54
+
55
+ ### From production hardening back to the core
56
+
57
+ The implementation had come to feel somewhat complex: the edge-case handling was crowding out the core. Those edge cases matter in a production-grade application, but for now, as long as normal use is unaffected, they hardly seem necessary.
58
+
59
+ With that in mind, I stripped out the production hardening wholesale and kept only the core flow. The design principle moves from "repair history and survive on error" to "crash on the unexpected, restart to recover":
60
+
61
+ - **Agent loop**: the per-moment Ctrl-C history repair, the API-error rollback (`ran_tools` / `turn_start`), patching `tool_result`s for truncated replies, and the per-turn request budget (`MAX_REQUESTS_PER_TURN`) are all deleted; the loop handles only the happy path.
62
+ - **bash tool**: switched to `subprocess.run`; temp-file capture, process-group kill, capped per-stream reads, and NUL protection are all removed. Known trades: a background child (`some_server &`) blocks until the timeout; the timeout kills bash itself, with no guarantee of killing the whole process tree; and text mode translates `\r` in output to `\n`.
63
+ - **terminal**: only this version's new tool-output background shading remains; control-character stripping and the stdout encoding fallback are removed.
64
+ - **settings**: happy path only — a missing file is normal, a corrupt file simply raises.
65
+
66
+ What stays is what genuinely belongs to agent design itself: the tool_use / tool_result pairing protocol, output truncation (protecting the context window), the command timeout, and `is_error` meaning only tool failure (the exit code goes into the result text, so the model can tell a negative answer from a failed execution).
67
+
68
+ The source drops from 544 lines to 262, with agent.py going from 250 to 116 — the main loop now fits on one screen. How each edge case was discovered is recorded in the git history; the hardened version before the trim is preserved under the `hardened-agent-loop` tag.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.2.x.md`](../en/0.2.x.md) 由其生成。
4
4
 
5
- ## 0.2.0 - 2026.06.29
5
+ ## 0.2.0 - 2026.07.09
6
6
 
7
7
  这个版本实现了一个最小功能的 chatbot,使用 [Message](https://platform.claude.com/docs/en/api/python/messages/create) 接口,完成与 LLM 的多轮对话。
8
8
 
@@ -0,0 +1,49 @@
1
+ # 开发笔记 — 0.4.x
2
+
3
+ > 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.4.x.md`](../en/0.4.x.md) 由其生成。
4
+
5
+ ## 0.4.0 - 2026.07.14
6
+
7
+ ### bash 工具
8
+
9
+ 此版本增加 tool use 功能,首先增加一个 bash 工具,bash 工具几乎可以做任何事,功能强大又简单,可以以此为基础,再逐步拆分出其他工具,方便对比增加其他工具后与单一 bash 工具的区别。
10
+
11
+ 另外,由于其他系统无测试环境,能完全测试的只有 Linux。Mac 与 Linux 的 bash 可能比较类似?
12
+
13
+ ### 工具输出背景色
14
+
15
+ 尝试给工具输出增加背景颜色,这样方便与 agent 的文本输出有区分,类似 pi code agent 的实现。
16
+
17
+ 另外,学习了终端改背景色的原理:
18
+
19
+ ```
20
+ 1. 终端颜色是"带内控制":样式混在数据流里
21
+
22
+ 程序和终端之间只有一条字节流,没有独立的样式通道。想改变显示效果,就往流里插入以 ESC(\x1b)开头的控制序列。\x1b[<参数>m 这一族叫 SGR(Select Graphic Rendition),它修改终端当前的"画笔"状态——之后打印的每个字符都带着当前画笔属性写进屏幕单元格,直到画笔再次被改。每行输出的字节流实际是:
23
+
24
+ \x1b[48;5;236m [bash]$ ls ... \x1b[K \x1b[0m \n
25
+ └─ 设背景色 ─┘ └── 文字 ──┘ └擦到行尾┘ └重置┘
26
+
27
+ 2. 48;5;236:256 色调色板里的深灰
28
+
29
+ 48;5;N 表示"背景色 = 256 色调色板第 N 号"。调色板分三段:0–15 基本色、16–231 色立方、232–255 灰阶梯。236 号是深灰(约 #303030)。选灰阶而不选 41–47 基本背景色,是因为基本色会被用户终端主题重映射、容易刺眼;灰阶在各种主题下都只是"比底色浅一点",做层次区分正合适。
30
+
31
+ 3. \x1b[K 让色块铺满整行
32
+
33
+ 背景色只作用于实际打印了字符的单元格,行尾空白仍是默认背景,色块会紧贴文字、右缘参差。\x1b[K(Erase in Line)从光标擦到行尾,而主流终端实现了 BCE(Background Color Erase):擦除时用当前背景色填充。所以在重置画笔前发一个 \x1b[K,等于"用深灰刷满本行剩余部分"——这就是截图里色块是整齐矩形的原因。
34
+ ```
35
+
36
+ ### 从生产级加固回到核心
37
+
38
+ 我觉得目前的实现,似乎有些复杂,边界条件的处理反而让核心内容不够明显。这些边界条件在生产级应用时有用,但目前如果不影响正常使用,似乎并无必要。
39
+
40
+ 基于这个想法,把生产级加固整体剥离,只保留核心流程。设计原则从「出错时修复历史并存活」改为「意外即崩溃,重启即恢复」:
41
+
42
+ - **agent 循环**:Ctrl-C 分时刻的历史修复、API 错误回滚(`ran_tools` / `turn_start`)、截断回复补 `tool_result`、每轮请求预算(`MAX_REQUESTS_PER_TURN`)全部删除,循环只处理快乐路径。
43
+ - **bash 工具**:改用 `subprocess.run`,临时文件捕获、进程组击杀、分流限量读、NUL 防护全部移除。已知取舍:后台子进程(`some_server &`)会阻塞到超时;超时只杀 bash 本体,不保证杀掉整棵进程树;文本模式会把输出中的 `\r` 转成 `\n`。
44
+ - **terminal**:只保留本版新增的工具输出背景色,控制字符剥离和 stdout 编码兜底移除。
45
+ - **settings**:只保留快乐路径 —— 文件缺失属正常,文件损坏直接抛异常。
46
+
47
+ 保留下来的是真正属于 agent 设计本身的部分:tool_use / tool_result 的配对协议、输出截断(保护上下文窗口)、命令超时、`is_error` 只表示工具失败(退出码写进正文,让模型区分「否定答案」和「执行失败」)。
48
+
49
+ 源码从 544 行降到 262 行,其中 agent.py 从 250 行降到 116 行,主循环可以一屏读完。这些边界条件的发现过程记录在 git 历史里;精简前的加固版打了 tag `hardened-agent-loop` 留档。
@@ -0,0 +1,116 @@
1
+ """A minimal agent loop built on the Anthropic Python SDK.
2
+
3
+ Run the program, type a message, and Agent replies. The full conversation is
4
+ kept in memory so each turn has context. The model can call a single ``bash``
5
+ tool to run shell commands; every command and its output are echoed to the
6
+ terminal as they happen. Type ``/exit`` to quit.
7
+
8
+ The loop handles only the happy path: anything unexpected — a network error,
9
+ a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
10
+ That trade keeps the core flow readable; the hardened variant it replaced is
11
+ preserved at the ``hardened-agent-loop`` tag.
12
+ """
13
+
14
+ import os
15
+
16
+ import anthropic
17
+ from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
18
+
19
+ from .bash_tool import BASH_TOOL, run_bash
20
+ from .settings import load_settings_env
21
+ from .terminal import print_tool
22
+
23
+ # The model used when ANTHROPIC_MODEL is set in neither the environment nor
24
+ # the config file.
25
+ DEFAULT_MODEL = "claude-sonnet-4-6"
26
+ MAX_TOKENS = 8192
27
+ SYSTEM_PROMPT = (
28
+ "You are nanoPyCodeAgent, a concise and helpful coding assistant. "
29
+ "Use the bash tool to inspect files, run code, and complete tasks that "
30
+ "need real command output instead of guessing."
31
+ )
32
+
33
+
34
+ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
35
+ """Execute one ``tool_use`` block, echoing the command and its output."""
36
+ command = block.input["command"]
37
+ print_tool(f"[bash]$ {command}")
38
+ output, is_error = run_bash(command)
39
+ print_tool(output)
40
+ return {
41
+ "type": "tool_result",
42
+ "tool_use_id": block.id,
43
+ "content": output,
44
+ "is_error": is_error,
45
+ }
46
+
47
+
48
+ def run() -> None:
49
+ """Start the read → ask → answer loop until the user types ``/exit``.
50
+
51
+ A reply may include bash tool calls; they are executed and their results
52
+ fed back to the model until it finishes the turn without tool use.
53
+ """
54
+ # Fill any unset ANTHROPIC_* keys from the config file (environment
55
+ # variables take precedence), then let the SDK read credentials from
56
+ # os.environ.
57
+ load_settings_env()
58
+ client = anthropic.Anthropic()
59
+ if client.api_key is None and client.auth_token is None:
60
+ print(
61
+ "No API credentials found. Set the ANTHROPIC_API_KEY environment variable."
62
+ )
63
+ print(
64
+ "If you use a third-party / proxy service, also set ANTHROPIC_BASE_URL "
65
+ "to point at its endpoint."
66
+ )
67
+ return
68
+
69
+ model = os.environ.get("ANTHROPIC_MODEL", "").strip() or DEFAULT_MODEL
70
+ print(f"nanoPyCodeAgent — model {model} (set ANTHROPIC_MODEL to override).")
71
+ print("Type a message to chat, or /exit to quit.")
72
+
73
+ messages: list[MessageParam] = []
74
+ while True:
75
+ try:
76
+ user_input = input("\nYou> ").strip()
77
+ except (EOFError, KeyboardInterrupt):
78
+ print()
79
+ break
80
+ if not user_input:
81
+ continue
82
+ if user_input == "/exit":
83
+ break
84
+
85
+ messages.append({"role": "user", "content": user_input})
86
+ # The model may ask to run tools; keep streaming replies and feeding
87
+ # results back until it finishes a reply without tool calls.
88
+ while True:
89
+ print("\nAgent> ", end="", flush=True)
90
+ # Stream the reply so text shows up as it is generated, then grab
91
+ # the accumulated message for the conversation history.
92
+ with client.messages.stream(
93
+ model=model,
94
+ max_tokens=MAX_TOKENS,
95
+ system=SYSTEM_PROMPT,
96
+ tools=[BASH_TOOL],
97
+ messages=messages,
98
+ ) as stream:
99
+ for text in stream.text_stream:
100
+ print(text, end="", flush=True)
101
+ message = stream.get_final_message()
102
+ print()
103
+
104
+ messages.append({"role": "assistant", "content": message.content})
105
+ if message.stop_reason != "tool_use":
106
+ break
107
+ # Every tool_use block needs a matching tool_result in the next
108
+ # user message, or the API rejects the request.
109
+ results = [
110
+ _run_one_tool(block)
111
+ for block in message.content
112
+ if block.type == "tool_use"
113
+ ]
114
+ messages.append({"role": "user", "content": results})
115
+
116
+ print("Bye!")
@@ -0,0 +1,77 @@
1
+ """The ``bash`` tool: its definition and its execution.
2
+
3
+ Each call runs a command with ``bash -c`` in a fresh shell and returns one
4
+ result string: stdout, then labelled stderr, then the exit code when non-zero.
5
+ """
6
+
7
+ import subprocess
8
+
9
+ from anthropic.types import ToolParam
10
+
11
+ # Guardrails for the bash tool: a hung command is killed after this many
12
+ # seconds, and results are truncated so one command cannot flood the context.
13
+ BASH_TIMEOUT_SECONDS = 120
14
+ MAX_TOOL_OUTPUT_CHARS = 20_000
15
+
16
+ BASH_TOOL: ToolParam = {
17
+ "name": "bash",
18
+ "description": (
19
+ "Run a command with `bash -c` on the user's machine and return its "
20
+ "output: stdout, then stderr (labelled), then the exit code when "
21
+ "non-zero. Each call is a fresh shell in the agent's working "
22
+ "directory, so environment variables and `cd` do not persist between "
23
+ "calls. Long output is truncated and long-running commands are killed "
24
+ "after a timeout."
25
+ ),
26
+ "input_schema": {
27
+ "type": "object",
28
+ "properties": {
29
+ "command": {
30
+ "type": "string",
31
+ "description": "The bash command to run.",
32
+ }
33
+ },
34
+ "required": ["command"],
35
+ },
36
+ }
37
+
38
+
39
+ def run_bash(command: str) -> tuple[str, bool]:
40
+ """Run ``command`` with ``bash -c`` and return ``(output, is_error)``.
41
+
42
+ ``is_error`` is true only when the tool itself failed — here, a timeout.
43
+ A command that ran to completion is a successful tool call whatever its
44
+ exit code: the code is reported in the output text, where the model can
45
+ tell a negative answer (``grep`` finding nothing) from a failure.
46
+ Non-UTF-8 output bytes are replaced rather than raising, and stdin is
47
+ ``/dev/null`` so a command that prompts sees EOF instead of eating the
48
+ user's keystrokes.
49
+
50
+ Known trades for simplicity: a background child inherits the output
51
+ pipes, so ``some_server &`` blocks until the timeout; the timeout kills
52
+ bash itself, not necessarily everything it forked; and text mode
53
+ translates ``\\r`` in output to ``\\n`` (universal newlines).
54
+ """
55
+ try:
56
+ process = subprocess.run(
57
+ ["bash", "-c", command],
58
+ capture_output=True,
59
+ text=True,
60
+ errors="replace",
61
+ stdin=subprocess.DEVNULL,
62
+ timeout=BASH_TIMEOUT_SECONDS,
63
+ )
64
+ except subprocess.TimeoutExpired:
65
+ return f"[command timed out after {BASH_TIMEOUT_SECONDS} seconds]", True
66
+
67
+ parts = []
68
+ if stdout := process.stdout.rstrip("\n"):
69
+ parts.append(stdout)
70
+ if stderr := process.stderr.rstrip("\n"):
71
+ parts.append("[stderr]\n" + stderr)
72
+ if process.returncode != 0:
73
+ parts.append(f"[exit code: {process.returncode}]")
74
+ output = "\n".join(parts) or "(no output)"
75
+ if len(output) > MAX_TOOL_OUTPUT_CHARS:
76
+ output = output[:MAX_TOOL_OUTPUT_CHARS] + "\n[... output truncated ...]"
77
+ return output, False
@@ -0,0 +1,35 @@
1
+ """User-level configuration for nanoPyCodeAgent.
2
+
3
+ The config file at ``~/.nanoPyCodeAgent/settings.json`` may carry an ``env``
4
+ mapping that supplies ``ANTHROPIC_*`` values for keys that are not already set
5
+ in the environment (environment variables win).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ SETTINGS_PATH = Path.home() / ".nanoPyCodeAgent" / "settings.json"
13
+
14
+
15
+ def load_settings_env(path: Path | None = None) -> None:
16
+ """Apply the ``env`` mapping from the config file into ``os.environ``.
17
+
18
+ Only ``ANTHROPIC_*`` keys that are not already present are set, so
19
+ environment variables take precedence over the config file and unrelated
20
+ variables are never injected. Empty, whitespace-only, and non-string
21
+ values are skipped (the documented example ships the keys as empty-string
22
+ placeholders). A missing file is normal; a malformed one raises — fix or
23
+ delete it.
24
+ """
25
+ if path is None:
26
+ path = SETTINGS_PATH
27
+ try:
28
+ raw = path.read_text(encoding="utf-8")
29
+ except FileNotFoundError:
30
+ return
31
+ for key, value in json.loads(raw).get("env", {}).items():
32
+ if not key.startswith("ANTHROPIC_"):
33
+ continue
34
+ if isinstance(value, str) and value.strip():
35
+ os.environ.setdefault(key, value.strip())
@@ -0,0 +1,29 @@
1
+ """Background shading for tool activity in the terminal."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ # Dark gray from the 256-color palette, so echoed commands and their output
7
+ # read apart from the user's prompts and the model's prose.
8
+ _TOOL_BG = "\x1b[48;5;236m"
9
+ _RESET = "\x1b[0m"
10
+
11
+
12
+ def _use_color() -> bool:
13
+ """Whether to emit ANSI colors: a real terminal, and NO_COLOR unset."""
14
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
15
+
16
+
17
+ def print_tool(text: str) -> None:
18
+ """Print tool activity (an echoed command or its output) shaded.
19
+
20
+ Each line carries its own set-background / erase-to-EOL / reset, so the
21
+ shading spans the full terminal width and never leaks past a line
22
+ boundary. Escape sequences inside ``text`` are printed as-is and may
23
+ disrupt the shading — a cosmetic trade for staying simple.
24
+ """
25
+ if _use_color():
26
+ text = "\n".join(
27
+ f"{_TOOL_BG}{line}\x1b[K{_RESET}" for line in text.split("\n")
28
+ )
29
+ print(text, flush=True)
@@ -0,0 +1,31 @@
1
+ """Shared fixtures for the test suite."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from nanopycodeagent import settings
8
+
9
+ _MANAGED_ENV = ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL")
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def _isolate_config(monkeypatch, tmp_path):
14
+ """Keep every test off the real config file and ambient ANTHROPIC_* vars.
15
+
16
+ ``settings.SETTINGS_PATH`` is redirected into an empty temp dir (so tests
17
+ never read the developer's ~/.nanoPyCodeAgent/settings.json), and the
18
+ managed env vars are cleared up front and restored afterwards —
19
+ ``load_settings_env`` writes to ``os.environ`` directly, which monkeypatch
20
+ would not roll back on its own.
21
+ """
22
+ monkeypatch.setattr(settings, "SETTINGS_PATH", tmp_path / "settings.json")
23
+ saved = {key: os.environ.pop(key, None) for key in _MANAGED_ENV}
24
+ try:
25
+ yield
26
+ finally:
27
+ for key, value in saved.items():
28
+ if value is None:
29
+ os.environ.pop(key, None)
30
+ else:
31
+ os.environ[key] = value
@@ -0,0 +1,107 @@
1
+ """Shared fakes and helpers for driving ``run()`` without the network.
2
+
3
+ Every Anthropic API access is mocked, so tests never hit the network and never
4
+ spend tokens: ``FakeMessages`` replays scripted replies and records each call,
5
+ and ``patch_client_and_input`` wires a fake client and a scripted ``input()``
6
+ into the loop.
7
+ """
8
+
9
+ import json
10
+ from types import SimpleNamespace
11
+
12
+ import anthropic
13
+
14
+
15
+ def text_block(text):
16
+ """A minimal stand-in for an SDK text content block."""
17
+ return SimpleNamespace(type="text", text=text)
18
+
19
+
20
+ def tool_use_block(block_id, command):
21
+ """A minimal stand-in for an SDK tool_use content block."""
22
+ return SimpleNamespace(
23
+ type="tool_use", id=block_id, name="bash", input={"command": command}
24
+ )
25
+
26
+
27
+ def write_settings(path, env):
28
+ """Write a ``settings.json`` with the given ``env`` mapping."""
29
+ path.write_text(json.dumps({"env": env}), encoding="utf-8")
30
+
31
+
32
+ class FakeStream:
33
+ """Mimics the SDK's ``MessageStream`` context manager for scripted replies.
34
+
35
+ ``stop_reason`` is ``"tool_use"`` when the scripted reply asks to run
36
+ tools.
37
+ """
38
+
39
+ def __init__(self, content, stop_reason="end_turn"):
40
+ self._content = content
41
+ self._stop_reason = stop_reason
42
+
43
+ def __enter__(self):
44
+ return self
45
+
46
+ def __exit__(self, *exc_info):
47
+ return False
48
+
49
+ @property
50
+ def text_stream(self):
51
+ def _gen():
52
+ # Yield each text block's text as a single chunk.
53
+ for b in self._content:
54
+ if b.type == "text":
55
+ yield b.text
56
+
57
+ return _gen()
58
+
59
+ def get_final_message(self):
60
+ return SimpleNamespace(content=self._content, stop_reason=self._stop_reason)
61
+
62
+
63
+ class FakeMessages:
64
+ """Records each ``stream()`` call and replays a scripted response."""
65
+
66
+ def __init__(self, script):
67
+ # Each script entry is either a list of content blocks to stream as
68
+ # the message's ``content``, or a ``FakeStream`` — e.g. a tool_use
69
+ # turn.
70
+ self._script = list(script)
71
+ self.calls = [] # snapshot of the ``messages`` list at each call
72
+ self.kwargs = [] # full kwargs passed to each stream() call
73
+
74
+ def stream(self, **kwargs):
75
+ self.calls.append(list(kwargs["messages"])) # freeze history at call time
76
+ self.kwargs.append(kwargs)
77
+ item = self._script.pop(0)
78
+ if isinstance(item, FakeStream):
79
+ return item
80
+ return FakeStream(item)
81
+
82
+
83
+ class FakeClient:
84
+ def __init__(self, messages, *, api_key="sk-test", auth_token=None):
85
+ self.messages = messages
86
+ self.api_key = api_key
87
+ self.auth_token = auth_token
88
+
89
+
90
+ def patch_client_and_input(monkeypatch, *, client, inputs):
91
+ """Wire up a fake Anthropic client and scripted input().
92
+
93
+ The config file is isolated by the autouse ``_isolate_config`` fixture in
94
+ conftest.py, so ``run()`` sees no config file unless a test writes one to
95
+ ``settings.SETTINGS_PATH``.
96
+ """
97
+ monkeypatch.setattr(anthropic, "Anthropic", lambda *a, **k: client)
98
+
99
+ answers = iter(inputs)
100
+
101
+ def fake_input(prompt=""):
102
+ try:
103
+ return next(answers)
104
+ except StopIteration as exc: # safety net: behaves like Ctrl-D
105
+ raise EOFError from exc
106
+
107
+ monkeypatch.setattr("builtins.input", fake_input)