pcli-agent 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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,389 @@
1
+ """describe_tool: lets the model ask for more detail about any tool in its
2
+ current tool set - the full description, parameter schema, permission
3
+ requirements, and a handful of representative example calls with a
4
+ one-line explanation each.
5
+
6
+ Motivation: a tool's one-line entry in the `tools=[...]` payload sent to
7
+ the model is sometimes not enough to use it correctly on the first try -
8
+ edit_file's three mutually-exclusive modes (see fs_tools.py) are the
9
+ concrete case that prompted this: without a clear example, some models
10
+ guessed at how to insert text rather than asking. describe_tool gives the
11
+ model somewhere cheap to ask first, instead of guessing and getting a
12
+ permission-gated call wrong (or silently no-op'ing, as edit_file's old
13
+ old_string==new_string footgun used to).
14
+
15
+ Examples are hand-curated (_EXAMPLES below), not derived from the JSON
16
+ schema - a schema alone shows shape, not intent, and doesn't distinguish
17
+ "pick exactly one of these optional parameter groups" (edit_file) from
18
+ "only path is ever required, everything else is optional" (list_dir).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from typing import NamedTuple
25
+
26
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
27
+
28
+
29
+ class ToolExample(NamedTuple):
30
+ arguments: dict
31
+ explanation: str
32
+
33
+
34
+ # Tool name -> a handful of representative calls. Deliberately not
35
+ # exhaustive for every tool (some are self-explanatory from their
36
+ # description/schema alone - a plain "read_file(path)" needs no example) -
37
+ # curated where a tool has more than one mode, an easy-to-miss parameter,
38
+ # or a real reported point of confusion.
39
+ _EXAMPLES: dict[str, list[ToolExample]] = {
40
+ "read_file": [
41
+ ToolExample({"path": "src/app.py"}, "Read a file's full contents."),
42
+ ],
43
+ "write_file": [
44
+ ToolExample(
45
+ {"path": "notes.txt", "content": "first line\nsecond line\n"},
46
+ "Create a new file, or overwrite an existing one entirely.",
47
+ ),
48
+ ],
49
+ "edit_file": [
50
+ ToolExample(
51
+ {
52
+ "path": "src/app.py",
53
+ "old_string": "def total(a, b):\n return a - b",
54
+ "new_string": "def total(a, b):\n return a + b",
55
+ },
56
+ "Replace mode: old_string is an exact, unique, verbatim copy of existing "
57
+ "content; swap it for new_string.",
58
+ ),
59
+ ToolExample(
60
+ {
61
+ "path": "src/app.py",
62
+ "old_string": "\"user\"",
63
+ "new_string": "\"account\"",
64
+ "replace_all": True,
65
+ },
66
+ "Replace mode with replace_all: replace every occurrence at once (e.g. "
67
+ "renaming something throughout the file), instead of requiring uniqueness.",
68
+ ),
69
+ ToolExample(
70
+ {"path": "src/app.py", "insert_after_line": 12, "new_string": "import os"},
71
+ "Insert mode: add a new line after line 12 without touching anything else - "
72
+ "the correct way to insert text (never set new_string equal to old_string to "
73
+ "fake this in replace mode).",
74
+ ),
75
+ ToolExample(
76
+ {"path": "src/app.py", "insert_after_line": 0, "new_string": "#!/usr/bin/env python3"},
77
+ "Insert mode with insert_after_line=0: insert at the very start of the file, "
78
+ "before the first line.",
79
+ ),
80
+ ToolExample(
81
+ {"path": "src/app.py", "delete_start_line": 40, "delete_end_line": 45},
82
+ "Delete mode: remove lines 40 through 45 inclusive. Re-check line numbers with "
83
+ "read_file/grep -n first - they shift after every edit to the same file.",
84
+ ),
85
+ ],
86
+ "list_dir": [
87
+ ToolExample({"path": "src/pcli/tools"}, "List a directory's immediate contents."),
88
+ ToolExample({}, "List the working directory itself (path defaults to '.')."),
89
+ ],
90
+ "glob_search": [
91
+ ToolExample(
92
+ {"pattern": "**/*.py", "path": "src"},
93
+ "Find every .py file anywhere under src/, recursively.",
94
+ ),
95
+ ],
96
+ "grep": [
97
+ ToolExample(
98
+ {"pattern": r"def \w+_tool\(", "path": "src/pcli/tools", "glob": "**/*.py"},
99
+ "Search for a regex pattern across text files under a directory.",
100
+ ),
101
+ ],
102
+ "diff_files": [
103
+ ToolExample(
104
+ {"path_a": "config.old.toml", "path_b": "config.toml"},
105
+ "Show a unified diff between two existing files - read-only, doesn't change "
106
+ "either one.",
107
+ ),
108
+ ],
109
+ "apply_patch": [
110
+ ToolExample(
111
+ {
112
+ "path": "src/app.py",
113
+ "patch": "--- a/src/app.py\n+++ b/src/app.py\n@@ -1,3 +1,3 @@\n line one\n-old line\n+new line\n line three\n",
114
+ },
115
+ "Apply a unified diff (e.g. one diff_files just produced) in one call - requires "
116
+ "an exact match against the file's current content, no fuzzy offsets.",
117
+ ),
118
+ ],
119
+ "run_shell": [
120
+ ToolExample({"command": "pytest tests/ -q"}, "Run a command and get its output/exit code back immediately."),
121
+ ToolExample(
122
+ {"command": "git status", "timeout_s": 30},
123
+ "Override the default timeout for a command expected to take a while.",
124
+ ),
125
+ ],
126
+ "run_shell_background": [
127
+ ToolExample(
128
+ {"command": "python -m http.server 8000"},
129
+ "Start a long-running/server-like command that shouldn't block the turn - "
130
+ "returns a job_id immediately instead of waiting for it to exit.",
131
+ ),
132
+ ],
133
+ "read_background_output": [
134
+ ToolExample(
135
+ {"job_id": "job_abc123"},
136
+ "Check on a background job's output so far without stopping it.",
137
+ ),
138
+ ],
139
+ "stop_background_process": [
140
+ ToolExample({"job_id": "job_abc123"}, "Kill a background job started by run_shell_background."),
141
+ ],
142
+ "download_file": [
143
+ ToolExample(
144
+ {"url": "https://example.test/dataset.csv", "path": "data/dataset.csv"},
145
+ "Save a URL's content to disk - for binary/large files, not for reading text "
146
+ "(use web_fetch for that instead).",
147
+ ),
148
+ ],
149
+ "web_fetch": [
150
+ ToolExample(
151
+ {"url": "https://example.test/docs/api"},
152
+ "Fetch a URL and get back its readable text (HTML converted to plain text).",
153
+ ),
154
+ ],
155
+ "web_search": [
156
+ ToolExample(
157
+ {"query": "python asyncio cancel task"},
158
+ "Short, keyword-based query (2-6 words) - not a full question or sentence.",
159
+ ),
160
+ ],
161
+ "pip_install": [
162
+ ToolExample({"packages": ["requests", "pydantic>=2"]}, "Install one or more packages by name/specifier."),
163
+ ToolExample(
164
+ {"requirements_file": "requirements.txt"},
165
+ "Install everything listed in a requirements-style file instead.",
166
+ ),
167
+ ],
168
+ "search_python": [
169
+ ToolExample({"query": "parse a URL"}, "Find installed Python modules/functions relevant to a task, by description."),
170
+ ],
171
+ "inspect_python_module": [
172
+ ToolExample(
173
+ {"module": "httpx", "query": "how to set a timeout"},
174
+ "See a specific installed module's actual signatures/docstrings, once you know its name.",
175
+ ),
176
+ ],
177
+ "call_python": [
178
+ ToolExample(
179
+ {"qualified_name": "json.dumps", "kwargs": {"obj": {"a": 1}, "indent": 2}},
180
+ "Call an installed Python function directly and get its return value back, "
181
+ "without writing/running a throwaway script.",
182
+ ),
183
+ ],
184
+ "write_todos": [
185
+ ToolExample(
186
+ {
187
+ "todos": [
188
+ {"content": "Read the failing test", "status": "completed"},
189
+ {"content": "Fix the off-by-one bug", "status": "in_progress"},
190
+ {"content": "Re-run the test suite", "status": "pending"},
191
+ ]
192
+ },
193
+ "Submit the FULL current list every time (this replaces it, it doesn't append) "
194
+ "- exactly one item may be 'in_progress' at a time.",
195
+ ),
196
+ ],
197
+ "record_decision": [
198
+ ToolExample(
199
+ {
200
+ "decision": "Use httpx instead of requests",
201
+ "rationale": "the codebase is already async elsewhere; requests has no native async support",
202
+ },
203
+ "Log a consequential decision and why - builds a persistent, exportable audit "
204
+ "trail, separate from the todo list.",
205
+ ),
206
+ ],
207
+ "remember": [
208
+ ToolExample(
209
+ {"content": "Prefers terse responses with no trailing summary", "category": "preference"},
210
+ "Save something durable about the user to global, cross-session memory - not "
211
+ "for task-scoped facts that only matter to the current session.",
212
+ ),
213
+ ],
214
+ "ask_user_question": [
215
+ ToolExample(
216
+ {"question": "Should I use PostgreSQL or SQLite for this?", "options": ["PostgreSQL", "SQLite"]},
217
+ "Pause and ask the user directly when a decision genuinely can't be made without "
218
+ "them - not a first resort for anything you could reasonably infer or verify.",
219
+ ),
220
+ ],
221
+ "fetch_artifact": [
222
+ ToolExample(
223
+ {"artifact_id": "art_001a0bf445376b27a5fa718"},
224
+ "Retrieve a large tool result or compacted transcript that was archived out of "
225
+ "the live conversation (see its own \"archived as artifact_id='...'\" note).",
226
+ ),
227
+ ToolExample(
228
+ {"artifact_id": "art_001a0bf445376b27a5fa718", "pattern": "TODO", "context_lines": 2},
229
+ "Search within a large archived artifact instead of pulling in the whole thing.",
230
+ ),
231
+ ],
232
+ "ask_artifact": [
233
+ ToolExample(
234
+ {"artifact_id": "art_001a0bf445376b27a5fa718", "question": "What was the final error message?"},
235
+ "Local-api mode only: ask a question about a large artifact and get a direct "
236
+ "answer back (an extra LLM call, free on a local gateway) instead of reading the "
237
+ "raw content yourself.",
238
+ ),
239
+ ],
240
+ "spawn_subagent": [
241
+ ToolExample(
242
+ {"task": "Find every place JWT tokens are validated in this codebase and summarize the logic"},
243
+ "Delegate a self-contained, well-scoped investigation to a nested agent - its "
244
+ "intermediate tool calls stay out of the main conversation, only its final "
245
+ "answer comes back.",
246
+ ),
247
+ ],
248
+ "explore_codebase": [
249
+ ToolExample(
250
+ {"query": "how is user authentication implemented?"},
251
+ "A pre-configured subagent for read-only codebase investigation - faster to "
252
+ "reach for than spawn_subagent when the task is exactly this shape.",
253
+ ),
254
+ ],
255
+ "explore_files": [
256
+ ToolExample({"query": "find all config files and summarize what each one controls"}, "A pre-configured subagent for broad file discovery/summarization."),
257
+ ],
258
+ "explore_logs": [
259
+ ToolExample({"query": "find the root cause of the crash in the most recent log file"}, "A pre-configured subagent for digging through log output."),
260
+ ],
261
+ "verify_computation": [
262
+ ToolExample({"query": "double-check this SQL migration is safe against a 50M-row table"}, "A pre-configured subagent for independently verifying a claim/calculation."),
263
+ ],
264
+ "deep_research": [
265
+ ToolExample({"query": "compare the tradeoffs of gRPC vs REST for this service"}, "A pre-configured subagent for a broader, multi-source research task."),
266
+ ],
267
+ "data_analysis": [
268
+ ToolExample({"query": "summarize trends in sales.csv"}, "A pre-configured subagent for analyzing data files."),
269
+ ],
270
+ "write_documentation": [
271
+ ToolExample({"query": "write a README section explaining the new config option"}, "A pre-configured subagent for drafting documentation."),
272
+ ],
273
+ "register_agent_tool": [
274
+ ToolExample(
275
+ {
276
+ "name": "changelog_writer",
277
+ "description": "Drafts a changelog entry from a git diff.",
278
+ "persona_prompt": "You write terse, user-facing changelog entries from a diff.",
279
+ "allowed_tools": ["run_shell", "read_file"],
280
+ },
281
+ "Create a new, reusable, narrowly-scoped agent tool the model (or a future turn) "
282
+ "can call by name, like the built-in explore_*/deep_research tools.",
283
+ ),
284
+ ],
285
+ "register_toolbox_tool": [
286
+ ToolExample(
287
+ {"name": "kubectl"},
288
+ "Discover an installed CLI on PATH and turn its subcommands into callable tools.",
289
+ ),
290
+ ToolExample(
291
+ {"name": "my_script", "path": "scripts/my_script.py"},
292
+ "Register a self-authored script directly, bypassing PATH lookup.",
293
+ ),
294
+ ],
295
+ "browser_navigate": [
296
+ ToolExample({"url": "https://example.test/dashboard"}, "Start/continue a browser session by navigating to a URL."),
297
+ ],
298
+ "browser_click": [
299
+ ToolExample({"selector": "text=Sign in"}, "Click an element, identified by a Playwright locator (CSS, or text=... for visible text)."),
300
+ ],
301
+ "browser_type": [
302
+ ToolExample({"selector": "#username", "text": "alice"}, "Type into a field, replacing whatever was already there."),
303
+ ],
304
+ "browser_press_key": [
305
+ ToolExample({"key": "Enter"}, "Send a single keypress - e.g. to submit a form after browser_type."),
306
+ ],
307
+ "browser_wait_for": [
308
+ ToolExample(
309
+ {"selector": "#results", "timeout_s": 15},
310
+ "Wait for an element to appear before continuing, instead of guessing how long "
311
+ "to sleep after an action that loads content asynchronously.",
312
+ ),
313
+ ],
314
+ "browser_read_page": [
315
+ ToolExample({}, "Read the visible text of the currently open page - no arguments needed."),
316
+ ],
317
+ "browser_screenshot": [
318
+ ToolExample({}, "Save a screenshot of the currently open page to disk and get back its path."),
319
+ ],
320
+ }
321
+
322
+
323
+ def _format_examples(name: str) -> str:
324
+ examples = _EXAMPLES.get(name)
325
+ if not examples:
326
+ return (
327
+ "(No curated examples for this tool yet - its description and parameter "
328
+ "schema above should be enough; it's a simple, single-purpose call.)"
329
+ )
330
+ lines = []
331
+ for i, example in enumerate(examples, start=1):
332
+ call = json.dumps(example.arguments, ensure_ascii=False)
333
+ lines.append(f"{i}. {name}({call})\n {example.explanation}")
334
+ return "\n".join(lines)
335
+
336
+
337
+ async def _describe_tool(arguments: dict, ctx: ToolContext) -> ToolResult:
338
+ name = arguments["name"]
339
+ if ctx.tool_registry is None:
340
+ return ToolResult(output="No tool registry available in this context.", is_error=True)
341
+ tool = ctx.tool_registry.get(name)
342
+ if tool is None:
343
+ available = ", ".join(sorted(t.name for t in ctx.tool_registry))
344
+ return ToolResult(
345
+ output=f"No tool named '{name}' in your current tool set.\n"
346
+ f"[pcli] Suggestion: check the exact spelling - available tools: {available}",
347
+ is_error=True,
348
+ )
349
+ permission_line = f"Needs permission: {tool.needs_permission}"
350
+ if tool.needs_permission and tool.risk_description:
351
+ permission_line += f" — {tool.risk_description}"
352
+ parts = [
353
+ f"# {tool.name}",
354
+ tool.description,
355
+ "",
356
+ permission_line,
357
+ f"Available in plan mode: {tool.plan_mode_safe}",
358
+ "",
359
+ "## Parameters (JSON schema)",
360
+ json.dumps(tool.parameters, indent=2, ensure_ascii=False),
361
+ "",
362
+ "## Example calls",
363
+ _format_examples(name),
364
+ ]
365
+ return ToolResult(output="\n".join(parts))
366
+
367
+
368
+ DESCRIBE_TOOL = ToolSpec(
369
+ name="describe_tool",
370
+ description="Get full detail about any tool in your current tool set — its complete "
371
+ "description, parameter schema, permission requirements, and a few representative "
372
+ "example calls with a one-line explanation each. Use this before an unfamiliar or "
373
+ "tricky call (e.g. one with several optional/mutually-exclusive parameters) instead of "
374
+ "guessing at the right arguments from its one-line summary alone.",
375
+ parameters={
376
+ "type": "object",
377
+ "properties": {
378
+ "name": {
379
+ "type": "string",
380
+ "description": "Exact name of the tool to describe, e.g. 'edit_file'.",
381
+ }
382
+ },
383
+ "required": ["name"],
384
+ },
385
+ handler=_describe_tool,
386
+ needs_permission=False,
387
+ plan_mode_safe=True,
388
+ read_only=True,
389
+ )
@@ -0,0 +1,225 @@
1
+ """diff_files/apply_patch: pure-Python replacements for the diff/patch CLIs
2
+ — neither ships with Windows, and shelling out to them would reintroduce
3
+ the exact cross-platform fragility download_file was built to avoid for
4
+ curl/wget. Built on difflib (diffing) and a small unified-diff applier
5
+ (patching, since difflib doesn't include one)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import re
11
+
12
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
13
+ from pcli.tools.builtin.fs_tools import resolve_path
14
+
15
+ _HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
16
+
17
+
18
+ async def _diff_files(arguments: dict, ctx: ToolContext) -> ToolResult:
19
+ raw_a, raw_b = arguments["path_a"], arguments["path_b"]
20
+ path_a = resolve_path(raw_a, ctx)
21
+ path_b = resolve_path(raw_b, ctx)
22
+
23
+ # Only path_a goes through the automatic guardrail_path_arg check
24
+ # (AgentLoop only threads one path argument per tool call) — path_b
25
+ # needs the same check done by hand here.
26
+ guardrail_result = ctx.guardrails.evaluate_path(str(path_b))
27
+ if not guardrail_result.allowed:
28
+ return ToolResult(output=f"path_b denied: {guardrail_result.reason}", is_error=True)
29
+
30
+ for label, raw, resolved in (("path_a", raw_a, path_a), ("path_b", raw_b, path_b)):
31
+ if not resolved.is_file():
32
+ return ToolResult(
33
+ output=f"{label} '{raw}' does not exist or is not a file.\n"
34
+ "[pcli] Suggestion: check the path with list_dir or glob_search before diffing.",
35
+ is_error=True,
36
+ )
37
+
38
+ lines_a = path_a.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
39
+ lines_b = path_b.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
40
+ context_lines = int(arguments.get("context_lines", 3))
41
+ diff_lines = list(
42
+ difflib.unified_diff(lines_a, lines_b, fromfile=raw_a, tofile=raw_b, n=context_lines)
43
+ )
44
+ if not diff_lines:
45
+ return ToolResult(output="No differences.")
46
+ # A line derived from a file whose last line has no trailing newline
47
+ # comes back from difflib without one too — joining it directly onto
48
+ # the next line would silently merge the two into one bogus line, so
49
+ # every line needs its own terminator regardless of the source file's
50
+ # own trailing-newline status (apply_patch tracks that separately, from
51
+ # the target file it's patching, not from the diff text).
52
+ text = "".join(line if line.endswith("\n") else line + "\n" for line in diff_lines)
53
+ return ToolResult(output=text)
54
+
55
+
56
+ DIFF_FILES = ToolSpec(
57
+ name="diff_files",
58
+ description="Compare two text files and return a unified diff (like `diff -u`), without "
59
+ "depending on a diff CLI being installed. Useful for reviewing a change or comparing two "
60
+ "versions of a file.",
61
+ parameters={
62
+ "type": "object",
63
+ "properties": {
64
+ "path_a": {"type": "string", "description": "First file (the 'before' side)."},
65
+ "path_b": {"type": "string", "description": "Second file (the 'after' side)."},
66
+ "context_lines": {
67
+ "type": "integer",
68
+ "description": "Lines of unchanged context around each change (default 3).",
69
+ },
70
+ },
71
+ "required": ["path_a", "path_b"],
72
+ },
73
+ handler=_diff_files,
74
+ needs_permission=False,
75
+ guardrail_path_arg="path_a",
76
+ plan_mode_safe=True,
77
+ read_only=True,
78
+ )
79
+
80
+
81
+ class _PatchError(Exception):
82
+ pass
83
+
84
+
85
+ def _parse_hunks(patch_text: str) -> list[tuple[int, list[tuple[str, str]]]]:
86
+ """Parses unified-diff hunks into (old_start_line, [(tag, content), ...])
87
+ pairs, tag being ' ' (context), '-' (removed) or '+' (added). File
88
+ header lines (---/+++) and anything before the first '@@' are ignored,
89
+ matching how `diff -u`/`git diff` output looks in practice."""
90
+ lines = patch_text.splitlines()
91
+ hunks: list[tuple[int, list[tuple[str, str]]]] = []
92
+ i = 0
93
+ while i < len(lines):
94
+ match = _HUNK_HEADER_RE.match(lines[i])
95
+ if not match:
96
+ i += 1
97
+ continue
98
+ old_start = int(match.group(1))
99
+ old_count = int(match.group(2)) if match.group(2) is not None else 1
100
+ new_count = int(match.group(4)) if match.group(4) is not None else 1
101
+ i += 1
102
+ body: list[tuple[str, str]] = []
103
+ consumed_old = consumed_new = 0
104
+ while i < len(lines) and (consumed_old < old_count or consumed_new < new_count):
105
+ raw = lines[i]
106
+ if raw.startswith("\\"): # ""
107
+ i += 1
108
+ continue
109
+ tag = raw[0] if raw else " "
110
+ content = raw[1:] if raw else ""
111
+ if tag not in (" ", "-", "+"):
112
+ raise _PatchError(f"Unrecognized line in hunk body: {raw!r}")
113
+ body.append((tag, content))
114
+ if tag != "+":
115
+ consumed_old += 1
116
+ if tag != "-":
117
+ consumed_new += 1
118
+ i += 1
119
+ hunks.append((old_start, body))
120
+ if not hunks:
121
+ raise _PatchError(
122
+ "No hunks found — expected unified diff format with '@@ -start,count "
123
+ "+start,count @@' headers."
124
+ )
125
+ return hunks
126
+
127
+
128
+ def _apply_hunks(original_lines: list[str], hunks: list[tuple[int, list[tuple[str, str]]]]) -> list[str]:
129
+ result: list[str] = []
130
+ cursor = 0
131
+ for old_start, body in hunks:
132
+ start_index = old_start - 1
133
+ if start_index < cursor:
134
+ raise _PatchError(
135
+ f"Hunk at line {old_start} overlaps the previous hunk — the patch's hunks must "
136
+ "be in order and non-overlapping."
137
+ )
138
+ if start_index > len(original_lines):
139
+ raise _PatchError(
140
+ f"Hunk at line {old_start} starts past the end of the file "
141
+ f"({len(original_lines)} lines)."
142
+ )
143
+ result.extend(original_lines[cursor:start_index])
144
+ cursor = start_index
145
+ for tag, content in body:
146
+ actual = original_lines[cursor] if cursor < len(original_lines) else None
147
+ if tag == " ":
148
+ if actual != content:
149
+ raise _PatchError(
150
+ f"Context mismatch at line {cursor + 1}: patch expects {content!r}, "
151
+ f"file has {actual!r}."
152
+ )
153
+ result.append(content)
154
+ cursor += 1
155
+ elif tag == "-":
156
+ if actual != content:
157
+ raise _PatchError(
158
+ f"Line to remove doesn't match at line {cursor + 1}: patch expects "
159
+ f"{content!r}, file has {actual!r}."
160
+ )
161
+ cursor += 1
162
+ else: # "+"
163
+ result.append(content)
164
+ result.extend(original_lines[cursor:])
165
+ return result
166
+
167
+
168
+ async def _apply_patch(arguments: dict, ctx: ToolContext) -> ToolResult:
169
+ resolved = resolve_path(arguments["path"], ctx)
170
+ if not resolved.is_file():
171
+ return ToolResult(
172
+ output=f"{resolved} doesn't exist — use write_file to create it instead of patching "
173
+ "a file that isn't there yet.",
174
+ is_error=True,
175
+ )
176
+
177
+ original_text = resolved.read_text(encoding="utf-8")
178
+ original_lines = original_text.splitlines()
179
+ had_trailing_newline = original_text == "" or original_text.endswith("\n")
180
+
181
+ try:
182
+ hunks = _parse_hunks(arguments["patch"])
183
+ new_lines = _apply_hunks(original_lines, hunks)
184
+ except _PatchError as exc:
185
+ return ToolResult(
186
+ output=f"Failed to apply patch: {exc}\n"
187
+ "[pcli] Suggestion: this tool requires an exact match against the file's current "
188
+ "content (no fuzzy offsets like the `patch` CLI) — the file has likely changed since "
189
+ "the patch was generated. Read the file with read_file, regenerate the diff against "
190
+ "its current content (diff_files), or use edit_file for a single targeted change "
191
+ "instead.",
192
+ is_error=True,
193
+ )
194
+
195
+ new_text = "\n".join(new_lines)
196
+ if new_lines and had_trailing_newline:
197
+ new_text += "\n"
198
+ resolved.write_text(new_text, encoding="utf-8")
199
+ return ToolResult(output=f"Applied patch to {resolved} ({len(hunks)} hunk(s)).")
200
+
201
+
202
+ APPLY_PATCH = ToolSpec(
203
+ name="apply_patch",
204
+ description="Apply a unified diff (as produced by diff_files or `git diff`) to a file, "
205
+ "modifying it in place. Requires the file's current content to exactly match the patch's "
206
+ "context/removed lines — unlike the `patch` CLI, it does not fuzzy-match on offset. Use this "
207
+ "to apply a multi-hunk change in one call instead of several edit_file calls; for a single "
208
+ "small change, edit_file is simpler and more robust.",
209
+ parameters={
210
+ "type": "object",
211
+ "properties": {
212
+ "path": {"type": "string", "description": "File to patch, relative or absolute."},
213
+ "patch": {
214
+ "type": "string",
215
+ "description": "Unified diff text (containing '@@ ... @@' hunk headers) to apply.",
216
+ },
217
+ },
218
+ "required": ["path", "patch"],
219
+ },
220
+ handler=_apply_patch,
221
+ needs_permission=True,
222
+ risk_description="Modifies a file on disk by applying a patch.",
223
+ guardrail_path_arg="path",
224
+ read_only=False,
225
+ )