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,117 @@
1
+ """search_python: coarse, in-process, name/summary-only search (tier 1) —
2
+ safe, no imports, no permission needed.
3
+
4
+ inspect_python_module: imports one specific module to list its public
5
+ functions/classes (tier 2) — this DOES import third-party code, so it's
6
+ permission-gated and runs through invoke.run_bootstrap's isolated subprocess.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
12
+ from pcli.tools.pydiscovery.cache import load_or_build_index
13
+ from pcli.tools.pydiscovery.invoke import run_bootstrap
14
+
15
+ _MAX_RESULTS = 50
16
+
17
+
18
+ async def _search_python(arguments: dict, ctx: ToolContext) -> ToolResult:
19
+ query = arguments.get("query", "").strip().lower()
20
+ entries = load_or_build_index()
21
+ matches = [
22
+ e for e in entries if not query or query in e.name.lower() or query in e.summary.lower()
23
+ ]
24
+
25
+ if not matches:
26
+ return ToolResult(output="No matching modules/packages found.")
27
+
28
+ lines = [f"{e.name} - {e.summary}" if e.summary else e.name for e in matches[:_MAX_RESULTS]]
29
+ text = "\n".join(lines)
30
+ if len(matches) > _MAX_RESULTS:
31
+ text += f"\n[...{len(matches) - _MAX_RESULTS} more matches not shown, narrow your query...]"
32
+ text += (
33
+ "\n\n(Name/summary index only — nothing was imported. Use inspect_python_module to "
34
+ "see the functions/classes inside a specific module.)"
35
+ )
36
+ return ToolResult(output=text)
37
+
38
+
39
+ SEARCH_PYTHON = ToolSpec(
40
+ name="search_python",
41
+ description="Search installed Python packages/modules (stdlib + site-packages) by name or "
42
+ "summary, without importing anything. Use this first to find candidate modules, then "
43
+ "inspect_python_module to see what's inside one.",
44
+ parameters={
45
+ "type": "object",
46
+ "properties": {
47
+ "query": {
48
+ "type": "string",
49
+ "description": "Keyword(s) to match against module names and package summaries. "
50
+ "Empty string lists everything.",
51
+ }
52
+ },
53
+ "required": ["query"],
54
+ },
55
+ handler=_search_python,
56
+ needs_permission=False,
57
+ plan_mode_safe=True,
58
+ read_only=True,
59
+ )
60
+
61
+
62
+ async def _inspect_python_module(arguments: dict, ctx: ToolContext) -> ToolResult:
63
+ module = arguments["module"]
64
+ payload = {"mode": "inspect", "module": module, "query": arguments.get("query", "")}
65
+ try:
66
+ data = await run_bootstrap(ctx, payload)
67
+ except RuntimeError as exc:
68
+ return ToolResult(
69
+ output=f"Failed to inspect module '{module}': {exc}\n"
70
+ "[pcli] Suggestion: run search_python first to confirm the exact module name is "
71
+ "actually installed.",
72
+ is_error=True,
73
+ )
74
+
75
+ if not data.get("ok"):
76
+ return ToolResult(
77
+ output=f"{data.get('error', 'unknown error')}\n"
78
+ "[pcli] Suggestion: run search_python first to confirm the exact module name is "
79
+ "actually installed.",
80
+ is_error=True,
81
+ )
82
+
83
+ members = data["members"]
84
+ if not members:
85
+ return ToolResult(output=f"No matching members found in module '{module}'.")
86
+ lines = [
87
+ f"{m['kind']} {m['name']}{m['signature']}" + (f" - {m['doc']}" if m["doc"] else "")
88
+ for m in members
89
+ ]
90
+ return ToolResult(output="\n".join(lines))
91
+
92
+
93
+ INSPECT_PYTHON_MODULE = ToolSpec(
94
+ name="inspect_python_module",
95
+ description="Import and introspect a specific Python module to see its public functions "
96
+ "and classes (name, signature, one-line docstring). This actually imports the module "
97
+ "(in an isolated subprocess), so use search_python first to find the right module name.",
98
+ parameters={
99
+ "type": "object",
100
+ "properties": {
101
+ "module": {
102
+ "type": "string",
103
+ "description": "Fully-qualified module name to import, e.g. 'json' or "
104
+ "'xml.etree.ElementTree'.",
105
+ },
106
+ "query": {"type": "string", "description": "Optional keyword to filter member names."},
107
+ },
108
+ "required": ["module"],
109
+ },
110
+ handler=_inspect_python_module,
111
+ needs_permission=True,
112
+ needs_sandbox=True,
113
+ risk_description="Imports a Python module, which may execute module-level side effects.",
114
+ guardrail_python_module_arg="module",
115
+ plan_mode_safe=True,
116
+ read_only=True,
117
+ )
pcli/tools/registry.py ADDED
@@ -0,0 +1,138 @@
1
+ """Name -> ToolSpec lookup, and the OpenAI `tools=[...]` payload builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from pcli.llm.models import ToolDefinition
8
+ from pcli.tools.base import ToolSpec
9
+
10
+
11
+ class ToolRegistry:
12
+ def __init__(self) -> None:
13
+ self._tools: dict[str, ToolSpec] = {}
14
+
15
+ def register(self, tool: ToolSpec) -> None:
16
+ self._tools[tool.name] = tool
17
+
18
+ def merge(self, other: ToolRegistry) -> None:
19
+ for tool in other:
20
+ self.register(tool)
21
+
22
+ def filtered(self, predicate: Callable[[ToolSpec], bool]) -> ToolRegistry:
23
+ """A new registry containing only the tools predicate(tool) accepts —
24
+ the shared subsetting primitive behind spawn_subagent's allowed_tools,
25
+ plan mode's tool filtering, and agent tools' fixed allowed-tool sets."""
26
+ subset = ToolRegistry()
27
+ for tool in self:
28
+ if predicate(tool):
29
+ subset.register(tool)
30
+ return subset
31
+
32
+ def get(self, name: str) -> ToolSpec | None:
33
+ return self._tools.get(name)
34
+
35
+ def to_openai_tools(self) -> list[ToolDefinition]:
36
+ return [tool.to_openai_tool() for tool in self._tools.values()]
37
+
38
+ def __contains__(self, name: str) -> bool:
39
+ return name in self._tools
40
+
41
+ def __iter__(self):
42
+ return iter(self._tools.values())
43
+
44
+ def __len__(self) -> int:
45
+ return len(self._tools)
46
+
47
+
48
+ def build_default_registry() -> ToolRegistry:
49
+ from pcli.tools.agent_tools import (
50
+ DATA_ANALYSIS,
51
+ DEEP_RESEARCH,
52
+ EXPLORE_CODEBASE,
53
+ EXPLORE_FILES,
54
+ EXPLORE_LOGS,
55
+ VERIFY_COMPUTATION,
56
+ WRITE_DOCUMENTATION,
57
+ )
58
+ from pcli.tools.builtin.agent_tool_register_tool import REGISTER_AGENT_TOOL
59
+ from pcli.tools.builtin.artifact_tool import ASK_ARTIFACT, FETCH_ARTIFACT
60
+ from pcli.tools.builtin.ask_tool import ASK_USER_QUESTION
61
+ from pcli.tools.builtin.browser_tool import (
62
+ BROWSER_CLICK,
63
+ BROWSER_NAVIGATE,
64
+ BROWSER_PRESS_KEY,
65
+ BROWSER_READ_PAGE,
66
+ BROWSER_SCREENSHOT,
67
+ BROWSER_TYPE,
68
+ BROWSER_WAIT_FOR,
69
+ )
70
+ from pcli.tools.builtin.decision_tool import RECORD_DECISION
71
+ from pcli.tools.builtin.describe_tool import DESCRIBE_TOOL
72
+ from pcli.tools.builtin.diff_tools import APPLY_PATCH, DIFF_FILES
73
+ from pcli.tools.builtin.fs_tools import EDIT_FILE, GLOB_SEARCH, LIST_DIR, READ_FILE, WRITE_FILE
74
+ from pcli.tools.builtin.grep_tool import GREP
75
+ from pcli.tools.builtin.memory_tool import REMEMBER
76
+ from pcli.tools.builtin.network_tools import DOWNLOAD_FILE
77
+ from pcli.tools.builtin.pip_tool import PIP_INSTALL
78
+ from pcli.tools.builtin.shell_tool import (
79
+ READ_BACKGROUND_OUTPUT,
80
+ RUN_SHELL,
81
+ RUN_SHELL_BACKGROUND,
82
+ STOP_BACKGROUND_PROCESS,
83
+ )
84
+ from pcli.tools.builtin.subagent_tool import SPAWN_SUBAGENT
85
+ from pcli.tools.builtin.todo_tool import WRITE_TODOS
86
+ from pcli.tools.builtin.toolbox_register_tool import REGISTER_TOOLBOX_TOOL
87
+ from pcli.tools.builtin.web_tools import WEB_FETCH, WEB_SEARCH
88
+ from pcli.tools.pydiscovery.invoke import CALL_PYTHON
89
+ from pcli.tools.pydiscovery.search import INSPECT_PYTHON_MODULE, SEARCH_PYTHON
90
+
91
+ registry = ToolRegistry()
92
+ for tool in (
93
+ READ_FILE,
94
+ WRITE_FILE,
95
+ EDIT_FILE,
96
+ LIST_DIR,
97
+ GLOB_SEARCH,
98
+ GREP,
99
+ DOWNLOAD_FILE,
100
+ WEB_FETCH,
101
+ WEB_SEARCH,
102
+ DIFF_FILES,
103
+ APPLY_PATCH,
104
+ RUN_SHELL,
105
+ RUN_SHELL_BACKGROUND,
106
+ READ_BACKGROUND_OUTPUT,
107
+ STOP_BACKGROUND_PROCESS,
108
+ PIP_INSTALL,
109
+ SEARCH_PYTHON,
110
+ INSPECT_PYTHON_MODULE,
111
+ CALL_PYTHON,
112
+ SPAWN_SUBAGENT,
113
+ ASK_USER_QUESTION,
114
+ WRITE_TODOS,
115
+ RECORD_DECISION,
116
+ REMEMBER,
117
+ FETCH_ARTIFACT,
118
+ ASK_ARTIFACT,
119
+ REGISTER_TOOLBOX_TOOL,
120
+ EXPLORE_CODEBASE,
121
+ EXPLORE_FILES,
122
+ EXPLORE_LOGS,
123
+ WRITE_DOCUMENTATION,
124
+ VERIFY_COMPUTATION,
125
+ DEEP_RESEARCH,
126
+ DATA_ANALYSIS,
127
+ REGISTER_AGENT_TOOL,
128
+ BROWSER_NAVIGATE,
129
+ BROWSER_CLICK,
130
+ BROWSER_TYPE,
131
+ BROWSER_PRESS_KEY,
132
+ BROWSER_WAIT_FOR,
133
+ BROWSER_READ_PAGE,
134
+ BROWSER_SCREENSHOT,
135
+ DESCRIBE_TOOL,
136
+ ):
137
+ registry.register(tool)
138
+ return registry
File without changes
@@ -0,0 +1,48 @@
1
+ """Generic `--help` walker for software without a curated plugin. Best-effort
2
+ text corpus collection that feeds into synthesize.py — not a man-page/argparse
3
+ parser, just enough structure-guessing to find likely subcommands."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from pcli.sandbox.base import ExecRequest
11
+ from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
12
+
13
+ _SUBCOMMAND_RE = re.compile(r"^\s{2,4}([a-z][a-z0-9_-]{1,30})\s{2,}\S", re.MULTILINE)
14
+ _MAX_SUBCOMMANDS = 8
15
+
16
+
17
+ async def _run_help(invocation: list[str], args: list[str], cwd: Path) -> str:
18
+ sandbox = RestrictedSubprocessSandbox(allowed_roots=[cwd])
19
+ result = await sandbox.execute(
20
+ ExecRequest(command=[*invocation, *args], cwd=cwd, timeout_s=10)
21
+ )
22
+ return (result.stdout + "\n" + result.stderr).strip()
23
+
24
+
25
+ def _guess_subcommands(help_text: str) -> list[str]:
26
+ seen: list[str] = []
27
+ for match in _SUBCOMMAND_RE.finditer(help_text):
28
+ word = match.group(1)
29
+ if word not in seen:
30
+ seen.append(word)
31
+ return seen[:_MAX_SUBCOMMANDS]
32
+
33
+
34
+ async def collect_help_corpus(invocation: list[str], cwd: Path) -> str:
35
+ """invocation is the argv prefix used to run the target — [binary_path]
36
+ for a plain executable on PATH, or [sys.executable, script_path] for a
37
+ self-authored .py script that needs an interpreter (see
38
+ ToolboxManager.discover's path= parameter)."""
39
+ label = " ".join(invocation)
40
+ top_help = await _run_help(invocation, ["--help"], cwd)
41
+ parts = [f"$ {label} --help\n{top_help}"]
42
+
43
+ for subcommand in _guess_subcommands(top_help):
44
+ sub_help = await _run_help(invocation, [subcommand, "--help"], cwd)
45
+ if sub_help:
46
+ parts.append(f"$ {label} {subcommand} --help\n{sub_help}")
47
+
48
+ return "\n\n".join(parts)
@@ -0,0 +1,336 @@
1
+ """ToolboxManager: user-triggered discovery of OS/software tools, persisted
2
+ across sessions and merged into the shared ToolRegistry on every startup.
3
+
4
+ Discovery is only ever invoked by discover() — nothing here runs
5
+ automatically or in the background."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from pcli.llm.client import GatewayClient
14
+ from pcli.sandbox.base import ExecRequest, SandboxSecurityError
15
+ from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
16
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
17
+ from pcli.tools.registry import ToolRegistry
18
+ from pcli.tools.toolbox import store
19
+ from pcli.tools.toolbox.introspect import collect_help_corpus
20
+ from pcli.tools.toolbox.plugin_base import CommandSpec, DetectionResult, ToolboxPlugin
21
+ from pcli.tools.toolbox.plugins import ALL_PLUGINS
22
+ from pcli.tools.toolbox.synthesize import synthesize_tools
23
+
24
+
25
+ class ToolboxDiscoveryError(Exception):
26
+ pass
27
+
28
+
29
+ def _plugin_for(software_name: str) -> ToolboxPlugin | None:
30
+ for plugin in ALL_PLUGINS:
31
+ if plugin.software_name == software_name:
32
+ return plugin
33
+ return None
34
+
35
+
36
+ async def _detect_primary_binary(
37
+ candidates: tuple[str, ...], version_args: tuple[str, ...], cwd: Path
38
+ ) -> tuple[str, str] | None:
39
+ for candidate in candidates:
40
+ binary_path = shutil.which(candidate)
41
+ if not binary_path:
42
+ continue
43
+ sandbox = RestrictedSubprocessSandbox(allowed_roots=[cwd])
44
+ result = await sandbox.execute(
45
+ ExecRequest(command=[binary_path, *version_args], cwd=cwd, timeout_s=10)
46
+ )
47
+ output = (result.stdout + "\n" + result.stderr).strip()
48
+ return binary_path, output
49
+ return None
50
+
51
+
52
+ def make_command_tool_spec(software_name: str, spec: CommandSpec) -> ToolSpec:
53
+ tool_name = f"{software_name}_{spec.name}"
54
+
55
+ async def _handler(arguments: dict, ctx: ToolContext) -> ToolResult:
56
+ argv = [spec.binary_path, *spec.build_args(arguments)]
57
+ result = await ctx.sandbox.execute(ExecRequest(command=argv, cwd=ctx.cwd, timeout_s=30))
58
+ output = result.stdout
59
+ if result.stderr:
60
+ output += f"\n--- stderr ---\n{result.stderr}"
61
+ is_error = result.exit_code != 0 or result.timed_out
62
+ return ToolResult(output=f"[exit_code={result.exit_code}]\n{output}", is_error=is_error)
63
+
64
+ return ToolSpec(
65
+ name=tool_name,
66
+ description=spec.description,
67
+ parameters=spec.parameters,
68
+ handler=_handler,
69
+ needs_permission=spec.risk != "read",
70
+ needs_sandbox=True,
71
+ risk_description=f"Runs `{spec.binary_path}` ({spec.risk}).",
72
+ read_only=spec.risk == "read",
73
+ )
74
+
75
+
76
+ def _auto_flags(arguments: dict, parameters: dict) -> list[str]:
77
+ """Generic best-effort flag builder for LLM-synthesized tools: each
78
+ provided argument becomes `--arg-name value` (or a bare flag for
79
+ booleans). Curated plugins use hand-written build_args instead because
80
+ real CLIs (positional args, short flags, `=` syntax) don't uniformly fit
81
+ this convention — this is the fallback for tools nobody's hand-reviewed."""
82
+ props = parameters.get("properties", {})
83
+ argv: list[str] = []
84
+ for key, value in arguments.items():
85
+ if value is None:
86
+ continue
87
+ flag = "--" + key.replace("_", "-")
88
+ prop_type = (props.get(key) or {}).get("type")
89
+ if prop_type == "boolean":
90
+ if value:
91
+ argv.append(flag)
92
+ continue
93
+ if isinstance(value, list):
94
+ for item in value:
95
+ argv += [flag, str(item)]
96
+ continue
97
+ argv += [flag, str(value)]
98
+ return argv
99
+
100
+
101
+ def _invocation_for_path(path: Path) -> list[str]:
102
+ """.py scripts need an interpreter — Windows can't execve them directly,
103
+ and POSIX only can if they're chmod +x with a shebang, which a
104
+ self-authored script can't be relied on to have."""
105
+ if path.suffix.lower() == ".py":
106
+ return [sys.executable, str(path)]
107
+ return [str(path)]
108
+
109
+
110
+ def make_synthesized_tool_spec(software_name: str, invocation: list[str], tool_data: dict) -> ToolSpec:
111
+ tool_name = f"{software_name}_{tool_data['name']}"
112
+ subcommand: list[str] = tool_data["subcommand"]
113
+ parameters: dict = tool_data["parameters"]
114
+ risk: str = tool_data.get("risk", "mutate")
115
+
116
+ async def _handler(arguments: dict, ctx: ToolContext) -> ToolResult:
117
+ argv = [*invocation, *subcommand, *_auto_flags(arguments, parameters)]
118
+ result = await ctx.sandbox.execute(ExecRequest(command=argv, cwd=ctx.cwd, timeout_s=30))
119
+ output = result.stdout
120
+ if result.stderr:
121
+ output += f"\n--- stderr ---\n{result.stderr}"
122
+ is_error = result.exit_code != 0 or result.timed_out
123
+ return ToolResult(output=f"[exit_code={result.exit_code}]\n{output}", is_error=is_error)
124
+
125
+ invocation_label = " ".join(invocation)
126
+ return ToolSpec(
127
+ name=tool_name,
128
+ description=tool_data["description"]
129
+ + " (auto-generated from --help output; unreviewed, verify before trusting blindly)",
130
+ parameters=parameters,
131
+ handler=_handler,
132
+ needs_permission=risk != "read",
133
+ needs_sandbox=True,
134
+ risk_description=f"Runs `{invocation_label} {' '.join(subcommand)}` ({risk}, auto-generated).",
135
+ read_only=risk == "read",
136
+ )
137
+
138
+
139
+ class ToolboxManager:
140
+ def __init__(self, *, cwd: Path) -> None:
141
+ self._cwd = cwd
142
+
143
+ def _resolve_script_path(self, path: str) -> Path:
144
+ """Resolves+validates a self-authored script for discover's path=
145
+ parameter: must land inside self._cwd (the same containment
146
+ RestrictedSubprocessSandbox._validate_cwd enforces for every other
147
+ tool), so a path argument can't reach outside the project tree the
148
+ agent is actually working in."""
149
+ candidate = Path(path).expanduser()
150
+ if not candidate.is_absolute():
151
+ candidate = self._cwd / candidate
152
+ resolved = candidate.resolve()
153
+ cwd_resolved = self._cwd.expanduser().resolve()
154
+ if resolved != cwd_resolved and cwd_resolved not in resolved.parents:
155
+ raise SandboxSecurityError(
156
+ f"'{path}' is outside the working directory '{cwd_resolved}'."
157
+ )
158
+ if not resolved.is_file():
159
+ raise ToolboxDiscoveryError(f"'{path}' doesn't exist or isn't a file.")
160
+ return resolved
161
+
162
+ async def discover(
163
+ self,
164
+ software_name: str,
165
+ *,
166
+ gateway_client: GatewayClient | None = None,
167
+ model: str | None = None,
168
+ path: str | None = None,
169
+ ) -> str:
170
+ """path registers a self-authored script directly, bypassing PATH
171
+ lookup entirely — for a model that just wrote its own small tool and
172
+ wants to make it callable without the user placing it on PATH first.
173
+ Curated-plugin matching is skipped when path is given: pointing at a
174
+ specific script means "use exactly this," not "look something up.\""""
175
+ registry = store.read_registry()
176
+
177
+ if path is not None:
178
+ script_path = self._resolve_script_path(path)
179
+ invocation = _invocation_for_path(script_path)
180
+ help_corpus = await collect_help_corpus(invocation, self._cwd)
181
+ if not help_corpus.strip():
182
+ raise ToolboxDiscoveryError(
183
+ f"'{' '.join(invocation)} --help' produced no output to work from."
184
+ )
185
+ corpus_hash = store.hash_corpus(help_corpus)
186
+
187
+ cached = store.read_synthesized_schema(software_name)
188
+ if cached and cached.get("help_corpus_hash") == corpus_hash:
189
+ tools_data = cached["tools"]
190
+ else:
191
+ if gateway_client is None:
192
+ raise ToolboxDiscoveryError(
193
+ f"No LLM gateway configured to synthesize a tool schema for "
194
+ f"'{software_name}'."
195
+ )
196
+ tools_data = await synthesize_tools(
197
+ gateway_client, software_name=software_name, help_corpus=help_corpus, model=model
198
+ )
199
+ store.write_synthesized_schema(
200
+ software_name, help_corpus_hash=corpus_hash, version="unknown", tools=tools_data
201
+ )
202
+
203
+ registry[software_name] = {
204
+ "source": "synthesized",
205
+ "binary_path": str(script_path),
206
+ "invocation": invocation,
207
+ "version": "unknown",
208
+ "tool_count": len(tools_data),
209
+ }
210
+ store.write_registry(registry)
211
+ return (
212
+ f"Registered '{software_name}' from {script_path} — synthesized "
213
+ f"{len(tools_data)} tool(s) from --help output."
214
+ )
215
+
216
+ plugin = _plugin_for(software_name)
217
+
218
+ if plugin is not None:
219
+ found = await _detect_primary_binary(plugin.binary_names, plugin.version_args, self._cwd)
220
+ if found is None:
221
+ raise ToolboxDiscoveryError(
222
+ f"Could not find '{software_name}' on PATH "
223
+ f"(tried: {', '.join(plugin.binary_names)})."
224
+ )
225
+ binary_path, version_output = found
226
+ version = plugin.parse_version(version_output) or (0,)
227
+ first_line = version_output.strip().splitlines()[0] if version_output.strip() else "unknown"
228
+ detection = DetectionResult(
229
+ software_name=software_name,
230
+ binary_path=binary_path,
231
+ version_string=first_line,
232
+ version=version,
233
+ )
234
+ tools = plugin.build_tools(detection)
235
+ registry[software_name] = {
236
+ "source": "curated",
237
+ "binary_path": binary_path,
238
+ "version": detection.version_string,
239
+ "tool_count": len(tools),
240
+ }
241
+ store.write_registry(registry)
242
+ return (
243
+ f"Found curated plugin for '{software_name}' at {binary_path} "
244
+ f"(version: {detection.version_string}). Registered {len(tools)} tool(s)."
245
+ )
246
+
247
+ binary_path = shutil.which(software_name)
248
+ if not binary_path:
249
+ raise ToolboxDiscoveryError(f"'{software_name}' was not found on PATH.")
250
+
251
+ help_corpus = await collect_help_corpus([binary_path], self._cwd)
252
+ if not help_corpus.strip():
253
+ raise ToolboxDiscoveryError(f"'{software_name} --help' produced no output to work from.")
254
+ corpus_hash = store.hash_corpus(help_corpus)
255
+
256
+ cached = store.read_synthesized_schema(software_name)
257
+ if cached and cached.get("help_corpus_hash") == corpus_hash:
258
+ tools_data = cached["tools"]
259
+ else:
260
+ if gateway_client is None:
261
+ raise ToolboxDiscoveryError(
262
+ f"No curated plugin for '{software_name}' and no LLM gateway configured "
263
+ "to synthesize one."
264
+ )
265
+ tools_data = await synthesize_tools(
266
+ gateway_client, software_name=software_name, help_corpus=help_corpus, model=model
267
+ )
268
+ store.write_synthesized_schema(
269
+ software_name, help_corpus_hash=corpus_hash, version="unknown", tools=tools_data
270
+ )
271
+
272
+ registry[software_name] = {
273
+ "source": "synthesized",
274
+ "binary_path": binary_path,
275
+ "invocation": [binary_path],
276
+ "version": "unknown",
277
+ "tool_count": len(tools_data),
278
+ }
279
+ store.write_registry(registry)
280
+ return (
281
+ f"No curated plugin for '{software_name}' — introspected {binary_path} and "
282
+ f"synthesized {len(tools_data)} tool(s) from --help output."
283
+ )
284
+
285
+ def list_discovered(self) -> dict[str, dict]:
286
+ return store.read_registry()
287
+
288
+ def remove(self, software_name: str) -> None:
289
+ registry = store.read_registry()
290
+ registry.pop(software_name, None)
291
+ store.write_registry(registry)
292
+
293
+ async def load_all(self) -> ToolRegistry:
294
+ """Rebuilds tool specs for every previously-discovered software.
295
+ Curated entries re-detect live (cheap); synthesized entries load
296
+ their cached schema. Missing binaries are skipped silently rather
297
+ than raising, so a stale registry entry doesn't break startup."""
298
+ registry = ToolRegistry()
299
+
300
+ for software_name, entry in store.read_registry().items():
301
+ plugin = _plugin_for(software_name)
302
+ if plugin is not None:
303
+ found = await _detect_primary_binary(plugin.binary_names, plugin.version_args, self._cwd)
304
+ if found is None:
305
+ continue
306
+ binary_path, version_output = found
307
+ version = plugin.parse_version(version_output) or (0,)
308
+ first_line = (
309
+ version_output.strip().splitlines()[0] if version_output.strip() else "unknown"
310
+ )
311
+ detection = DetectionResult(
312
+ software_name=software_name,
313
+ binary_path=binary_path,
314
+ version_string=first_line,
315
+ version=version,
316
+ )
317
+ for spec in plugin.build_tools(detection):
318
+ registry.register(make_command_tool_spec(software_name, spec))
319
+ else:
320
+ cached = store.read_synthesized_schema(software_name)
321
+ if cached is None:
322
+ continue
323
+ invocation = entry.get("invocation")
324
+ if not invocation:
325
+ # Registry entries written before the invocation field
326
+ # existed only have binary_path - fall back to it.
327
+ binary_path = entry.get("binary_path") or shutil.which(software_name)
328
+ if not binary_path:
329
+ continue
330
+ invocation = [binary_path]
331
+ for tool_data in cached["tools"]:
332
+ registry.register(
333
+ make_synthesized_tool_spec(software_name, invocation, tool_data)
334
+ )
335
+
336
+ return registry
@@ -0,0 +1,51 @@
1
+ """Plugin architecture for curated per-software tool sets.
2
+
3
+ A plugin declares how to recognize its software (candidate binary names +
4
+ how to run/parse a version check) and, once detected, the specific
5
+ admin/ops/user commands it exposes as tools. Each CommandSpec carries its
6
+ own already-resolved `binary_path` rather than assuming one binary for the
7
+ whole plugin — most software (kubectl, apachectl) is one binary with many
8
+ subcommands, but some (Sun Grid Engine: qstat/qsub/qdel/...) is a suite of
9
+ separate binaries, and this shape covers both without special-casing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal
18
+
19
+
20
+ @dataclass
21
+ class DetectionResult:
22
+ software_name: str
23
+ binary_path: str
24
+ """Path to the plugin's primary binary (used to confirm presence/version)."""
25
+ version_string: str
26
+ version: tuple[int, ...]
27
+
28
+
29
+ @dataclass
30
+ class CommandSpec:
31
+ name: str
32
+ description: str
33
+ parameters: dict[str, Any]
34
+ """JSON schema for the tool's arguments."""
35
+ build_args: Callable[[dict[str, Any]], list[str]]
36
+ """Given validated tool arguments, returns the argv (after the binary)."""
37
+ binary_path: str
38
+ risk: Literal["read", "mutate", "destructive"] = "read"
39
+
40
+
41
+ class ToolboxPlugin(ABC):
42
+ software_name: str
43
+ binary_names: tuple[str, ...]
44
+ """Candidate primary-binary names to look for on PATH, tried in order."""
45
+ version_args: tuple[str, ...] = ("--version",)
46
+
47
+ @abstractmethod
48
+ def parse_version(self, version_output: str) -> tuple[int, ...] | None: ...
49
+
50
+ @abstractmethod
51
+ def build_tools(self, detection: DetectionResult) -> list[CommandSpec]: ...