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
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()