coderio 0.4.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.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ _LINE_PREFIX_RE = re.compile(r"^\s*\d+\s*?\t")
9
+
10
+
11
+ class EditFileArgs(BaseModel):
12
+ path: str = Field(description="Path to the file to edit.")
13
+ old_string: str = Field(description="Exact text to replace (must match uniquely unless replace_all).")
14
+ new_string: str = Field(description="Text to substitute in place of old_string.")
15
+ replace_all: bool = Field(default=False, description="Replace every occurrence.")
16
+
17
+
18
+ def _strip_line_prefix(text: str) -> str:
19
+ """Strip read_file's 'N\\t' line-number prefix from each line (spec §3.2)."""
20
+ return "\n".join(
21
+ _LINE_PREFIX_RE.sub("", line) if _LINE_PREFIX_RE.match(line) else line for line in text.splitlines()
22
+ )
23
+
24
+
25
+ class EditFileTool:
26
+ name = "edit_file"
27
+ description = (
28
+ "Exact string replacement in a file. old_string must match uniquely unless "
29
+ "replace_all is true. Requires permission in confirm/plan modes."
30
+ )
31
+ args_schema = EditFileArgs
32
+
33
+ def run(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
34
+ p = Path(path)
35
+ if not p.is_file():
36
+ return f"Error: file not found: {path}"
37
+ text = p.read_text(encoding="utf-8", errors="replace")
38
+ old_string = _strip_line_prefix(old_string)
39
+ new_string = _strip_line_prefix(new_string)
40
+ # Empty old_string is a footgun: str.replace("", x) inserts x between
41
+ # every character (len(text)+1 insertions), producing catastrophic
42
+ # bloat. Reject explicitly — the caller has a bug.
43
+ if not old_string:
44
+ return "Error: old_string is empty; provide the exact text to replace"
45
+ count = text.count(old_string)
46
+ if count == 0:
47
+ return f"Error: old_string not found in {path}"
48
+ if count > 1 and not replace_all:
49
+ return f"Error: old_string matches {count} times (not unique); set replace_all=true"
50
+ if replace_all:
51
+ new_text = text.replace(old_string, new_string)
52
+ else:
53
+ new_text = text.replace(old_string, new_string, 1)
54
+ p.write_text(new_text, encoding="utf-8")
55
+ return f"Edited {path}: replaced {count if replace_all else 1} occurrence(s)"
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class GlobArgs(BaseModel):
9
+ pattern: str = Field(description="Glob pattern (e.g. '**/*.py' to recurse).")
10
+ path: str = Field(default=".", description="Base directory to search in.")
11
+
12
+
13
+ class GlobTool:
14
+ name = "glob"
15
+ description = "Match files by glob pattern (e.g. **/*.py). Returns matching paths."
16
+ args_schema = GlobArgs
17
+
18
+ def run(self, pattern: str, path: str = ".") -> str:
19
+ base = Path(path)
20
+ matches = sorted(str(p) for p in base.glob(pattern))
21
+ return "\n".join(matches) if matches else "No matches"
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class GrepArgs(BaseModel):
12
+ pattern: str = Field(description="Regex pattern to search for.")
13
+ path: str = Field(default=".", description="File or directory to search in.")
14
+ glob: str = Field(default="", description="Optional glob filter (e.g. '*.py').")
15
+ output_mode: str = Field(
16
+ default="content",
17
+ description="'content' (lines), 'files_with_matches' (paths), or 'count'.",
18
+ )
19
+
20
+
21
+ def _rg_available() -> bool:
22
+ return shutil.which("rg") is not None
23
+
24
+
25
+ class GrepTool:
26
+ name = "grep"
27
+ description = (
28
+ "Search file contents by regex. Uses ripgrep if available, else Python fallback. "
29
+ "output_mode: 'content' (default), 'files_with_matches', or 'count'."
30
+ )
31
+ args_schema = GrepArgs
32
+
33
+ def run(
34
+ self,
35
+ pattern: str,
36
+ path: str = ".",
37
+ glob: str = "",
38
+ output_mode: str = "content",
39
+ ) -> str:
40
+ if _rg_available():
41
+ return self._with_rg(pattern, path, glob, output_mode)
42
+ return self._python_fallback(pattern, path, glob, output_mode)
43
+
44
+ def _with_rg(self, pattern: str, path: str, glob: str, output_mode: str) -> str:
45
+ cmd = ["rg", "--no-heading", "-n"]
46
+ mode_map = {"content": "", "files_with_matches": "-l", "count": "-c"}
47
+ flag = mode_map.get(output_mode, "")
48
+ if flag:
49
+ cmd.append(flag)
50
+ if glob:
51
+ cmd += ["--glob", glob]
52
+ cmd += [pattern, path]
53
+ try:
54
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
55
+ return (proc.stdout or "").strip() or "No matches"
56
+ except FileNotFoundError:
57
+ return self._python_fallback(pattern, path, glob, output_mode)
58
+
59
+ def _python_fallback(self, pattern: str, path: str, glob: str, output_mode: str) -> str:
60
+ base = Path(path)
61
+ if glob:
62
+ files = list(base.rglob(glob))
63
+ else:
64
+ files = list(base.rglob("*"))
65
+ rx = re.compile(pattern)
66
+ content_hits = []
67
+ matched_files = []
68
+ total = 0
69
+ for f in files:
70
+ if not f.is_file():
71
+ continue
72
+ try:
73
+ text = f.read_text(encoding="utf-8", errors="replace")
74
+ except OSError:
75
+ continue
76
+ file_count = 0
77
+ for i, line in enumerate(text.splitlines(), 1):
78
+ if rx.search(line):
79
+ total += 1
80
+ file_count += 1
81
+ if output_mode == "files_with_matches":
82
+ matched_files.append(str(f))
83
+ break
84
+ elif output_mode == "content":
85
+ content_hits.append(f"{f}:{i}:{line}")
86
+ if output_mode == "count":
87
+ return f"{total} matches"
88
+ if output_mode == "files_with_matches":
89
+ return "\n".join(matched_files) if matched_files else "No matches"
90
+ return "\n".join(content_hits) if content_hits else "No matches"
@@ -0,0 +1,196 @@
1
+ """Linux sandboxing via bubblewrap (bwrap).
2
+
3
+ bubblewrap uses Linux user namespaces to provide a lightweight sandbox without
4
+ requiring root or a container runtime. It's the same tool Claude Code uses on
5
+ Linux, and Flatpak's underlying sandbox.
6
+
7
+ What this isolates (in "write" mode):
8
+ - **File writes**: the sandboxed process sees the root filesystem read-only;
9
+ only the workspace dir is mounted read-write. Writes outside the workspace
10
+ fail at the filesystem (mount) level — not a regex check, an actual EROFS.
11
+ - **Network**: optionally disabled via ``--unshare-net`` (network namespace).
12
+
13
+ Availability: requires ``bwrap`` installed (``apt install bubblewrap`` on Debian
14
+ / Ubuntu, standard on Fedora). Some distros disable unprivileged user
15
+ namespaces by default (RHEL, older Debian) — in that case bwrap fails and we
16
+ fall back to plain subprocess.
17
+
18
+ Unlike the Windows path, this is a TRUE sandbox in v1 (bubblewrap's namespace
19
+ isolation is complete, not "prepared but not applied"). The gap vs. Windows
20
+ reflects the platform maturity: bubblewrap is battle-tested (Flatpak), while
21
+ the Windows Restricted Token path needs more wiring (see win_sandbox.py).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+
35
+ def bwrap_available() -> bool:
36
+ """Check if bubblewrap is installed and functional.
37
+
38
+ Returns True only if the ``bwrap`` binary is on PATH. We don't probe
39
+ unprivileged-userns support here (that requires actually running bwrap) —
40
+ the caller should handle a bwrap failure as a fallback signal.
41
+ """
42
+ if sys.platform == "win32":
43
+ return False # bubblewrap is Linux-only
44
+ return shutil.which("bwrap") is not None
45
+
46
+
47
+ def build_bwrap_args(
48
+ command: str,
49
+ workspace: str,
50
+ *,
51
+ network_allowed: bool = True,
52
+ fs_config=None,
53
+ ) -> list[str]:
54
+ """Build the bwrap argument list for a write-restricted sandbox.
55
+
56
+ Mount layout (default, no fs_config):
57
+ - ``/`` read-only (system libraries readable, no writes).
58
+ - ``workspace`` read-write (the only writable path).
59
+ - ``/dev``, ``/proc`` mounted (many tools need them; /dev is read-only).
60
+ - ``/tmp`` as a private tmpfs (writable but isolated).
61
+
62
+ Network: if ``network_allowed=False``, ``--unshare-net`` puts the process
63
+ in its own network namespace with only loopback — no external connectivity.
64
+
65
+ Filesystem config (Claude-Code-compatible four-tuple, when fs_config given):
66
+ - ``allow_write``: extra read-write mounts beyond workspace.
67
+ - ``deny_write``: read-only overrides (workspace subpaths forced read-only).
68
+ - ``deny_read``: tmpfs blackholes (path exists but contents invisible).
69
+ - ``allow_read``: read-only re-mounts that punch through a deny_read.
70
+ Paths support ``~`` (home) and ``./`` (workspace-relative) prefixes.
71
+ """
72
+ # Resolve workspace to an absolute path (bwrap requires it).
73
+ ws = str(Path(workspace).resolve())
74
+
75
+ args = [
76
+ "bwrap",
77
+ "--ro-bind",
78
+ "/",
79
+ "/",
80
+ "--bind",
81
+ ws,
82
+ ws,
83
+ "--dev",
84
+ "/dev",
85
+ "--proc",
86
+ "/proc",
87
+ "--tmpfs",
88
+ "/tmp",
89
+ ]
90
+ if not network_allowed:
91
+ args.append("--unshare-net")
92
+ # Filesystem four-tuple (Gap 3). Each path is expanded and appended in
93
+ # order; bwrap applies later mounts on top of earlier ones, so deny_read's
94
+ # tmpfs must come before allow_read's ro-bind to "punch a hole".
95
+ if fs_config is not None:
96
+ from pathlib import Path as _Path
97
+
98
+ home = _Path.home()
99
+ # allow_write: extra read-write mounts (e.g. /tmp/build, ~/.cache).
100
+ for p in getattr(fs_config, "allow_write", []) or []:
101
+ resolved = _resolve_fs_path(p, ws, home)
102
+ args += ["--bind", resolved, resolved]
103
+ # deny_write: read-only overrides (force a path read-only even if it
104
+ # would otherwise be writable, e.g. .git/hooks inside workspace).
105
+ for p in getattr(fs_config, "deny_write", []) or []:
106
+ resolved = _resolve_fs_path(p, ws, home)
107
+ args += ["--ro-bind", resolved, resolved]
108
+ # deny_read: tmpfs blackholes (path exists but is empty — hides the
109
+ # real contents, e.g. ~/.ssh appears as an empty dir).
110
+ for p in getattr(fs_config, "deny_read", []) or []:
111
+ resolved = _resolve_fs_path(p, ws, home)
112
+ args += ["--tmpfs", resolved]
113
+ # allow_read: read-only re-mounts that punch through a deny_read
114
+ # blackhole. MUST come after the deny_read tmpfs to take effect.
115
+ for p in getattr(fs_config, "allow_read", []) or []:
116
+ resolved = _resolve_fs_path(p, ws, home)
117
+ args += ["--ro-bind", resolved, resolved]
118
+ # Die if the child can't set up the namespace (don't silently run unsandboxed).
119
+ args.append("--die-with-parent")
120
+ # Finally, the command to run inside the sandbox.
121
+ args += ["--", "sh", "-c", command]
122
+ return args
123
+
124
+
125
+ def _resolve_fs_path(path: str, workspace: str, home) -> str:
126
+ """Expand ~ and relative paths in a filesystem config entry.
127
+
128
+ - ``~/foo`` → ``{home}/foo``
129
+ - ``./foo`` or ``foo`` → ``{workspace}/foo`` (project-relative)
130
+ - ``/abs/path`` → unchanged
131
+ """
132
+ if path.startswith("~"):
133
+ return str((home / path[2:]).resolve()) if path.startswith("~/") else str(home.resolve())
134
+ if path.startswith("./"):
135
+ return str((Path(workspace) / path[2:]).resolve())
136
+ if not path.startswith("/"):
137
+ # Bare relative path → workspace-relative (same as ./).
138
+ return str((Path(workspace) / path).resolve())
139
+ return path
140
+
141
+
142
+ def run_bwrap(
143
+ command: str,
144
+ cwd: str,
145
+ *,
146
+ timeout: int = 120,
147
+ env: dict | None = None,
148
+ max_output_bytes: int = 100_000,
149
+ network_allowed: bool = True,
150
+ fs_config=None,
151
+ ) -> tuple[int, str]:
152
+ """Run a command in a bubblewrap sandbox.
153
+
154
+ Returns (exit_code, combined_output). On bwrap failure (not installed,
155
+ userns disabled), the caller (sandbox_runner) falls back to plain subprocess.
156
+
157
+ Args:
158
+ network_allowed: if False, adds ``--unshare-net`` (own network namespace,
159
+ only loopback — no external connectivity). Prevents ``curl``/``wget``
160
+ in shell commands from exfiltrating data.
161
+ fs_config: optional SandboxFsConfig with allow_write/deny_write/deny_read/
162
+ allow_read lists for per-path filesystem isolation.
163
+ """
164
+ if not bwrap_available():
165
+ return (-1, "bubblewrap not installed")
166
+
167
+ args = build_bwrap_args(command, cwd, network_allowed=network_allowed, fs_config=fs_config)
168
+ try:
169
+ proc = subprocess.run(
170
+ args,
171
+ capture_output=True,
172
+ cwd=cwd,
173
+ timeout=timeout,
174
+ text=False,
175
+ stdin=subprocess.DEVNULL,
176
+ env=env,
177
+ start_new_session=True, # own process group for reliable kill
178
+ )
179
+ stdout = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
180
+ stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
181
+ # bubblewrap setup errors go to stderr — surface them so the model knows
182
+ # why the command failed (e.g. "bwrap: No support for user namespaces").
183
+ output = stdout
184
+ if stderr:
185
+ # On a successful run, bwrap's own stderr is empty — stderr content
186
+ # is either the command's stderr or a bwrap setup error. Include it.
187
+ output += f"\n[stderr]\n{stderr}"
188
+ if len(output) > max_output_bytes:
189
+ output = output[:max_output_bytes] + f"\n\n... Output truncated at {max_output_bytes} bytes."
190
+ return (proc.returncode, output)
191
+ except subprocess.TimeoutExpired:
192
+ return (124, f"Command timed out after {timeout}s (in bubblewrap sandbox)")
193
+ except FileNotFoundError:
194
+ return (-1, "bubblewrap binary not found at runtime")
195
+ except Exception as e: # noqa: BLE001
196
+ return (-1, f"bubblewrap sandbox failed: {e}")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ListDirArgs(BaseModel):
9
+ path: str = Field(description="Directory to list.")
10
+ recursive: bool = Field(default=False, description="Recurse into subdirectories.")
11
+ max_depth: int = Field(default=3, description="Max recursion depth (when recursive).")
12
+
13
+
14
+ class ListDirTool:
15
+ name = "list_dir"
16
+ description = (
17
+ "List the contents of a directory. Returns file/dir names (directories marked "
18
+ "with a trailing /). Supports recursive listing with a max depth. Use to "
19
+ "understand project structure."
20
+ )
21
+ args_schema = ListDirArgs
22
+
23
+ def run(self, path: str, recursive: bool = False, max_depth: int = 3) -> str:
24
+ base = Path(path)
25
+ if not base.exists():
26
+ return f"Error: path not found: {path}"
27
+ if not base.is_dir():
28
+ return f"Error: not a directory: {path}"
29
+
30
+ lines = []
31
+
32
+ def _walk(d: Path, prefix: str, depth: int) -> None:
33
+ try:
34
+ entries = sorted(
35
+ d.iterdir(),
36
+ key=lambda p: (not p.is_dir(), p.name.lower()),
37
+ )
38
+ except OSError as e:
39
+ lines.append(f"{prefix}<error: {e}>")
40
+ return
41
+ for entry in entries:
42
+ if entry.name in {
43
+ "__pycache__",
44
+ "venv",
45
+ ".venv",
46
+ ".git",
47
+ "node_modules",
48
+ }:
49
+ continue
50
+ if entry.is_dir():
51
+ lines.append(f"{prefix}{entry.name}/")
52
+ if recursive and depth + 1 < max_depth:
53
+ _walk(entry, prefix + " ", depth + 1)
54
+ else:
55
+ lines.append(f"{prefix}{entry.name}")
56
+
57
+ _walk(base, "", 0)
58
+ return "\n".join(lines) if lines else "(empty directory)"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from coderio.tools.edit_file import _strip_line_prefix
8
+
9
+
10
+ class _SingleEdit(BaseModel):
11
+ old_string: str = Field(description="Exact text to replace (must match uniquely unless replace_all).")
12
+ new_string: str = Field(description="Text to substitute.")
13
+ replace_all: bool = Field(default=False, description="Replace every occurrence.")
14
+
15
+
16
+ class MultiEditArgs(BaseModel):
17
+ path: str = Field(description="Path to the file to edit.")
18
+ edits: list[_SingleEdit] = Field(description="Ordered list of edits to apply sequentially.")
19
+
20
+
21
+ class MultiEditTool:
22
+ name = "multi_edit"
23
+ description = (
24
+ "Apply multiple exact-string edits to one file in a single call. Each edit sees "
25
+ "the result of the previous one. If any edit's old_string is not found (or is "
26
+ "ambiguous), the whole operation aborts with NO changes written. Requires "
27
+ "permission in confirm/plan modes."
28
+ )
29
+ args_schema = MultiEditArgs
30
+
31
+ def run(self, path: str, edits: list[dict]) -> str:
32
+ p = Path(path)
33
+ if not p.is_file():
34
+ return f"Error: file not found: {path}"
35
+ if not edits:
36
+ return "No edits provided; file unchanged."
37
+ text = p.read_text(encoding="utf-8", errors="replace")
38
+ applied = 0
39
+ for i, edit in enumerate(edits):
40
+ old = _strip_line_prefix(edit.get("old_string", ""))
41
+ new = _strip_line_prefix(edit.get("new_string", ""))
42
+ replace_all = edit.get("replace_all", False)
43
+ # Empty old_string is a footgun (see edit_file.py): str.replace("",
44
+ # x) inserts x between every character. Reject before it corrupts
45
+ # the file — multi_edit is atomic, so nothing is written yet.
46
+ if not old:
47
+ return (
48
+ f"Error: edit #{i + 1} old_string is empty; provide the exact "
49
+ "text to replace. Aborted (no changes written)."
50
+ )
51
+ count = text.count(old)
52
+ if count == 0:
53
+ return f"Error: edit #{i + 1} old_string not found in {path}; aborted (no changes written)."
54
+ if count > 1 and not replace_all:
55
+ return (
56
+ f"Error: edit #{i + 1} old_string matches {count} times (not unique); "
57
+ "set replace_all=true or make it unique. Aborted (no changes written)."
58
+ )
59
+ text = text.replace(old, new) if replace_all else text.replace(old, new, 1)
60
+ applied += 1
61
+ p.write_text(text, encoding="utf-8")
62
+ return f"Edited {path}: applied {applied} edit(s)"
coderio/tools/note.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ _DEFAULT_NOTES_DIR = Path.home() / ".coderio" / "notes"
8
+
9
+
10
+ class NoteArgs(BaseModel):
11
+ action: str = Field(description="One of: write, read, list, append, delete.")
12
+ name: str = Field(default="", description="Note name (becomes <name>.md).")
13
+ content: str = Field(default="", description="Note content (for write/append).")
14
+
15
+
16
+ class NoteTool:
17
+ """Cross-session long-term memory. Notes persist in ~/.coderio/notes/<name>.md
18
+ and survive across REPL sessions, unlike session history."""
19
+
20
+ name = "note"
21
+ description = (
22
+ "Persistent notes for cross-session memory. actions: write/read/list/append/delete. "
23
+ "Notes are stored as markdown files and survive across sessions — use them to record "
24
+ "decisions, context, or TODOs you want to recall later."
25
+ )
26
+ args_schema = NoteArgs
27
+
28
+ def __init__(self, notes_dir: Path | str | None = None):
29
+ self._dir = Path(notes_dir) if notes_dir else _DEFAULT_NOTES_DIR
30
+
31
+ def _path(self, name: str) -> Path:
32
+ safe = "".join(c for c in name if c.isalnum() or c in "-_") or "untitled"
33
+ return self._dir / f"{safe}.md"
34
+
35
+ def run(self, action: str, name: str = "", content: str = "") -> str:
36
+ if action == "list":
37
+ self._dir.mkdir(parents=True, exist_ok=True)
38
+ notes = sorted(p.stem for p in self._dir.glob("*.md"))
39
+ if not notes:
40
+ return "No notes."
41
+ return "Notes:\n" + "\n".join(f" - {n}" for n in notes)
42
+ elif action == "write":
43
+ if not name:
44
+ return "Error: name is required for write."
45
+ p = self._path(name)
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+ p.write_text(content, encoding="utf-8")
48
+ return f"Saved note: {p.stem}"
49
+ elif action == "read":
50
+ if not name:
51
+ return "Error: name is required for read."
52
+ p = self._path(name)
53
+ if not p.is_file():
54
+ return f"Note not found: {name}"
55
+ return p.read_text(encoding="utf-8", errors="replace")
56
+ elif action == "append":
57
+ if not name:
58
+ return "Error: name is required for append."
59
+ p = self._path(name)
60
+ p.parent.mkdir(parents=True, exist_ok=True)
61
+ existing = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else ""
62
+ p.write_text(existing + content, encoding="utf-8")
63
+ return f"Appended to note: {p.stem}"
64
+ elif action == "delete":
65
+ if not name:
66
+ return "Error: name is required for delete."
67
+ p = self._path(name)
68
+ if p.is_file():
69
+ p.unlink()
70
+ return f"Deleted note: {p.stem}"
71
+ return f"Note not found: {name}"
72
+ return f"Error: unknown action {action!r}. Use: write/read/list/append/delete."