nanoPyCodeAgent 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
nanopycodeagent/agent.py CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Run the program, type a message, and Agent replies. The full conversation is
4
4
  kept in memory so each turn has context. The model can call a ``read`` tool
5
- to view files and a ``bash`` tool to run shell commands; every call and its
6
- output are echoed to the terminal as they happen. Type ``/exit`` to quit.
5
+ to view files, a ``write`` tool to create or overwrite them, and a ``bash``
6
+ tool to run shell commands; every call and its output are echoed to the
7
+ terminal as they happen. Type ``/exit`` to quit.
7
8
 
8
9
  The loop handles only the happy path: anything unexpected — a network error,
9
10
  a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
@@ -14,13 +15,24 @@ preserved at the ``hardened-agent-loop`` tag.
14
15
  import os
15
16
  from importlib.metadata import PackageNotFoundError, version
16
17
 
18
+ try:
19
+ # Importing readline routes input() through a line editor that redraws the
20
+ # whole line. Without it the tty erases one column per backspace, which
21
+ # leaves half of a double-width character (CJK, emoji) on screen even
22
+ # though it is gone from the buffer. Editing and history come along for
23
+ # the ride. Not available on every platform, so the import is optional.
24
+ import readline # noqa: F401
25
+ except ImportError: # pragma: no cover - platform without readline
26
+ pass
27
+
17
28
  import anthropic
18
29
  from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
19
30
 
20
31
  from .bash_tool import BASH_TOOL, run_bash
21
32
  from .read_tool import READ_TOOL, run_read
22
33
  from .settings import load_settings_env
23
- from .terminal import print_tool
34
+ from .terminal import Spinner, print_tool_output, print_tool_use
35
+ from .write_tool import WRITE_TOOL, content_preview, run_write
24
36
 
25
37
  # The model used when ANTHROPIC_MODEL is set in neither the environment nor
26
38
  # the config file.
@@ -28,13 +40,14 @@ DEFAULT_MODEL = "claude-sonnet-4-6"
28
40
  MAX_TOKENS = 8192
29
41
  SYSTEM_PROMPT = (
30
42
  "You are nanoPyCodeAgent, a concise and helpful coding assistant. "
31
- "Prefer the read tool for viewing files. Use the bash tool to run "
32
- "commands, search with grep, and complete tasks that need real command "
33
- "output instead of guessing."
43
+ "Prefer the read tool for viewing files and the write tool for creating "
44
+ "files or rewriting them whole. Use the bash tool to run commands, "
45
+ "search with grep, and complete tasks that need real command output "
46
+ "instead of guessing."
34
47
  )
35
48
 
36
49
  # Every tool offered to the model on each request.
37
- TOOLS = [READ_TOOL, BASH_TOOL]
50
+ TOOLS = [READ_TOOL, WRITE_TOOL, BASH_TOOL]
38
51
 
39
52
 
40
53
  def _package_version() -> str:
@@ -55,17 +68,25 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
55
68
  """Execute one ``tool_use`` block, echoing the call and its output."""
56
69
  if block.name == "read":
57
70
  path = block.input["path"]
58
- print_tool(f"[read] {path}")
71
+ print_tool_use(f"[read] {path}")
59
72
  output, is_error = run_read(
60
73
  path,
61
74
  offset=block.input.get("offset", 1),
62
75
  limit=block.input.get("limit"),
63
76
  )
77
+ elif block.name == "write":
78
+ path = block.input["path"]
79
+ content = block.input["content"]
80
+ # The echo folds the content: the terminal shows where the write
81
+ # goes and how it starts, not hundreds of lines.
82
+ print_tool_use(f"[write] {path}\n{content_preview(content)}")
83
+ output, is_error = run_write(path, content)
64
84
  else: # bash — the only other tool offered
65
85
  command = block.input["command"]
66
- print_tool(f"[bash]$ {command}")
67
- output, is_error = run_bash(command)
68
- print_tool(output)
86
+ print_tool_use(f"[bash]$ {command}")
87
+ with Spinner("Running..."):
88
+ output, is_error = run_bash(command)
89
+ print_tool_output(output)
69
90
  return {
70
91
  "type": "tool_result",
71
92
  "tool_use_id": block.id,
@@ -105,7 +126,11 @@ def run() -> None:
105
126
  messages: list[MessageParam] = []
106
127
  while True:
107
128
  try:
108
- user_input = input("\nYou> ").strip()
129
+ # The blank line before the prompt is printed separately: readline
130
+ # measures the prompt to place the cursor, and a newline inside it
131
+ # throws that off.
132
+ print()
133
+ user_input = input("You> ").strip()
109
134
  except (EOFError, KeyboardInterrupt):
110
135
  print()
111
136
  break
@@ -118,10 +143,13 @@ def run() -> None:
118
143
  # The model may ask to run tools; keep streaming replies and feeding
119
144
  # results back until it finishes a reply without tool calls.
120
145
  while True:
121
- print("\nAgent> ", end="", flush=True)
146
+ # A spinner marks the wait for the reply; the first streamed
147
+ # token replaces it with the Agent> prompt. A tool-only reply
148
+ # streams no text, so the prompt is skipped for it entirely.
149
+ replied = False
122
150
  # Stream the reply so text shows up as it is generated, then grab
123
151
  # the accumulated message for the conversation history.
124
- with client.messages.stream(
152
+ with Spinner() as spinner, client.messages.stream(
125
153
  model=model,
126
154
  max_tokens=MAX_TOKENS,
127
155
  system=SYSTEM_PROMPT,
@@ -129,9 +157,14 @@ def run() -> None:
129
157
  messages=messages,
130
158
  ) as stream:
131
159
  for text in stream.text_stream:
160
+ if not replied:
161
+ spinner.stop()
162
+ print("\nAgent> ", end="", flush=True)
163
+ replied = True
132
164
  print(text, end="", flush=True)
133
165
  message = stream.get_final_message()
134
- print()
166
+ if replied:
167
+ print()
135
168
 
136
169
  messages.append({"role": "assistant", "content": message.content})
137
170
  if message.stop_reason != "tool_use":
@@ -1,11 +1,17 @@
1
- """Background shading for tool activity in the terminal."""
1
+ """Terminal presentation: tool-activity shading and a wait spinner."""
2
2
 
3
+ import itertools
3
4
  import os
4
5
  import sys
6
+ import threading
5
7
 
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"
8
+ # Grays from the 256-color palette. The echoed call sits on a lighter shade
9
+ # than its output so the two read apart at a glance, and both read apart
10
+ # from the user's prompts and the model's prose. The output keeps the
11
+ # darker shade: it stays subdued while remaining visible on common dark
12
+ # terminal backgrounds.
13
+ _USE_BG = "\x1b[48;5;238m"
14
+ _OUTPUT_BG = "\x1b[48;5;236m"
9
15
  _RESET = "\x1b[0m"
10
16
 
11
17
 
@@ -14,8 +20,8 @@ def _use_color() -> bool:
14
20
  return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
15
21
 
16
22
 
17
- def print_tool(text: str) -> None:
18
- """Print tool activity (an echoed command or its output) shaded.
23
+ def _print_shaded(text: str, bg: str) -> None:
24
+ """Print ``text`` with every line shaded in ``bg``.
19
25
 
20
26
  Each line carries its own set-background / erase-to-EOL / reset, so the
21
27
  shading spans the full terminal width and never leaks past a line
@@ -23,7 +29,68 @@ def print_tool(text: str) -> None:
23
29
  disrupt the shading — a cosmetic trade for staying simple.
24
30
  """
25
31
  if _use_color():
26
- text = "\n".join(
27
- f"{_TOOL_BG}{line}\x1b[K{_RESET}" for line in text.split("\n")
28
- )
32
+ text = "\n".join(f"{bg}{line}\x1b[K{_RESET}" for line in text.split("\n"))
29
33
  print(text, flush=True)
34
+
35
+
36
+ def print_tool_use(text: str) -> None:
37
+ """Print an echoed tool call (e.g. ``[bash]$ ls``) on the lighter shade."""
38
+ _print_shaded(text, _USE_BG)
39
+
40
+
41
+ def print_tool_output(text: str) -> None:
42
+ """Print a tool's output on the darker shade."""
43
+ _print_shaded(text, _OUTPUT_BG)
44
+
45
+
46
+ # The classic braille "dots" spinner: one glyph per frame, different dots
47
+ # raised in each, cycled fast enough to read as rotation.
48
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
49
+ _SPINNER_INTERVAL = 0.08
50
+
51
+
52
+ class Spinner:
53
+ """Animate ``⠋ Working...`` in place on one line until stopped.
54
+
55
+ A background thread redraws the line with carriage returns while the
56
+ caller blocks on slow work, and erases it on stop, so the spinner
57
+ leaves no trace in the transcript. When stdout is not a terminal (or
58
+ NO_COLOR is set) nothing is printed at all. ``stop`` is idempotent:
59
+ stopping early — say, when the first streamed token arrives — and
60
+ again on context-manager exit is fine.
61
+ """
62
+
63
+ def __init__(self, text: str = "Working..."):
64
+ self._text = text
65
+ self._stop_event = threading.Event()
66
+ self._thread: threading.Thread | None = None
67
+
68
+ def start(self) -> None:
69
+ if not _use_color():
70
+ return
71
+ self._thread = threading.Thread(target=self._spin, daemon=True)
72
+ self._thread.start()
73
+
74
+ def stop(self) -> None:
75
+ if self._thread is None:
76
+ return
77
+ self._stop_event.set()
78
+ self._thread.join()
79
+
80
+ def __enter__(self) -> "Spinner":
81
+ self.start()
82
+ return self
83
+
84
+ def __exit__(self, *exc_info: object) -> None:
85
+ self.stop()
86
+
87
+ def _spin(self) -> None:
88
+ # Draw a frame, then sleep on the stop event so stop() interrupts
89
+ # the pause instead of waiting out the full interval.
90
+ for frame in itertools.cycle(_SPINNER_FRAMES):
91
+ sys.stdout.write(f"\r{frame} {self._text}")
92
+ sys.stdout.flush()
93
+ if self._stop_event.wait(_SPINNER_INTERVAL):
94
+ break
95
+ sys.stdout.write("\r\x1b[K") # erase the spinner line
96
+ sys.stdout.flush()
@@ -0,0 +1,153 @@
1
+ """The ``write`` tool: its definition and its execution.
2
+
3
+ Each call writes a whole UTF-8 text file from a structured
4
+ ``{path, content}`` input: the content lands on disk as data, never
5
+ passing through shell expansion, heredoc delimiters or quoting, which is
6
+ what makes a heredoc silently corrupt a file while exiting 0.
7
+ """
8
+
9
+ import shlex
10
+ from pathlib import Path
11
+
12
+ from anthropic.types import ToolParam
13
+
14
+ # The terminal echo folds the content to this many lines, and any one line
15
+ # to this many characters, so a large write does not flood the terminal the
16
+ # way the heredoc it replaces would. The full content reaches the file (and
17
+ # the model already holds it); only the echo is folded.
18
+ WRITE_PREVIEW_LINES = 10
19
+ WRITE_PREVIEW_LINE_CHARS = 200
20
+
21
+ WRITE_TOOL: ToolParam = {
22
+ "name": "write",
23
+ "description": (
24
+ "Write a UTF-8 text file: create it if missing (parent directories "
25
+ "included), or replace its entire content if it exists. The result "
26
+ "says which of the two happened. Use this for new files and "
27
+ "whole-file rewrites instead of shell redirection or heredocs; use "
28
+ "bash to append or to transform many files at once. The write is a "
29
+ "plain overwrite — last writer wins, with no check that the file "
30
+ "was read first or is unchanged since."
31
+ ),
32
+ "input_schema": {
33
+ "type": "object",
34
+ "properties": {
35
+ "path": {
36
+ "type": "string",
37
+ "description": (
38
+ "Path to the file, absolute or relative to the agent's "
39
+ "working directory. A leading ~ is expanded."
40
+ ),
41
+ },
42
+ "content": {
43
+ "type": "string",
44
+ "description": (
45
+ "The complete file content, written exactly as given — "
46
+ "no newline or whitespace normalization."
47
+ ),
48
+ },
49
+ },
50
+ "required": ["path", "content"],
51
+ },
52
+ }
53
+
54
+
55
+ def _logical_lines(content: str) -> list[str]:
56
+ """Split ``content`` into the lines read would number.
57
+
58
+ The split is on ``\\n`` alone, and the newline ending the last line
59
+ starts no new one — so ``"a\\nb\\n"`` is two lines, not three.
60
+ """
61
+ lines = content.split("\n")
62
+ if lines and lines[-1] == "":
63
+ lines.pop()
64
+ return lines
65
+
66
+
67
+ def content_preview(content: str) -> str:
68
+ """Fold ``content`` for the terminal echo of a write call.
69
+
70
+ The first few lines are shown, over-long lines are cut short, and a
71
+ closing note counts the hidden lines, so the echo reads as "where the
72
+ write goes and how it starts" rather than the whole file.
73
+ """
74
+ lines = _logical_lines(content)
75
+ if not lines:
76
+ return "(empty)"
77
+ shown = [
78
+ line
79
+ if len(line) <= WRITE_PREVIEW_LINE_CHARS
80
+ else line[:WRITE_PREVIEW_LINE_CHARS] + "..."
81
+ for line in lines[:WRITE_PREVIEW_LINES]
82
+ ]
83
+ hidden = len(lines) - len(shown)
84
+ if hidden > 0:
85
+ shown.append(f"... (+{hidden} more lines)")
86
+ return "\n".join(shown)
87
+
88
+
89
+ def run_write(path_str: str, content: str) -> tuple[str, bool]:
90
+ """Write ``content`` to the file whole and return ``(output, is_error)``.
91
+
92
+ The file is created when missing — parent directories included — and
93
+ replaced entirely when it exists; the result names which of the two
94
+ happened, plus the byte and line counts the model can check its intent
95
+ against. ``is_error`` is true when the target is a directory or any
96
+ other non-regular file, or when the write itself fails. A FIFO is
97
+ refused because opening one for writing blocks until a reader appears,
98
+ with no timeout here to escape that; a device file is refused because
99
+ writing to one changes the machine, not the workspace. A symlink to a
100
+ regular file is followed: the write lands in its target.
101
+
102
+ Content is encoded as UTF-8 and written as given — no newline or
103
+ whitespace normalization, no trailing-newline fix-up. The write is a
104
+ plain overwrite: nothing checks that the file was read first or is
105
+ unchanged since, and there is no atomic-replace step. In this agent's
106
+ single-threaded loop those guards would only be pretend safety, so the
107
+ semantics are stated plainly instead: last writer wins.
108
+ """
109
+ path = Path(path_str).expanduser()
110
+ if path.is_dir():
111
+ return (
112
+ f"[{path_str} is a directory, not a file; give a path to a file "
113
+ f"inside it]",
114
+ True,
115
+ )
116
+ existed = path.exists()
117
+ if existed and not path.is_file():
118
+ # Opening a FIFO for writing blocks until a reader shows up, and a
119
+ # device file must not be overwritten as if it were text. Same
120
+ # check as read's, from the writing side.
121
+ return (
122
+ f"[{path_str} is not a regular file; write refuses FIFOs and "
123
+ f"device files — if it really is the target, write it with "
124
+ f"bash, whose timeout escapes a blocked open]",
125
+ True,
126
+ )
127
+ try:
128
+ data = content.encode("utf-8")
129
+ except UnicodeEncodeError as exc:
130
+ # A lone surrogate cannot reach disk as UTF-8; writing a replacement
131
+ # instead would corrupt the file silently — the heredoc failure mode
132
+ # this tool exists to remove.
133
+ return f"[content is not encodable as UTF-8: {exc}; resend it]", True
134
+ try:
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ path.write_bytes(data)
137
+ except OSError as exc:
138
+ # ``exc`` names the OS reason — permission denied, a file sitting
139
+ # in the parent path, a full disk. The hint points at the usual
140
+ # first suspect, following read's bash-hint conventions: expanded
141
+ # path, quoted, behind -- so it cannot read as an option.
142
+ return (
143
+ f"[cannot write {path_str}: {exc}; inspect the parent with "
144
+ f"bash, e.g. ls -ld -- {shlex.quote(str(path.parent))}]",
145
+ True,
146
+ )
147
+
148
+ verb = "overwrote" if existed else "created"
149
+ return (
150
+ f"[{verb} {path_str}: {len(data)} bytes, "
151
+ f"{len(_logical_lines(content))} lines]",
152
+ False,
153
+ )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nanoPyCodeAgent
3
- Version: 0.5.0
3
+ Version: 0.6.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,12 @@
1
+ nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
2
+ nanopycodeagent/agent.py,sha256=4HC6ZXm3zMKso1-VS9VYH4bIrFcR33U_gIbBcrfsnX4,7219
3
+ nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
4
+ nanopycodeagent/read_tool.py,sha256=4AddoZ8uHGzPNKMFagrskjmOWv5nhpk3oJbG8eBBgcM,7374
5
+ nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
6
+ nanopycodeagent/terminal.py,sha256=jSe0CXayttR25RBt_qmCsFURVDW0uha391hBKgW3XwQ,3340
7
+ nanopycodeagent/write_tool.py,sha256=dnHoNGT6HpQvJXQvxXPQcS4yBTtfwCkSV4hLAn9_YgY,6168
8
+ nanopycodeagent-0.6.0.dist-info/METADATA,sha256=x0dali3scKdug_qwwMy2XzPxNpZh3pPv5oIwbPD9VLg,3028
9
+ nanopycodeagent-0.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ nanopycodeagent-0.6.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
11
+ nanopycodeagent-0.6.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
12
+ nanopycodeagent-0.6.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
2
- nanopycodeagent/agent.py,sha256=AAOnt1q-6FQgcAOODxHv2iM0UwlbqSnPD8kmtPGPo68,5452
3
- nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
4
- nanopycodeagent/read_tool.py,sha256=4AddoZ8uHGzPNKMFagrskjmOWv5nhpk3oJbG8eBBgcM,7374
5
- nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
6
- nanopycodeagent/terminal.py,sha256=ZkRvKbGQGiu7SI8Pe40THVYR1-4CMBEMVEgAH8h8Ytw,995
7
- nanopycodeagent-0.5.0.dist-info/METADATA,sha256=Bep9XESPN7PJ34J9zPvh4YtXdfQ1h2Yeh1Ht5uZdG9E,3028
8
- nanopycodeagent-0.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
- nanopycodeagent-0.5.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
10
- nanopycodeagent-0.5.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
11
- nanopycodeagent-0.5.0.dist-info/RECORD,,