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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
pcli/cli.py
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
"""Typer entry point. `pcli` with no subcommand launches the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from pcli.config.paths import data_dir
|
|
12
|
+
from pcli.config.settings import add_local_api_gateway, get_settings, update_config_file
|
|
13
|
+
from pcli.cost.tracker import global_cost_report
|
|
14
|
+
from pcli.scheduler.runner import ScheduledSessionNotFoundError, run_task_once
|
|
15
|
+
from pcli.session.directory_check import directory_mismatch
|
|
16
|
+
from pcli.session.export import export_session
|
|
17
|
+
from pcli.session.importer import import_session
|
|
18
|
+
from pcli.session.store import SessionNotFoundError, SessionStore
|
|
19
|
+
from pcli.telegram.bot import run_telegram_daemon
|
|
20
|
+
from pcli.util.logging import configure_logging
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False)
|
|
23
|
+
|
|
24
|
+
sessions_app = typer.Typer(help="Manage stored sessions.")
|
|
25
|
+
app.add_typer(sessions_app, name="sessions")
|
|
26
|
+
|
|
27
|
+
cost_app = typer.Typer(help="Cost reporting.")
|
|
28
|
+
app.add_typer(cost_app, name="cost")
|
|
29
|
+
|
|
30
|
+
toolbox_app = typer.Typer(help="Discover OS/software tools for the agent to use.")
|
|
31
|
+
app.add_typer(toolbox_app, name="toolbox")
|
|
32
|
+
|
|
33
|
+
schedule_app = typer.Typer(help="Crontab-like recurring task scheduling (requires the "
|
|
34
|
+
"'schedule' extra: pip install -e '.[schedule]').")
|
|
35
|
+
app.add_typer(schedule_app, name="schedule")
|
|
36
|
+
|
|
37
|
+
tools_app = typer.Typer(help="Inspect the built-in tool set.")
|
|
38
|
+
app.add_typer(tools_app, name="tools")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback(invoke_without_command=True)
|
|
42
|
+
def _root(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
gateway_url: str = typer.Option(None, "--gateway-url", help="Override the gateway base URL."),
|
|
45
|
+
api_key: str = typer.Option(None, "--api-key", help="Override the gateway API key."),
|
|
46
|
+
model: str = typer.Option(None, "--model", help="Override the default model."),
|
|
47
|
+
artifact_threshold: int = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--artifact-threshold",
|
|
50
|
+
help="Override the artifact-archiving threshold, in characters "
|
|
51
|
+
"(tool results longer than this get truncated + archived; see fetch_artifact).",
|
|
52
|
+
),
|
|
53
|
+
local_api: bool = typer.Option(
|
|
54
|
+
False,
|
|
55
|
+
"--local-api",
|
|
56
|
+
help="Mark the active gateway as local-api mode: uncaps max_tool_iterations and the "
|
|
57
|
+
"guardrails' max_tool_calls_per_turn/per_minute, and forces cost to $0 instead of "
|
|
58
|
+
"looking it up in the pricing table. Paired to (and persisted with) whichever "
|
|
59
|
+
"gateway is active for this invocation.",
|
|
60
|
+
),
|
|
61
|
+
resume: str = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--resume",
|
|
64
|
+
"-r",
|
|
65
|
+
help="Resume a past session by id (see 'pcli sessions list'; the printed hint on quit "
|
|
66
|
+
"gives the exact command).",
|
|
67
|
+
),
|
|
68
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
|
|
69
|
+
) -> None:
|
|
70
|
+
configure_logging(verbose=verbose)
|
|
71
|
+
overrides = {}
|
|
72
|
+
if gateway_url:
|
|
73
|
+
overrides["gateway_base_url"] = gateway_url
|
|
74
|
+
if api_key:
|
|
75
|
+
overrides["gateway_api_key"] = api_key
|
|
76
|
+
if model:
|
|
77
|
+
overrides["default_model"] = model
|
|
78
|
+
if artifact_threshold is not None:
|
|
79
|
+
overrides["artifact_threshold_chars"] = artifact_threshold
|
|
80
|
+
if gateway_url or model or artifact_threshold is not None:
|
|
81
|
+
# Remembered for next time so a bare `pcli` picks it up — deliberately
|
|
82
|
+
# not persisting api_key here, so a secret passed via --api-key isn't
|
|
83
|
+
# silently written to disk.
|
|
84
|
+
update_config_file(
|
|
85
|
+
gateway_base_url=gateway_url,
|
|
86
|
+
default_model=model,
|
|
87
|
+
artifact_threshold_chars=artifact_threshold,
|
|
88
|
+
)
|
|
89
|
+
settings = get_settings(**overrides)
|
|
90
|
+
|
|
91
|
+
if local_api:
|
|
92
|
+
if not settings.gateway_base_url:
|
|
93
|
+
typer.echo(
|
|
94
|
+
"--local-api needs a gateway to pair with; pass --gateway-url too "
|
|
95
|
+
"(or configure one first).",
|
|
96
|
+
err=True,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
add_local_api_gateway(settings.gateway_base_url)
|
|
100
|
+
if settings.gateway_base_url not in settings.local_api_gateways:
|
|
101
|
+
settings.local_api_gateways.append(settings.gateway_base_url)
|
|
102
|
+
|
|
103
|
+
ctx.obj = settings
|
|
104
|
+
|
|
105
|
+
if ctx.invoked_subcommand is None:
|
|
106
|
+
from pcli.tui.app import PcliApp
|
|
107
|
+
|
|
108
|
+
store = SessionStore()
|
|
109
|
+
resumed_session = None
|
|
110
|
+
if resume:
|
|
111
|
+
try:
|
|
112
|
+
resumed_session = store.load(resume)
|
|
113
|
+
except SessionNotFoundError:
|
|
114
|
+
typer.echo(
|
|
115
|
+
f"No session found with id '{resume}'. Run 'pcli sessions list' to see "
|
|
116
|
+
"available sessions.",
|
|
117
|
+
err=True,
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(code=1) from None
|
|
120
|
+
warning = directory_mismatch(resumed_session, Path.cwd())
|
|
121
|
+
if warning and not typer.confirm(f"{warning}\nContinue anyway?", default=False):
|
|
122
|
+
raise typer.Exit(code=0)
|
|
123
|
+
|
|
124
|
+
PcliApp(settings, session=resumed_session).run()
|
|
125
|
+
_print_resume_hint(store)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_resume_hint(store: SessionStore) -> None:
|
|
129
|
+
"""The session most recently touched by the run that just ended - not
|
|
130
|
+
necessarily `resumed_session` above, since /sessions can switch to a
|
|
131
|
+
different one mid-run. Skips a session with zero messages: it gets
|
|
132
|
+
silently pruned on the next launch (SessionStore.prune_empty_sessions),
|
|
133
|
+
so a resume hint for it would go stale immediately."""
|
|
134
|
+
entries = store.list_index()
|
|
135
|
+
if not entries or entries[0].message_count == 0:
|
|
136
|
+
return
|
|
137
|
+
latest = entries[0]
|
|
138
|
+
typer.echo(f"\nResume this session anytime with: pcli --resume {latest.id}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("run")
|
|
142
|
+
def run_command(
|
|
143
|
+
task: str | None = typer.Option(None, "--task", help="The task to run, given inline."),
|
|
144
|
+
task_file: str | None = typer.Option(
|
|
145
|
+
None,
|
|
146
|
+
"--task-file",
|
|
147
|
+
help="Path to a file containing the task (for longer, step-by-step instructions you "
|
|
148
|
+
"want to reuse - e.g. on a schedule via cron/Task Scheduler). Exactly one of --task/"
|
|
149
|
+
"--task-file is required.",
|
|
150
|
+
),
|
|
151
|
+
session: str | None = typer.Option(
|
|
152
|
+
None,
|
|
153
|
+
"--session",
|
|
154
|
+
help="Resume/append to an existing session by id (see 'pcli sessions list'), instead "
|
|
155
|
+
"of starting a fresh one.",
|
|
156
|
+
),
|
|
157
|
+
quiet: bool = typer.Option(
|
|
158
|
+
False, "--quiet", help="Only print the final answer, not tool-call progress."
|
|
159
|
+
),
|
|
160
|
+
headed: bool = typer.Option(
|
|
161
|
+
False,
|
|
162
|
+
"--headed",
|
|
163
|
+
help="Show the browser window if browser_* tools are used, instead of running it "
|
|
164
|
+
"headless (the default for a scheduled/unattended run - nothing to show, and a real "
|
|
165
|
+
"window shouldn't pop up unattended).",
|
|
166
|
+
),
|
|
167
|
+
notify_telegram_flag: bool = typer.Option(
|
|
168
|
+
False,
|
|
169
|
+
"--notify-telegram",
|
|
170
|
+
help="Also send the final answer to the configured Telegram chat once this run "
|
|
171
|
+
"finishes (see 'pcli telegram --help'). Requires telegram_bot_token/telegram_chat_id "
|
|
172
|
+
"to already be configured - skipped with a warning otherwise, never a hard failure.",
|
|
173
|
+
),
|
|
174
|
+
max_cost: float | None = typer.Option(
|
|
175
|
+
None,
|
|
176
|
+
"--max-cost",
|
|
177
|
+
help="Hard cap on this run's total spend (USD), overriding max_session_cost_usd for "
|
|
178
|
+
"just this invocation - never persisted to config.toml, never affects the TUI or any "
|
|
179
|
+
"other run. Omit to use whatever max_session_cost_usd is already configured (unset by "
|
|
180
|
+
"default - no cap).",
|
|
181
|
+
),
|
|
182
|
+
audit: bool = typer.Option(
|
|
183
|
+
False,
|
|
184
|
+
"--audit",
|
|
185
|
+
help="Record a tamper-evident audit log (session/audit.py) for this run's permission "
|
|
186
|
+
"decisions and tool calls, overriding audit_mode_enabled for just this invocation - "
|
|
187
|
+
"never persisted to config.toml, never affects the TUI or any other run. Verify "
|
|
188
|
+
"afterward with 'pcli sessions verify <id>'.",
|
|
189
|
+
),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Runs a single task non-interactively and exits - no TUI. Meant to be
|
|
192
|
+
invoked by an OS scheduler (cron / Task Scheduler) or pcli's own
|
|
193
|
+
scheduler (`pcli schedule`) for a task you've already worked out
|
|
194
|
+
interactively once. Anything not already granted "Always Allow" (see
|
|
195
|
+
the TUI's permission prompt) is refused rather than prompted for, since
|
|
196
|
+
there's no one here to ask - set those up interactively first if this
|
|
197
|
+
task needs them."""
|
|
198
|
+
if bool(task) == bool(task_file):
|
|
199
|
+
typer.echo("Provide exactly one of --task or --task-file.", err=True)
|
|
200
|
+
raise typer.Exit(code=1)
|
|
201
|
+
if task_file:
|
|
202
|
+
task = Path(task_file).read_text(encoding="utf-8")
|
|
203
|
+
assert task is not None
|
|
204
|
+
|
|
205
|
+
async def _run() -> None:
|
|
206
|
+
settings = get_settings()
|
|
207
|
+
if not settings.is_configured():
|
|
208
|
+
typer.echo(
|
|
209
|
+
"Gateway not configured. Set PCLI_GATEWAY_URL (and PCLI_GATEWAY_API_KEY if "
|
|
210
|
+
"your gateway requires auth) or edit the config file first.",
|
|
211
|
+
err=True,
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
if max_cost is not None:
|
|
215
|
+
# A local override, not get_settings(max_session_cost_usd=...) -
|
|
216
|
+
# that helper rebuilds the whole cached Settings singleton from
|
|
217
|
+
# scratch using only the given overrides (config/settings.py's
|
|
218
|
+
# get_settings), which would silently drop --gateway-url/--api-key/
|
|
219
|
+
# --model overrides _root's own callback already applied earlier
|
|
220
|
+
# in this same invocation. model_copy starts from the already-
|
|
221
|
+
# fully-resolved settings instead, so only this one field changes.
|
|
222
|
+
settings = settings.model_copy(update={"max_session_cost_usd": max_cost})
|
|
223
|
+
if audit:
|
|
224
|
+
settings = settings.model_copy(update={"audit_mode_enabled": True})
|
|
225
|
+
|
|
226
|
+
store = SessionStore()
|
|
227
|
+
cwd = Path.cwd()
|
|
228
|
+
|
|
229
|
+
def on_progress(line: str) -> None:
|
|
230
|
+
if not quiet:
|
|
231
|
+
typer.echo(line)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
result = await run_task_once(
|
|
235
|
+
task,
|
|
236
|
+
settings=settings,
|
|
237
|
+
store=store,
|
|
238
|
+
cwd=cwd,
|
|
239
|
+
session_id=session,
|
|
240
|
+
quiet=quiet,
|
|
241
|
+
headed=headed,
|
|
242
|
+
notify_telegram_flag=notify_telegram_flag,
|
|
243
|
+
on_progress=on_progress,
|
|
244
|
+
)
|
|
245
|
+
except ScheduledSessionNotFoundError:
|
|
246
|
+
typer.echo(f"No session found with id '{session}'.", err=True)
|
|
247
|
+
raise typer.Exit(code=1) from None
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
typer.echo(f"Run failed: {exc}", err=True)
|
|
250
|
+
raise typer.Exit(code=1) from exc
|
|
251
|
+
|
|
252
|
+
typer.echo(f"\n{result.final_text}" if not quiet else result.final_text)
|
|
253
|
+
typer.echo(f"\nSession: {result.session.id} (resume with: pcli --resume {result.session.id})")
|
|
254
|
+
|
|
255
|
+
if result.terminated_early or result.truncations_exhausted:
|
|
256
|
+
raise typer.Exit(code=1)
|
|
257
|
+
|
|
258
|
+
asyncio.run(_run())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command("telegram")
|
|
262
|
+
def telegram_command() -> None:
|
|
263
|
+
"""Runs pcli as a long-running Telegram bot - the third way to run
|
|
264
|
+
pcli, alongside the interactive TUI and one-shot `pcli run`. Requires
|
|
265
|
+
telegram_bot_token (from @BotFather) and telegram_chat_id (the one
|
|
266
|
+
chat this bot will talk to - everything else is silently ignored) to
|
|
267
|
+
already be configured; refuses to start otherwise rather than running
|
|
268
|
+
unsecured. Keeps one ongoing session for that chat (reset with the
|
|
269
|
+
bot's own /new command), driven by the same AgentLoop machinery as
|
|
270
|
+
everything else - a consequential tool call is approved or denied via
|
|
271
|
+
an inline-keyboard prompt in the chat, exactly like the TUI's own
|
|
272
|
+
permission modal. Runs until interrupted (Ctrl+C)."""
|
|
273
|
+
|
|
274
|
+
async def _run() -> None:
|
|
275
|
+
settings = get_settings()
|
|
276
|
+
if not settings.is_configured():
|
|
277
|
+
typer.echo(
|
|
278
|
+
"Gateway not configured. Set PCLI_GATEWAY_URL (and PCLI_GATEWAY_API_KEY if "
|
|
279
|
+
"your gateway requires auth) or edit the config file first.",
|
|
280
|
+
err=True,
|
|
281
|
+
)
|
|
282
|
+
raise typer.Exit(code=1)
|
|
283
|
+
if not settings.is_telegram_configured():
|
|
284
|
+
typer.echo(
|
|
285
|
+
"telegram_bot_token and telegram_chat_id must both be set (env vars "
|
|
286
|
+
"PCLI_TELEGRAM_BOT_TOKEN / PCLI_TELEGRAM_CHAT_ID, or the constructor kwargs) "
|
|
287
|
+
"before pcli telegram can start.",
|
|
288
|
+
err=True,
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(code=1)
|
|
291
|
+
|
|
292
|
+
cwd = Path.cwd()
|
|
293
|
+
|
|
294
|
+
def on_ready(session_id: str) -> None:
|
|
295
|
+
typer.echo(f"Listening for chat {settings.telegram_chat_id}. Session: {session_id}")
|
|
296
|
+
typer.echo("Press Ctrl+C to stop.")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
await run_telegram_daemon(settings, cwd, on_ready=on_ready)
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
typer.echo(f"Startup failed: {exc}", err=True)
|
|
302
|
+
raise typer.Exit(code=1) from exc
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
asyncio.run(_run())
|
|
306
|
+
except KeyboardInterrupt:
|
|
307
|
+
typer.echo("\nStopped.")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@sessions_app.command("list")
|
|
311
|
+
def sessions_list() -> None:
|
|
312
|
+
store = SessionStore()
|
|
313
|
+
for entry in store.list_index():
|
|
314
|
+
typer.echo(
|
|
315
|
+
f"{entry.id} {entry.updated_at:%Y-%m-%d %H:%M} "
|
|
316
|
+
f"${entry.total_cost_usd:.4f} {entry.title}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@sessions_app.command("export")
|
|
321
|
+
def sessions_export(
|
|
322
|
+
session_id: str,
|
|
323
|
+
out: str = typer.Option(None, "--out", help="Output path."),
|
|
324
|
+
use_gzip: bool = typer.Option(False, "--gzip", help="Gzip-compress the export."),
|
|
325
|
+
) -> None:
|
|
326
|
+
store = SessionStore()
|
|
327
|
+
session = store.load(session_id)
|
|
328
|
+
suffix = ".pcli-session.json.gz" if use_gzip else ".pcli-session.json"
|
|
329
|
+
out_path = Path(out) if out else data_dir() / "exports" / f"{session.id}{suffix}"
|
|
330
|
+
export_session(session, out_path, store=store, use_gzip=use_gzip or None)
|
|
331
|
+
typer.echo(f"Exported to {out_path}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@sessions_app.command("import")
|
|
335
|
+
def sessions_import(
|
|
336
|
+
path: str,
|
|
337
|
+
restore_grants: bool = typer.Option(
|
|
338
|
+
False, "--restore-grants", help="Also restore 'always allow' permission grants."
|
|
339
|
+
),
|
|
340
|
+
) -> None:
|
|
341
|
+
store = SessionStore()
|
|
342
|
+
session = import_session(Path(path), store=store, restore_grants=restore_grants)
|
|
343
|
+
typer.echo(f"Imported as session {session.id}")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@sessions_app.command("verify")
|
|
347
|
+
def sessions_verify(session_id: str) -> None:
|
|
348
|
+
"""Recomputes and validates session.audit_log's hash chain (see
|
|
349
|
+
session/audit.py) - proves whether the record was edited after it was
|
|
350
|
+
written. Only meaningful for a session that ran with audit_mode_enabled
|
|
351
|
+
('pcli run --audit'/'pcli schedule add --audit', or the persisted
|
|
352
|
+
audit_mode_enabled setting) - an empty log is reported as valid (0
|
|
353
|
+
entries), not an error, since audit mode may simply have been off."""
|
|
354
|
+
from pcli.session.audit import verify_audit_chain
|
|
355
|
+
|
|
356
|
+
store = SessionStore()
|
|
357
|
+
try:
|
|
358
|
+
session = store.load(session_id)
|
|
359
|
+
except SessionNotFoundError:
|
|
360
|
+
typer.echo(f"No session found with id '{session_id}'.", err=True)
|
|
361
|
+
raise typer.Exit(code=1) from None
|
|
362
|
+
|
|
363
|
+
result = verify_audit_chain(session)
|
|
364
|
+
if result.valid:
|
|
365
|
+
typer.echo(f"{result.entry_count} audit entries, chain valid.")
|
|
366
|
+
return
|
|
367
|
+
typer.echo(
|
|
368
|
+
f"Chain broken at entry {result.broken_at_index}: {result.reason}", err=True
|
|
369
|
+
)
|
|
370
|
+
raise typer.Exit(code=1)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@cost_app.command("report")
|
|
374
|
+
def cost_report_command() -> None:
|
|
375
|
+
typer.echo(json.dumps(global_cost_report(), indent=2))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@toolbox_app.command("discover")
|
|
379
|
+
def toolbox_discover(
|
|
380
|
+
name: str,
|
|
381
|
+
path: str = typer.Option(
|
|
382
|
+
None, "--path", help="Register a self-authored script directly, bypassing PATH lookup."
|
|
383
|
+
),
|
|
384
|
+
) -> None:
|
|
385
|
+
from pcli.llm.client import GatewayClient
|
|
386
|
+
from pcli.llm.errors import GatewayError
|
|
387
|
+
from pcli.sandbox.base import SandboxSecurityError
|
|
388
|
+
from pcli.tools.toolbox.manager import ToolboxDiscoveryError, ToolboxManager
|
|
389
|
+
|
|
390
|
+
async def _run() -> None:
|
|
391
|
+
settings = get_settings()
|
|
392
|
+
manager = ToolboxManager(cwd=Path.cwd())
|
|
393
|
+
client = GatewayClient(settings) if settings.is_configured() else None
|
|
394
|
+
try:
|
|
395
|
+
summary = await manager.discover(
|
|
396
|
+
name, gateway_client=client, model=settings.default_model or None, path=path
|
|
397
|
+
)
|
|
398
|
+
typer.echo(summary)
|
|
399
|
+
except (ToolboxDiscoveryError, GatewayError, SandboxSecurityError) as exc:
|
|
400
|
+
# discover() calls the gateway to synthesize tool schemas when
|
|
401
|
+
# there's no curated plugin - that can fail same as any other
|
|
402
|
+
# gateway call (previously uncaught here, crashing with a raw
|
|
403
|
+
# traceback instead of a clean message).
|
|
404
|
+
typer.echo(f"Discovery failed: {exc}", err=True)
|
|
405
|
+
raise typer.Exit(code=1) from exc
|
|
406
|
+
finally:
|
|
407
|
+
if client is not None:
|
|
408
|
+
await client.aclose()
|
|
409
|
+
|
|
410
|
+
asyncio.run(_run())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@toolbox_app.command("list")
|
|
414
|
+
def toolbox_list() -> None:
|
|
415
|
+
from pcli.tools.toolbox import store
|
|
416
|
+
|
|
417
|
+
registry = store.read_registry()
|
|
418
|
+
if not registry:
|
|
419
|
+
typer.echo("No software discovered yet. Run: pcli toolbox discover <name>")
|
|
420
|
+
return
|
|
421
|
+
for name, entry in registry.items():
|
|
422
|
+
typer.echo(
|
|
423
|
+
f"{name} [{entry['source']}] {entry.get('version', '?')} "
|
|
424
|
+
f"{entry.get('tool_count', 0)} tool(s)"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@toolbox_app.command("remove")
|
|
429
|
+
def toolbox_remove(name: str) -> None:
|
|
430
|
+
from pcli.tools.toolbox import store
|
|
431
|
+
|
|
432
|
+
registry = store.read_registry()
|
|
433
|
+
if name not in registry:
|
|
434
|
+
typer.echo(f"'{name}' is not in the toolbox.")
|
|
435
|
+
raise typer.Exit(code=1)
|
|
436
|
+
registry.pop(name)
|
|
437
|
+
store.write_registry(registry)
|
|
438
|
+
typer.echo(f"Removed '{name}' from the toolbox.")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@schedule_app.command("add")
|
|
442
|
+
def schedule_add(
|
|
443
|
+
cron: str | None = typer.Option(None, "--cron", help="Standard 5-field cron expression, "
|
|
444
|
+
"e.g. '*/15 * * * *' (minute hour day month weekday). Exactly one of --cron/"
|
|
445
|
+
"--on-file-change/--on-git-commit is required."),
|
|
446
|
+
on_file_change: str | None = typer.Option(
|
|
447
|
+
None, "--on-file-change", help="Fire whenever this file or directory (watched "
|
|
448
|
+
"recursively) changes, instead of on a cron schedule."
|
|
449
|
+
),
|
|
450
|
+
on_git_commit: bool = typer.Option(
|
|
451
|
+
False, "--on-git-commit", help="Fire whenever a new commit lands on the watched "
|
|
452
|
+
"branch, instead of on a cron schedule. See --git-repo/--git-branch."
|
|
453
|
+
),
|
|
454
|
+
git_repo: str | None = typer.Option(
|
|
455
|
+
None, "--git-repo", help="--on-git-commit only: repo to watch. Defaults to the "
|
|
456
|
+
"scheduler daemon's own working directory."
|
|
457
|
+
),
|
|
458
|
+
git_branch: str | None = typer.Option(
|
|
459
|
+
None, "--git-branch", help="--on-git-commit only: branch to watch. Defaults to "
|
|
460
|
+
"whatever's currently checked out."
|
|
461
|
+
),
|
|
462
|
+
task: str | None = typer.Option(None, "--task", help="The task to run, given inline."),
|
|
463
|
+
task_file: str | None = typer.Option(
|
|
464
|
+
None, "--task-file", help="Path to a file containing the task. Exactly one of "
|
|
465
|
+
"--task/--task-file is required."
|
|
466
|
+
),
|
|
467
|
+
name: str = typer.Option("", "--name", help="A friendly label shown in 'pcli schedule list'."),
|
|
468
|
+
session: str | None = typer.Option(
|
|
469
|
+
None, "--session", help="Append to this existing session on every run, instead of "
|
|
470
|
+
"starting a fresh one each time."
|
|
471
|
+
),
|
|
472
|
+
headed: bool = typer.Option(False, "--headed", help="Show the browser window, if used."),
|
|
473
|
+
notify_telegram_flag: bool = typer.Option(
|
|
474
|
+
False, "--notify-telegram", help="Send the final answer to the configured Telegram "
|
|
475
|
+
"chat after each run."
|
|
476
|
+
),
|
|
477
|
+
quiet: bool = typer.Option(
|
|
478
|
+
True, "--quiet/--no-quiet", help="Only keep the final answer in the run's progress "
|
|
479
|
+
"log, not tool-call-by-tool-call output."
|
|
480
|
+
),
|
|
481
|
+
max_cost: float | None = typer.Option(
|
|
482
|
+
None, "--max-cost", help="Hard cap on this job's own runs (USD), overriding "
|
|
483
|
+
"max_session_cost_usd just for it - never persisted to config.toml, never affects "
|
|
484
|
+
"other jobs or the TUI. Omit to use whatever max_session_cost_usd is already "
|
|
485
|
+
"configured (unset by default - no cap)."
|
|
486
|
+
),
|
|
487
|
+
audit: bool = typer.Option(
|
|
488
|
+
False, "--audit", help="Record a tamper-evident audit log for this job's own runs, "
|
|
489
|
+
"overriding audit_mode_enabled just for it - never persisted to config.toml, never "
|
|
490
|
+
"affects other jobs or the TUI."
|
|
491
|
+
),
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Adds a new recurring job. Nothing runs until 'pcli schedule run' (the
|
|
494
|
+
daemon) is actually started - adding a job only saves it. Exactly one
|
|
495
|
+
of --cron/--on-file-change/--on-git-commit selects the trigger; the job
|
|
496
|
+
fires either on that time schedule or the next time the watched
|
|
497
|
+
file/commit changes (scheduler/triggers.py, polled by the daemon the
|
|
498
|
+
same way a cron job's due time is)."""
|
|
499
|
+
from croniter import croniter
|
|
500
|
+
|
|
501
|
+
from pcli.scheduler.models import ScheduleJob
|
|
502
|
+
from pcli.scheduler.store import add_job
|
|
503
|
+
|
|
504
|
+
if bool(task) == bool(task_file):
|
|
505
|
+
typer.echo("Provide exactly one of --task or --task-file.", err=True)
|
|
506
|
+
raise typer.Exit(code=1)
|
|
507
|
+
|
|
508
|
+
triggers_given = sum(1 for t in (cron, on_file_change, on_git_commit) if t)
|
|
509
|
+
if triggers_given != 1:
|
|
510
|
+
typer.echo(
|
|
511
|
+
"Provide exactly one of --cron, --on-file-change, or --on-git-commit.", err=True
|
|
512
|
+
)
|
|
513
|
+
raise typer.Exit(code=1)
|
|
514
|
+
|
|
515
|
+
if cron is not None:
|
|
516
|
+
if not croniter.is_valid(cron):
|
|
517
|
+
typer.echo(f"'{cron}' isn't a valid 5-field cron expression.", err=True)
|
|
518
|
+
raise typer.Exit(code=1)
|
|
519
|
+
job = ScheduleJob(
|
|
520
|
+
name=name,
|
|
521
|
+
trigger="cron",
|
|
522
|
+
cron=cron,
|
|
523
|
+
task=task,
|
|
524
|
+
task_file=task_file,
|
|
525
|
+
session_id=session,
|
|
526
|
+
headed=headed,
|
|
527
|
+
notify_telegram=notify_telegram_flag,
|
|
528
|
+
quiet=quiet,
|
|
529
|
+
max_cost_usd=max_cost,
|
|
530
|
+
audit=audit,
|
|
531
|
+
)
|
|
532
|
+
description = cron
|
|
533
|
+
elif on_file_change is not None:
|
|
534
|
+
job = ScheduleJob(
|
|
535
|
+
name=name,
|
|
536
|
+
trigger="file_change",
|
|
537
|
+
watch_path=on_file_change,
|
|
538
|
+
task=task,
|
|
539
|
+
task_file=task_file,
|
|
540
|
+
session_id=session,
|
|
541
|
+
headed=headed,
|
|
542
|
+
notify_telegram=notify_telegram_flag,
|
|
543
|
+
quiet=quiet,
|
|
544
|
+
max_cost_usd=max_cost,
|
|
545
|
+
audit=audit,
|
|
546
|
+
)
|
|
547
|
+
description = f"on change: {on_file_change}"
|
|
548
|
+
else:
|
|
549
|
+
job = ScheduleJob(
|
|
550
|
+
name=name,
|
|
551
|
+
trigger="git_commit",
|
|
552
|
+
watch_git_repo=git_repo,
|
|
553
|
+
watch_git_branch=git_branch,
|
|
554
|
+
task=task,
|
|
555
|
+
task_file=task_file,
|
|
556
|
+
session_id=session,
|
|
557
|
+
headed=headed,
|
|
558
|
+
notify_telegram=notify_telegram_flag,
|
|
559
|
+
quiet=quiet,
|
|
560
|
+
max_cost_usd=max_cost,
|
|
561
|
+
audit=audit,
|
|
562
|
+
)
|
|
563
|
+
description = f"on commit: {git_repo or '.'}" + (f" [{git_branch}]" if git_branch else "")
|
|
564
|
+
|
|
565
|
+
add_job(job)
|
|
566
|
+
typer.echo(f"Added job {job.id} ({description}). Start 'pcli schedule run' to begin executing it.")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@schedule_app.command("list")
|
|
570
|
+
def schedule_list() -> None:
|
|
571
|
+
from pcli.scheduler.store import read_schedule
|
|
572
|
+
|
|
573
|
+
jobs = read_schedule().jobs
|
|
574
|
+
if not jobs:
|
|
575
|
+
typer.echo("No scheduled jobs. Add one with: pcli schedule add --cron '...' --task '...'")
|
|
576
|
+
return
|
|
577
|
+
for job in jobs:
|
|
578
|
+
state = "enabled" if job.enabled else "disabled"
|
|
579
|
+
last = f"{job.last_status} @ {job.last_run_at.isoformat()}" if job.last_run_at else "never run"
|
|
580
|
+
label = job.name or "(unnamed)"
|
|
581
|
+
if job.trigger == "cron":
|
|
582
|
+
next_run = job.next_run_at.isoformat() if job.next_run_at else "not yet computed"
|
|
583
|
+
trigger_desc = f"[{job.cron}] {state} next: {next_run}"
|
|
584
|
+
elif job.trigger == "file_change":
|
|
585
|
+
trigger_desc = f"watching: {job.watch_path} {state}"
|
|
586
|
+
else:
|
|
587
|
+
repo = job.watch_git_repo or "."
|
|
588
|
+
branch = f" [{job.watch_git_branch}]" if job.watch_git_branch else ""
|
|
589
|
+
trigger_desc = f"watching: commits on {repo}{branch} {state}"
|
|
590
|
+
typer.echo(f"{job.id} {label} {trigger_desc} last: {last}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@schedule_app.command("remove")
|
|
594
|
+
def schedule_remove(job_id: str) -> None:
|
|
595
|
+
from pcli.scheduler.store import remove_job
|
|
596
|
+
|
|
597
|
+
if not remove_job(job_id):
|
|
598
|
+
typer.echo(f"No job found with id '{job_id}'.", err=True)
|
|
599
|
+
raise typer.Exit(code=1)
|
|
600
|
+
typer.echo(f"Removed job {job_id}.")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@schedule_app.command("enable")
|
|
604
|
+
def schedule_enable(job_id: str) -> None:
|
|
605
|
+
from pcli.scheduler.store import set_job_enabled
|
|
606
|
+
|
|
607
|
+
if not set_job_enabled(job_id, True):
|
|
608
|
+
typer.echo(f"No job found with id '{job_id}'.", err=True)
|
|
609
|
+
raise typer.Exit(code=1)
|
|
610
|
+
typer.echo(f"Enabled job {job_id}.")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@schedule_app.command("disable")
|
|
614
|
+
def schedule_disable(job_id: str) -> None:
|
|
615
|
+
from pcli.scheduler.store import set_job_enabled
|
|
616
|
+
|
|
617
|
+
if not set_job_enabled(job_id, False):
|
|
618
|
+
typer.echo(f"No job found with id '{job_id}'.", err=True)
|
|
619
|
+
raise typer.Exit(code=1)
|
|
620
|
+
typer.echo(f"Disabled job {job_id}.")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@schedule_app.command("run")
|
|
624
|
+
def schedule_run(
|
|
625
|
+
poll_interval: int = typer.Option(
|
|
626
|
+
30, "--poll-interval", help="Seconds between checking schedule.json for due jobs."
|
|
627
|
+
),
|
|
628
|
+
) -> None:
|
|
629
|
+
"""The daemon: runs until interrupted (Ctrl+C), firing each enabled
|
|
630
|
+
job's task when its cron schedule says it's due. Point an OS-level
|
|
631
|
+
scheduler (Task Scheduler/systemd/a 'nohup'/tmux session) at this
|
|
632
|
+
command to keep it running - it is itself the thing that decides
|
|
633
|
+
*when*, not something an external cron needs to re-invoke per job."""
|
|
634
|
+
from pcli.agent.headless import HeadlessTurnResult
|
|
635
|
+
from pcli.scheduler.daemon import run_scheduler_daemon
|
|
636
|
+
from pcli.scheduler.models import ScheduleJob
|
|
637
|
+
|
|
638
|
+
async def _run() -> None:
|
|
639
|
+
settings = get_settings()
|
|
640
|
+
if not settings.is_configured():
|
|
641
|
+
typer.echo(
|
|
642
|
+
"Gateway not configured. Set PCLI_GATEWAY_URL (and PCLI_GATEWAY_API_KEY if "
|
|
643
|
+
"your gateway requires auth) or edit the config file first.",
|
|
644
|
+
err=True,
|
|
645
|
+
)
|
|
646
|
+
raise typer.Exit(code=1)
|
|
647
|
+
|
|
648
|
+
def on_job_run(
|
|
649
|
+
job: ScheduleJob, result: HeadlessTurnResult | None, error: Exception | None
|
|
650
|
+
) -> None:
|
|
651
|
+
if error is not None:
|
|
652
|
+
typer.echo(f"[{job.id}] {job.name or job.cron} failed: {error}", err=True)
|
|
653
|
+
else:
|
|
654
|
+
typer.echo(f"[{job.id}] {job.name or job.cron} finished.")
|
|
655
|
+
|
|
656
|
+
typer.echo(f"Scheduler running (polling every {poll_interval}s). Press Ctrl+C to stop.")
|
|
657
|
+
await run_scheduler_daemon(settings, poll_interval_s=poll_interval, on_job_run=on_job_run)
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
asyncio.run(_run())
|
|
661
|
+
except KeyboardInterrupt:
|
|
662
|
+
typer.echo("\nStopped.")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@schedule_app.command("run-now")
|
|
666
|
+
def schedule_run_now(job_id: str) -> None:
|
|
667
|
+
"""Fires one job immediately, bypassing its cron schedule - for testing
|
|
668
|
+
a job works before trusting the daemon's own timing."""
|
|
669
|
+
from pcli.scheduler.store import get_job
|
|
670
|
+
|
|
671
|
+
job = get_job(job_id)
|
|
672
|
+
if job is None:
|
|
673
|
+
typer.echo(f"No job found with id '{job_id}'.", err=True)
|
|
674
|
+
raise typer.Exit(code=1)
|
|
675
|
+
|
|
676
|
+
task = job.task
|
|
677
|
+
if job.task_file:
|
|
678
|
+
task = Path(job.task_file).read_text(encoding="utf-8")
|
|
679
|
+
assert task is not None
|
|
680
|
+
|
|
681
|
+
async def _run() -> None:
|
|
682
|
+
settings = get_settings()
|
|
683
|
+
if not settings.is_configured():
|
|
684
|
+
typer.echo(
|
|
685
|
+
"Gateway not configured. Set PCLI_GATEWAY_URL (and PCLI_GATEWAY_API_KEY if "
|
|
686
|
+
"your gateway requires auth) or edit the config file first.",
|
|
687
|
+
err=True,
|
|
688
|
+
)
|
|
689
|
+
raise typer.Exit(code=1)
|
|
690
|
+
|
|
691
|
+
store = SessionStore()
|
|
692
|
+
try:
|
|
693
|
+
result = await run_task_once(
|
|
694
|
+
task,
|
|
695
|
+
settings=settings,
|
|
696
|
+
store=store,
|
|
697
|
+
cwd=Path.cwd(),
|
|
698
|
+
session_id=job.session_id,
|
|
699
|
+
quiet=job.quiet,
|
|
700
|
+
headed=job.headed,
|
|
701
|
+
notify_telegram_flag=job.notify_telegram,
|
|
702
|
+
on_progress=typer.echo,
|
|
703
|
+
)
|
|
704
|
+
except ScheduledSessionNotFoundError:
|
|
705
|
+
typer.echo(f"No session found with id '{job.session_id}'.", err=True)
|
|
706
|
+
raise typer.Exit(code=1) from None
|
|
707
|
+
except Exception as exc:
|
|
708
|
+
typer.echo(f"Run failed: {exc}", err=True)
|
|
709
|
+
raise typer.Exit(code=1) from exc
|
|
710
|
+
|
|
711
|
+
typer.echo(f"\n{result.final_text}")
|
|
712
|
+
|
|
713
|
+
asyncio.run(_run())
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _one_line_description(description: str, max_chars: int = 100) -> str:
|
|
717
|
+
"""Reduces a tool's (possibly multi-paragraph, possibly one very long
|
|
718
|
+
paragraph) description to a single line for 'pcli tools list': keeps
|
|
719
|
+
only the first line (drops anything after an internal "\\n\\n", e.g.
|
|
720
|
+
web_search's "Query tips:" paragraph), then hard-truncates at a word
|
|
721
|
+
boundary if that first line is itself still too long (e.g. web_fetch's
|
|
722
|
+
description is one long paragraph with no internal newline at all)."""
|
|
723
|
+
first_line = description.splitlines()[0].strip()
|
|
724
|
+
if len(first_line) <= max_chars:
|
|
725
|
+
return first_line
|
|
726
|
+
truncated = first_line[:max_chars].rsplit(" ", 1)[0]
|
|
727
|
+
return truncated + "..."
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@tools_app.command("list")
|
|
731
|
+
def tools_list() -> None:
|
|
732
|
+
"""Lists every built-in tool (not toolbox-discovered or
|
|
733
|
+
register_agent_tool-created ones, which are dynamic/session state -
|
|
734
|
+
see 'pcli toolbox list' for the toolbox case) with its read-only/
|
|
735
|
+
mutating type, permission/plan-mode requirements, and a one-line
|
|
736
|
+
description."""
|
|
737
|
+
from pcli.tools.registry import build_default_registry
|
|
738
|
+
|
|
739
|
+
registry = build_default_registry()
|
|
740
|
+
tools = sorted(registry, key=lambda t: t.name)
|
|
741
|
+
name_width = max(len(t.name) for t in tools)
|
|
742
|
+
for tool in tools:
|
|
743
|
+
read_only_label = "read-only" if tool.read_only else "mutating"
|
|
744
|
+
typer.echo(
|
|
745
|
+
f"{tool.name:<{name_width}} [{read_only_label:<9}] "
|
|
746
|
+
f"needs_permission={tool.needs_permission!s:<5} "
|
|
747
|
+
f"plan_mode_safe={tool.plan_mode_safe!s:<5} "
|
|
748
|
+
f"{_one_line_description(tool.description)}"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def main() -> None:
|
|
753
|
+
app()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
if __name__ == "__main__":
|
|
757
|
+
main()
|