noah-code 0.1.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.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Permission-gated workspace tools wrapping ShellTools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
from nooa import Skill, hidden, spec
|
|
10
|
+
from nooa.tools.shell_tools import Match, ShellResult, ShellTools, StreamDone, StreamEvent
|
|
11
|
+
|
|
12
|
+
from noah_code.approvals import ApprovalBroker
|
|
13
|
+
from noah_code.permissions import PermissionCategory, PermissionDecision, PermissionEngine
|
|
14
|
+
from noah_code.snapshots import SnapshotJournal
|
|
15
|
+
from noah_code.workspace import Workspace, WorkspaceError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _truncate(text: str, limit: int) -> str:
|
|
19
|
+
if len(text) <= limit:
|
|
20
|
+
return text
|
|
21
|
+
head = limit // 2
|
|
22
|
+
tail = limit - head - 40
|
|
23
|
+
return (
|
|
24
|
+
text[:head]
|
|
25
|
+
+ f"\n...[{len(text) - head - max(tail, 0)} chars truncated]...\n"
|
|
26
|
+
+ text[-max(tail, 0) :]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WorkspaceTools(Skill):
|
|
31
|
+
"""Read, search, edit, and run commands inside the active workspace.
|
|
32
|
+
|
|
33
|
+
All mutating operations go through the permission engine. Prefer
|
|
34
|
+
Match-based ``replace`` over rewriting whole files. Paths are
|
|
35
|
+
canonicalized and must remain inside the workspace unless
|
|
36
|
+
``external_directory`` is approved.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
workspace: Workspace,
|
|
42
|
+
shell: ShellTools,
|
|
43
|
+
engine: PermissionEngine,
|
|
44
|
+
approvals: ApprovalBroker,
|
|
45
|
+
journal: SnapshotJournal,
|
|
46
|
+
*,
|
|
47
|
+
max_output_chars: int = 80_000,
|
|
48
|
+
default_timeout: float = 60.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self._workspace = workspace
|
|
52
|
+
self._shell = shell
|
|
53
|
+
self._engine = engine
|
|
54
|
+
self._approvals = approvals
|
|
55
|
+
self._journal = journal
|
|
56
|
+
self._max_output = max_output_chars
|
|
57
|
+
self._default_timeout = default_timeout
|
|
58
|
+
self._on_shell_chunk: Any = None
|
|
59
|
+
|
|
60
|
+
def set_shell_chunk_handler(self, handler: Any) -> None:
|
|
61
|
+
"""Optional callback(stream: str, text: str) for UI streaming."""
|
|
62
|
+
self._on_shell_chunk = handler
|
|
63
|
+
|
|
64
|
+
@hidden
|
|
65
|
+
@property
|
|
66
|
+
def raw_shell(self) -> ShellTools:
|
|
67
|
+
"""Raw shell - host only; not for the model."""
|
|
68
|
+
return self._shell
|
|
69
|
+
|
|
70
|
+
async def read(
|
|
71
|
+
self,
|
|
72
|
+
path: Annotated[str, spec(description="File path relative to workspace")],
|
|
73
|
+
lines: Annotated[
|
|
74
|
+
tuple[int, int] | None,
|
|
75
|
+
spec(description="Optional (start, end) 1-indexed inclusive range"),
|
|
76
|
+
] = None,
|
|
77
|
+
) -> Match:
|
|
78
|
+
"""Read a file (or line range) and return a Match anchor for editing."""
|
|
79
|
+
resolved = await self._authorize_path(path, PermissionCategory.READ)
|
|
80
|
+
rel = self._workspace.relpath(resolved)
|
|
81
|
+
result = await self._shell.read(rel, lines=lines)
|
|
82
|
+
if len(result.text) > self._max_output:
|
|
83
|
+
truncated = _truncate(result.text, self._max_output)
|
|
84
|
+
return Match(result.path, result.start, result.end, truncated)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
async def search(
|
|
88
|
+
self,
|
|
89
|
+
pattern: Annotated[str, spec(description="Regex or fixed pattern for ripgrep")],
|
|
90
|
+
path: Annotated[str, spec(description="Subdirectory or file to search")] = ".",
|
|
91
|
+
) -> ShellResult:
|
|
92
|
+
"""Search the workspace with ripgrep; results may include Match anchors."""
|
|
93
|
+
resolved = await self._authorize_path(path, PermissionCategory.READ)
|
|
94
|
+
import shlex
|
|
95
|
+
|
|
96
|
+
target = self._workspace.relpath(resolved) or "."
|
|
97
|
+
cmd = " ".join(
|
|
98
|
+
shlex.quote(a) for a in ["rg", "-n", "--no-heading", "-S", "--", pattern, target]
|
|
99
|
+
)
|
|
100
|
+
result = await self._shell.run(cmd, timeout=self._default_timeout)
|
|
101
|
+
return self._cap_shell_result(result)
|
|
102
|
+
|
|
103
|
+
async def list_files(
|
|
104
|
+
self,
|
|
105
|
+
pattern: Annotated[str, spec(description="Glob pattern")] = "**/*",
|
|
106
|
+
path: Annotated[str, spec(description="Subdirectory")] = ".",
|
|
107
|
+
) -> list[str]:
|
|
108
|
+
"""List files under path matching a glob (deterministic, no shell)."""
|
|
109
|
+
root = await self._authorize_path(path, PermissionCategory.READ)
|
|
110
|
+
matches = sorted(
|
|
111
|
+
str(p.relative_to(self._workspace.root)) for p in root.glob(pattern) if p.is_file()
|
|
112
|
+
)
|
|
113
|
+
if len(matches) > 2000:
|
|
114
|
+
return matches[:2000] + [f"...[{len(matches) - 2000} more]"]
|
|
115
|
+
return matches
|
|
116
|
+
|
|
117
|
+
async def replace(
|
|
118
|
+
self,
|
|
119
|
+
match: Annotated[Any, spec(description="Match from read()/search() or path string")],
|
|
120
|
+
new_text: Annotated[str, spec(description="Replacement text for Match form")] = "",
|
|
121
|
+
new: Annotated[str | None, spec(description="Path-form replacement")] = None,
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Edit via Match anchor (preferred) or unique string replacement."""
|
|
124
|
+
if isinstance(match, Match):
|
|
125
|
+
resolved = await self._authorize_path(match.path, PermissionCategory.EDIT)
|
|
126
|
+
mut = self._journal.record_preimage(resolved)
|
|
127
|
+
try:
|
|
128
|
+
result = await self._shell.replace(match, new_text)
|
|
129
|
+
except Exception:
|
|
130
|
+
self._journal.discard_mutation(mut)
|
|
131
|
+
raise
|
|
132
|
+
self._journal.record_postimage(mut, resolved)
|
|
133
|
+
return result
|
|
134
|
+
if isinstance(match, str):
|
|
135
|
+
resolved = await self._authorize_path(match, PermissionCategory.EDIT)
|
|
136
|
+
mut = self._journal.record_preimage(resolved)
|
|
137
|
+
try:
|
|
138
|
+
result = await self._shell.replace(match, new_text, new)
|
|
139
|
+
except Exception:
|
|
140
|
+
self._journal.discard_mutation(mut)
|
|
141
|
+
raise
|
|
142
|
+
self._journal.record_postimage(mut, resolved)
|
|
143
|
+
return result
|
|
144
|
+
raise TypeError("replace expects a Match or path string")
|
|
145
|
+
|
|
146
|
+
async def write_file(
|
|
147
|
+
self,
|
|
148
|
+
path: Annotated[str, spec(description="File path relative to workspace")],
|
|
149
|
+
content: Annotated[str, spec(description="Full file content")],
|
|
150
|
+
) -> Any:
|
|
151
|
+
"""Create or overwrite a file with content."""
|
|
152
|
+
resolved = await self._authorize_path(path, PermissionCategory.EDIT)
|
|
153
|
+
mut = self._journal.record_preimage(resolved)
|
|
154
|
+
try:
|
|
155
|
+
result = await self._shell.write_file(path, content)
|
|
156
|
+
except Exception:
|
|
157
|
+
self._journal.discard_mutation(mut)
|
|
158
|
+
raise
|
|
159
|
+
self._journal.record_postimage(mut, resolved)
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
async def run(
|
|
163
|
+
self,
|
|
164
|
+
command: Annotated[str, spec(description="Shell command")],
|
|
165
|
+
stdin: Annotated[str | None, spec(description="Optional stdin payload")] = None,
|
|
166
|
+
timeout: Annotated[float | None, spec(description="Timeout seconds")] = None,
|
|
167
|
+
) -> ShellResult:
|
|
168
|
+
"""Run a command in the workspace shell session."""
|
|
169
|
+
decision = self._shell_decision(command)
|
|
170
|
+
await self._approvals.require(decision)
|
|
171
|
+
if not self._engine.is_readonly_command(command):
|
|
172
|
+
self._journal.mark_shell_bypass()
|
|
173
|
+
if self._on_shell_chunk is not None:
|
|
174
|
+
self._on_shell_chunk("status", f"$ {command}\n")
|
|
175
|
+
result = await self._shell.run(
|
|
176
|
+
command,
|
|
177
|
+
stdin=stdin,
|
|
178
|
+
timeout=timeout or self._default_timeout,
|
|
179
|
+
)
|
|
180
|
+
if self._on_shell_chunk is not None:
|
|
181
|
+
if result.stdout:
|
|
182
|
+
self._on_shell_chunk("stdout", result.stdout)
|
|
183
|
+
if result.stderr:
|
|
184
|
+
self._on_shell_chunk("stderr", result.stderr)
|
|
185
|
+
self._on_shell_chunk("status", f"[exit {result.returncode}]\n")
|
|
186
|
+
return self._cap_shell_result(result)
|
|
187
|
+
|
|
188
|
+
async def run_stream(
|
|
189
|
+
self,
|
|
190
|
+
command: Annotated[str, spec(description="Shell command")],
|
|
191
|
+
timeout: Annotated[float | None, spec(description="Timeout seconds")] = None,
|
|
192
|
+
) -> AsyncIterator[StreamEvent | StreamDone]:
|
|
193
|
+
"""Stream command output; same permission rules as run()."""
|
|
194
|
+
decision = self._shell_decision(command)
|
|
195
|
+
await self._approvals.require(decision)
|
|
196
|
+
if not self._engine.is_readonly_command(command):
|
|
197
|
+
self._journal.mark_shell_bypass()
|
|
198
|
+
if self._on_shell_chunk is not None:
|
|
199
|
+
self._on_shell_chunk("status", f"$ {command}\n")
|
|
200
|
+
async for event in self._shell.run_stream(
|
|
201
|
+
command, timeout=timeout or self._default_timeout
|
|
202
|
+
):
|
|
203
|
+
if self._on_shell_chunk is not None and hasattr(event, "kind"):
|
|
204
|
+
self._on_shell_chunk(getattr(event, "kind", "stdout"), getattr(event, "text", ""))
|
|
205
|
+
yield event
|
|
206
|
+
|
|
207
|
+
def _shell_decision(self, command: str) -> PermissionDecision:
|
|
208
|
+
decision = self._engine.decide(PermissionCategory.BASH, command)
|
|
209
|
+
if decision.denied or not self._engine.is_uncertain_shell(command):
|
|
210
|
+
return decision
|
|
211
|
+
# Shell syntax is too ambiguous to auto-approve safely. Interactive
|
|
212
|
+
# sessions may still approve the exact command once.
|
|
213
|
+
action = "deny" if self._engine.auto_approve else "ask"
|
|
214
|
+
reason = (
|
|
215
|
+
"compound/uncertain shell commands cannot be auto-approved"
|
|
216
|
+
if action == "deny"
|
|
217
|
+
else "compound/uncertain shell command requires approval"
|
|
218
|
+
)
|
|
219
|
+
return PermissionDecision(
|
|
220
|
+
category=PermissionCategory.BASH,
|
|
221
|
+
target=command,
|
|
222
|
+
action=action,
|
|
223
|
+
matching_rule=decision.matching_rule,
|
|
224
|
+
reason=reason,
|
|
225
|
+
remember_pattern=decision.remember_pattern,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@hidden
|
|
229
|
+
async def run_trusted_readonly(self, command: str) -> ShellResult:
|
|
230
|
+
"""Run a host-constructed, strictly read-only command without a model approval."""
|
|
231
|
+
if not self._engine.is_readonly_command(command):
|
|
232
|
+
raise PermissionError(f"trusted command is not read-only: {command}")
|
|
233
|
+
result = await self._shell.run(command, timeout=self._default_timeout)
|
|
234
|
+
return self._cap_shell_result(result)
|
|
235
|
+
|
|
236
|
+
def _cap_shell_result(self, result: ShellResult) -> ShellResult:
|
|
237
|
+
stdout = _truncate(result.stdout, self._max_output)
|
|
238
|
+
stderr = _truncate(result.stderr, self._max_output)
|
|
239
|
+
if stdout is result.stdout and stderr is result.stderr:
|
|
240
|
+
return result
|
|
241
|
+
return ShellResult(
|
|
242
|
+
stdout=stdout,
|
|
243
|
+
stderr=stderr,
|
|
244
|
+
returncode=result.returncode,
|
|
245
|
+
matches=result.matches,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def _authorize_path(self, path: str, category: str) -> Path:
|
|
249
|
+
try:
|
|
250
|
+
resolved = self._workspace.resolve(path)
|
|
251
|
+
rel = str(resolved.relative_to(self._workspace.root))
|
|
252
|
+
decision = self._engine.decide(category, rel)
|
|
253
|
+
except WorkspaceError as exc:
|
|
254
|
+
abs_path = Path(path).expanduser()
|
|
255
|
+
if not abs_path.is_absolute():
|
|
256
|
+
abs_path = (self._workspace.root / path).resolve()
|
|
257
|
+
else:
|
|
258
|
+
abs_path = abs_path.resolve()
|
|
259
|
+
decision = self._engine.decide(PermissionCategory.EXTERNAL_DIRECTORY, str(abs_path))
|
|
260
|
+
await self._approvals.require(decision)
|
|
261
|
+
raise WorkspaceError(
|
|
262
|
+
f"path outside workspace requires dedicated external handling: {path}"
|
|
263
|
+
) from exc
|
|
264
|
+
await self._approvals.require(decision)
|
|
265
|
+
return resolved
|
|
266
|
+
|
|
267
|
+
@hidden
|
|
268
|
+
async def close(self) -> None:
|
|
269
|
+
await self._shell.close()
|
noah_code/ui/__init__.py
ADDED
noah_code/ui/console.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Line-oriented console renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
|
|
13
|
+
from noah_code.approvals import ApprovalChoice, ApprovalRequest
|
|
14
|
+
from noah_code.events import HostEvent, HostEventKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConsoleUI:
|
|
18
|
+
"""Simple line-oriented console client for the host."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, markdown: bool = True, file: TextIO | None = None) -> None:
|
|
21
|
+
self.console = Console(file=file or sys.stdout)
|
|
22
|
+
self.markdown = markdown
|
|
23
|
+
self._status_line = ""
|
|
24
|
+
self._busy = False
|
|
25
|
+
|
|
26
|
+
def set_status(self, text: str) -> None:
|
|
27
|
+
self._status_line = text
|
|
28
|
+
|
|
29
|
+
def set_busy(self, busy: bool) -> None:
|
|
30
|
+
self._busy = busy
|
|
31
|
+
|
|
32
|
+
def render(self, event: HostEvent) -> None:
|
|
33
|
+
if event.kind == HostEventKind.MESSAGE:
|
|
34
|
+
if self.markdown:
|
|
35
|
+
self.console.print(Markdown(event.text))
|
|
36
|
+
else:
|
|
37
|
+
self.console.print(event.text)
|
|
38
|
+
elif event.kind == HostEventKind.REASONING:
|
|
39
|
+
self.console.print(f"[dim]thinking:[/dim] {event.text}")
|
|
40
|
+
elif event.kind == HostEventKind.TOOL_START:
|
|
41
|
+
self.console.print(f"[cyan]→[/cyan] {event.text}")
|
|
42
|
+
elif event.kind == HostEventKind.TOOL_FINISH:
|
|
43
|
+
self.console.print(f"[green]✓[/green] {event.text}")
|
|
44
|
+
elif event.kind == HostEventKind.SHELL_CHUNK:
|
|
45
|
+
stream = event.meta.get("stream", "stdout")
|
|
46
|
+
style = "red" if stream == "stderr" else "white"
|
|
47
|
+
self.console.print(event.text.rstrip("\n"), style=style, highlight=False)
|
|
48
|
+
elif event.kind == HostEventKind.ERROR:
|
|
49
|
+
self.console.print(f"[bold red]error:[/bold red] {event.text}")
|
|
50
|
+
elif event.kind == HostEventKind.SUMMARY:
|
|
51
|
+
self.console.print(Panel(event.text, title="summary", border_style="blue"))
|
|
52
|
+
elif event.kind == HostEventKind.STATUS:
|
|
53
|
+
self.console.print(f"[dim]{event.text}[/dim]")
|
|
54
|
+
elif event.kind == HostEventKind.STOP:
|
|
55
|
+
self.console.print(f"[bold]stop:[/bold] {event.text}")
|
|
56
|
+
else:
|
|
57
|
+
self.console.print(event.text)
|
|
58
|
+
|
|
59
|
+
async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
|
|
60
|
+
d = request.decision
|
|
61
|
+
self.console.print(
|
|
62
|
+
Panel(
|
|
63
|
+
f"[yellow]{d.category}[/yellow] {d.target}\n{d.reason}\n"
|
|
64
|
+
f"remember pattern: {d.remember_pattern}",
|
|
65
|
+
title=f"Approval {request.id[:8]}",
|
|
66
|
+
border_style="yellow",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
self.console.print("[1] once [2] session [3] reject")
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
choice = await asyncio.to_thread(input, "approve> ")
|
|
73
|
+
choice = choice.strip().lower()
|
|
74
|
+
except EOFError:
|
|
75
|
+
return ApprovalChoice.REJECT
|
|
76
|
+
if choice in {"1", "once", "o", "y", "yes"}:
|
|
77
|
+
return ApprovalChoice.ONCE
|
|
78
|
+
if choice in {"2", "session", "s"}:
|
|
79
|
+
return ApprovalChoice.SESSION
|
|
80
|
+
if choice in {"3", "reject", "r", "n", "no"}:
|
|
81
|
+
return ApprovalChoice.REJECT
|
|
82
|
+
self.console.print("Enter 1/2/3")
|
|
83
|
+
|
|
84
|
+
async def prompt(self, status: str) -> str | None:
|
|
85
|
+
try:
|
|
86
|
+
return await asyncio.to_thread(input, f"{status}> ")
|
|
87
|
+
except EOFError:
|
|
88
|
+
return None
|
noah_code/ui/protocol.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Host UI protocol - console and Textual implement this."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from noah_code.approvals import ApprovalChoice, ApprovalRequest
|
|
8
|
+
from noah_code.events import HostEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class HostUI(Protocol):
|
|
13
|
+
"""Thin UI client of AgentHost. No agent/permission logic here."""
|
|
14
|
+
|
|
15
|
+
def render(self, event: HostEvent) -> None:
|
|
16
|
+
"""Display a host event to the user."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
|
|
20
|
+
"""Prompt for an allow-once / session / reject decision."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
async def prompt(self, status: str) -> str | None:
|
|
24
|
+
"""Read the next user line. Return None on EOF/quit."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def set_status(self, text: str) -> None:
|
|
28
|
+
"""Update status chrome (optional for console)."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def set_busy(self, busy: bool) -> None:
|
|
32
|
+
"""Indicate whether a turn is in progress."""
|
|
33
|
+
...
|
noah_code/ui/textual.css
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Optional reference stylesheet - App embeds CSS to avoid token clashes.
|
|
2
|
+
Do not use Textual reserved names ($panel, $primary, …) here. */
|
|
3
|
+
|
|
4
|
+
$nc-bg: #14161a;
|
|
5
|
+
$nc-surface: #1c1f26;
|
|
6
|
+
$nc-border: #2a303a;
|
|
7
|
+
$nc-fg: #e6e8eb;
|
|
8
|
+
$nc-muted: #8b939e;
|
|
9
|
+
$nc-accent: #5b9fd4;
|