pulse-coding-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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/cli.py
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import getpass
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import warnings
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from pulse import __version__
|
|
16
|
+
from pulse.auth import (
|
|
17
|
+
AuthenticationManager,
|
|
18
|
+
AuthError,
|
|
19
|
+
AuthTimeoutError,
|
|
20
|
+
StateMismatchError,
|
|
21
|
+
UserCancelledError,
|
|
22
|
+
get_current_user,
|
|
23
|
+
is_authenticated,
|
|
24
|
+
login,
|
|
25
|
+
)
|
|
26
|
+
from pulse.ci import github_client
|
|
27
|
+
from pulse.ci.runner import CIRunner
|
|
28
|
+
from pulse.config import load_agent_config
|
|
29
|
+
from pulse.conversations import ConversationManager
|
|
30
|
+
from pulse.edits import EditProposal
|
|
31
|
+
from pulse.interactive import InteractivePrompt, parse_slash_command
|
|
32
|
+
from pulse.provider_keys import ProviderKeyError, ProviderKeyStore
|
|
33
|
+
from pulse.providers.manager import ProviderManager
|
|
34
|
+
from pulse.runtime import build_runtime
|
|
35
|
+
from pulse.telemetry import set_correlation_id
|
|
36
|
+
from pulse.tool_registry import ToolInvocation
|
|
37
|
+
|
|
38
|
+
from .cli_ui import (
|
|
39
|
+
print_all_models_list,
|
|
40
|
+
print_auth_prompt,
|
|
41
|
+
print_banner,
|
|
42
|
+
print_chat_card,
|
|
43
|
+
print_chat_created,
|
|
44
|
+
print_chat_exported,
|
|
45
|
+
print_chat_list,
|
|
46
|
+
print_chat_search_results,
|
|
47
|
+
print_chat_switched,
|
|
48
|
+
print_cli_output,
|
|
49
|
+
print_current_model_card,
|
|
50
|
+
print_error,
|
|
51
|
+
print_help_screen,
|
|
52
|
+
print_info,
|
|
53
|
+
print_model_selection,
|
|
54
|
+
print_provider_changed_card,
|
|
55
|
+
print_provider_selection,
|
|
56
|
+
print_session_footer,
|
|
57
|
+
print_signed_in,
|
|
58
|
+
print_status_cards,
|
|
59
|
+
print_success,
|
|
60
|
+
print_verification,
|
|
61
|
+
print_warning,
|
|
62
|
+
task_spinner,
|
|
63
|
+
thinking_spinner,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
"""Build the public parser shared by batch and interactive modes."""
|
|
69
|
+
parser = argparse.ArgumentParser(prog="pulse", description="Permissioned single-model project agent.")
|
|
70
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
71
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
72
|
+
|
|
73
|
+
subparsers.add_parser("version", help="Show the installed Pulse version.")
|
|
74
|
+
|
|
75
|
+
ask_parser = subparsers.add_parser("ask", help="Ask about the current project.")
|
|
76
|
+
ask_parser.add_argument("question", nargs="+")
|
|
77
|
+
|
|
78
|
+
model_parser = subparsers.add_parser("model", help="Interactive AI provider & model manager.")
|
|
79
|
+
model_parser.add_argument("provider", nargs="?", default=None, help="Provider name or subcommand ('list', 'current', gemini, openrouter, openai, anthropic, groq, deepseek)")
|
|
80
|
+
model_parser.add_argument("model", nargs="?", default=None, help="Model identifier")
|
|
81
|
+
|
|
82
|
+
keys_parser = subparsers.add_parser(
|
|
83
|
+
"keys", help="Securely manage provider API keys in the OS credential vault."
|
|
84
|
+
)
|
|
85
|
+
keys_subparsers = keys_parser.add_subparsers(dest="keys_command")
|
|
86
|
+
keys_subparsers.add_parser("list", help="Show provider key configuration without values.")
|
|
87
|
+
keys_set = keys_subparsers.add_parser("set", help="Securely prompt for a provider API key.")
|
|
88
|
+
keys_set.add_argument("provider", help="Provider name")
|
|
89
|
+
keys_rotate = keys_subparsers.add_parser(
|
|
90
|
+
"rotate", help="Securely replace a configured provider API key."
|
|
91
|
+
)
|
|
92
|
+
keys_rotate.add_argument("provider", help="Provider name")
|
|
93
|
+
keys_remove = keys_subparsers.add_parser(
|
|
94
|
+
"remove", help="Remove a provider key from secure local storage."
|
|
95
|
+
)
|
|
96
|
+
keys_remove.add_argument("provider", help="Provider name")
|
|
97
|
+
|
|
98
|
+
# ── Conversation management ──────────────────────────────────────────────
|
|
99
|
+
chat_parser = subparsers.add_parser("chat", help="Manage conversations.")
|
|
100
|
+
chat_subs = chat_parser.add_subparsers(dest="chat_cmd")
|
|
101
|
+
|
|
102
|
+
chat_subs.add_parser("new", help="Start a new conversation.").add_argument(
|
|
103
|
+
"--title", default=None, help="Optional title for the new conversation."
|
|
104
|
+
)
|
|
105
|
+
chat_subs.add_parser("list", help="List all conversations.")
|
|
106
|
+
|
|
107
|
+
chat_switch = chat_subs.add_parser("switch", help="Switch to a conversation by ID.")
|
|
108
|
+
chat_switch.add_argument("id", help="Conversation ID (or unique prefix)")
|
|
109
|
+
|
|
110
|
+
chat_delete = chat_subs.add_parser("delete", help="Delete a conversation.")
|
|
111
|
+
chat_delete.add_argument("id", help="Conversation ID (or unique prefix)")
|
|
112
|
+
|
|
113
|
+
chat_rename = chat_subs.add_parser("rename", help="Rename a conversation.")
|
|
114
|
+
chat_rename.add_argument("id", help="Conversation ID (or unique prefix)")
|
|
115
|
+
chat_rename.add_argument("title", help="New title")
|
|
116
|
+
|
|
117
|
+
chat_export = chat_subs.add_parser("export", help="Export a conversation to Markdown or JSON.")
|
|
118
|
+
chat_export.add_argument("id", help="Conversation ID (or unique prefix)")
|
|
119
|
+
chat_export.add_argument("--output", default=None, help="Output file path")
|
|
120
|
+
chat_export.add_argument("--format", dest="fmt", choices=["md", "json"], default="md", help="Export format (md or json)")
|
|
121
|
+
|
|
122
|
+
chat_search = chat_subs.add_parser("search", help="Search conversations by title or content.")
|
|
123
|
+
chat_search.add_argument("query", help="Search query")
|
|
124
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
subparsers.add_parser("status", help="Show agent configuration.")
|
|
127
|
+
doctor_parser = subparsers.add_parser(
|
|
128
|
+
"doctor", help="Check CLI, provider, and production deployment readiness."
|
|
129
|
+
)
|
|
130
|
+
doctor_parser.add_argument(
|
|
131
|
+
"--production", action="store_true", help="Run release-blocking production checks."
|
|
132
|
+
)
|
|
133
|
+
doctor_parser.add_argument(
|
|
134
|
+
"--target", choices=("local", "remote"), default="local"
|
|
135
|
+
)
|
|
136
|
+
doctor_parser.add_argument(
|
|
137
|
+
"--json", action="store_true", dest="as_json", help="Emit machine-readable JSON."
|
|
138
|
+
)
|
|
139
|
+
mutations_parser = subparsers.add_parser("mutations", help="Show tracked repository mutations.")
|
|
140
|
+
mutations_parser.add_argument("--last", action="store_true", help="Show only the latest transaction.")
|
|
141
|
+
edit_parser = subparsers.add_parser("edit", help="Propose a file replacement and request approval.")
|
|
142
|
+
edit_parser.add_argument("file")
|
|
143
|
+
edit_parser.add_argument("content")
|
|
144
|
+
patch_parser = subparsers.add_parser("patch", help="Patch a specific function or class in a file.")
|
|
145
|
+
patch_parser.add_argument("file", help="Path to the target file")
|
|
146
|
+
patch_parser.add_argument("target", help="Name of the function or class to patch")
|
|
147
|
+
patch_parser.add_argument("operation", choices=["insert", "replace", "delete", "rename"], help="Patch operation")
|
|
148
|
+
patch_parser.add_argument("content", nargs="?", default=None, help="Patch content string or path to a file containing the content")
|
|
149
|
+
subparsers.add_parser("rollback", help="Rollback the latest approved edit.")
|
|
150
|
+
subparsers.add_parser("index", help="Incrementally index the repository.")
|
|
151
|
+
search_parser = subparsers.add_parser("search", help="Search indexed repository files.")
|
|
152
|
+
search_parser.add_argument("query")
|
|
153
|
+
symbols_parser = subparsers.add_parser("symbols", help="Show indexed symbols from a file.")
|
|
154
|
+
symbols_parser.add_argument("file")
|
|
155
|
+
subparsers.add_parser("verify", help="Run the detected project test suite.")
|
|
156
|
+
subparsers.add_parser("git", help="Show Git status, diff analysis, and a commit suggestion.")
|
|
157
|
+
memory_parser = subparsers.add_parser("memory", help="Inspect long-term memory or save a preference.")
|
|
158
|
+
memory_parser.add_argument("--query", default="", help="Search remembered context.")
|
|
159
|
+
memory_parser.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"), help="Save a user preference.")
|
|
160
|
+
ci_parser = subparsers.add_parser("ci", help="Run CI for a pull request.")
|
|
161
|
+
ci_parser.add_argument("--pr", type=int, required=True, help="Pull request number to process.")
|
|
162
|
+
tasks_parser = subparsers.add_parser("tasks", help="List workspace tasks.")
|
|
163
|
+
tasks_parser.add_argument("--status", help="Filter tasks by status (PENDING, RUNNING, COMPLETED, FAILED, PAUSED, CANCELLED)")
|
|
164
|
+
task_detail_parser = subparsers.add_parser("task", help="Display task details.")
|
|
165
|
+
task_detail_parser.add_argument("id", help="Task ID")
|
|
166
|
+
resume_parser = subparsers.add_parser("resume", help="Resume a paused or failed task.")
|
|
167
|
+
resume_parser.add_argument("id", help="Task ID to resume")
|
|
168
|
+
cancel_parser = subparsers.add_parser("cancel", help="Cancel a pending, queued, or running task.")
|
|
169
|
+
cancel_parser.add_argument("id", help="Task ID to cancel")
|
|
170
|
+
cancel_parser.add_argument("--reason", default="Cancelled via CLI", help="Cancellation reason")
|
|
171
|
+
subparsers.add_parser("sessions", help="List all sessions.")
|
|
172
|
+
session_parser = subparsers.add_parser("session", help="Display session details.")
|
|
173
|
+
session_parser.add_argument("id", help="Session ID")
|
|
174
|
+
resume_session_parser = subparsers.add_parser("resume-session", help="Resume an archived or inactive session.")
|
|
175
|
+
resume_session_parser.add_argument("id", help="Session ID to resume")
|
|
176
|
+
|
|
177
|
+
# Authentication commands
|
|
178
|
+
subparsers.add_parser(
|
|
179
|
+
"login",
|
|
180
|
+
help="Sign in with Google OAuth 2.0 PKCE flow.",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
subparsers.add_parser("logout", help="Sign out and clear stored tokens.")
|
|
184
|
+
subparsers.add_parser("whoami", help="Show the currently signed-in user.")
|
|
185
|
+
subparsers.add_parser("auth-status", help="Show current authentication status.")
|
|
186
|
+
|
|
187
|
+
serve_parser = subparsers.add_parser("serve", help="Start the local JSON-RPC WebSocket server for IDE clients.")
|
|
188
|
+
serve_parser.add_argument("--host", default="127.0.0.1")
|
|
189
|
+
serve_parser.add_argument("--port", type=int, default=8765)
|
|
190
|
+
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run_main(argv: list[str] | None = None) -> None:
|
|
195
|
+
set_correlation_id()
|
|
196
|
+
parser = _build_parser()
|
|
197
|
+
args = parser.parse_args(argv)
|
|
198
|
+
workspace = Path.cwd()
|
|
199
|
+
|
|
200
|
+
if args.command == "version":
|
|
201
|
+
print(f"pulse {__version__}")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if args.command == "keys":
|
|
205
|
+
_handle_keys_command(workspace, args)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if args.command in {"login", "logout", "whoami", "auth-status"}:
|
|
209
|
+
auth = AuthenticationManager(workspace)
|
|
210
|
+
if args.command == "login":
|
|
211
|
+
if _handle_login_command():
|
|
212
|
+
_run_provider_onboarding(workspace)
|
|
213
|
+
elif args.command == "logout":
|
|
214
|
+
auth.logout()
|
|
215
|
+
print_success("Signed out and cleared the stored session.")
|
|
216
|
+
elif args.command == "whoami":
|
|
217
|
+
_show_whoami()
|
|
218
|
+
elif auth.is_authenticated():
|
|
219
|
+
user = auth.get_current_user()
|
|
220
|
+
identity = (user.name or user.email) if user else "Authenticated user"
|
|
221
|
+
print_success(f"Signed in as {identity}.")
|
|
222
|
+
else:
|
|
223
|
+
print_warning("Not signed in. Run `pulse login` to authenticate.")
|
|
224
|
+
raise SystemExit(1)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if args.command == "model":
|
|
228
|
+
_handle_model_command(workspace, args.provider, args.model)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
if args.command == "chat":
|
|
232
|
+
_handle_chat_command(workspace, args)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
config = load_agent_config(workspace)
|
|
236
|
+
if args.command == "serve":
|
|
237
|
+
from pulse.rpc import serve
|
|
238
|
+
|
|
239
|
+
asyncio.run(serve(str(workspace), args.host, args.port))
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
runtime = build_runtime(workspace, config)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
if args.command == "ask":
|
|
246
|
+
with task_spinner("Generating response"):
|
|
247
|
+
runtime.agent.ask(" ".join(args.question), auto_approve_reads=True)
|
|
248
|
+
elif args.command == "status":
|
|
249
|
+
print_status_cards(config, runtime.provider, runtime=runtime)
|
|
250
|
+
elif args.command == "doctor":
|
|
251
|
+
passed = print_doctor(
|
|
252
|
+
config,
|
|
253
|
+
runtime.provider,
|
|
254
|
+
workspace,
|
|
255
|
+
production=args.production,
|
|
256
|
+
target=args.target,
|
|
257
|
+
as_json=args.as_json,
|
|
258
|
+
)
|
|
259
|
+
if not passed:
|
|
260
|
+
raise SystemExit(2)
|
|
261
|
+
|
|
262
|
+
elif args.command == "mutations":
|
|
263
|
+
if not is_authenticated():
|
|
264
|
+
print_info("Authentication required. Please login first.")
|
|
265
|
+
return
|
|
266
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="mutations", arguments={"last": args.last}))).content, title="Mutations")
|
|
267
|
+
elif args.command == "edit":
|
|
268
|
+
if not is_authenticated():
|
|
269
|
+
print_info("Authentication required. Please login first.")
|
|
270
|
+
return
|
|
271
|
+
result = asyncio.run(runtime.tools.execute(ToolInvocation(name="edit", arguments={"file": args.file, "content": args.content, "reason": "CLI requested edit", "approve": approve_in_cli})))
|
|
272
|
+
print_cli_output(result.content, title="Edit Result")
|
|
273
|
+
elif args.command == "patch":
|
|
274
|
+
if not is_authenticated():
|
|
275
|
+
print_info("Authentication required. Please login first.")
|
|
276
|
+
return
|
|
277
|
+
from pulse.patch import PatchEngine
|
|
278
|
+
content = args.content
|
|
279
|
+
if content and Path(content).is_file():
|
|
280
|
+
content = Path(content).read_text(encoding="utf-8")
|
|
281
|
+
|
|
282
|
+
patch_engine = PatchEngine(
|
|
283
|
+
edits=runtime.edits,
|
|
284
|
+
safety_manager=runtime.reasoning_engine.safety_manager,
|
|
285
|
+
mutations=runtime.mutations,
|
|
286
|
+
context_manager=runtime.context_manager,
|
|
287
|
+
reasoning_engine=runtime.reasoning_engine,
|
|
288
|
+
task_manager=runtime.task_manager
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def run_patch():
|
|
292
|
+
try:
|
|
293
|
+
success = await patch_engine.apply_patch(
|
|
294
|
+
file_path=args.file,
|
|
295
|
+
target_name=args.target,
|
|
296
|
+
operation=args.operation,
|
|
297
|
+
content=content,
|
|
298
|
+
approve=approve_in_cli
|
|
299
|
+
)
|
|
300
|
+
if success:
|
|
301
|
+
print_info("Patch applied successfully.")
|
|
302
|
+
else:
|
|
303
|
+
print_error("Patch rejected or failed safety check.")
|
|
304
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
305
|
+
except Exception as e: # noqa: BLE001
|
|
306
|
+
# Intentionally broad at CLI boundary to gracefully report user errors.
|
|
307
|
+
print_error(f"Patch error: {e}")
|
|
308
|
+
|
|
309
|
+
asyncio.run(run_patch())
|
|
310
|
+
elif args.command == "rollback":
|
|
311
|
+
if not is_authenticated():
|
|
312
|
+
print_info("Authentication required. Please login first.")
|
|
313
|
+
return
|
|
314
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="rollback"))).content, title="Rollback")
|
|
315
|
+
elif args.command == "index":
|
|
316
|
+
with task_spinner("Indexing repository"):
|
|
317
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="index"))).content, title="Index")
|
|
318
|
+
elif args.command == "search":
|
|
319
|
+
with task_spinner("Searching repository"):
|
|
320
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="search", arguments={"query": args.query}))).content, title="Search")
|
|
321
|
+
elif args.command == "symbols":
|
|
322
|
+
with task_spinner("Parsing symbols"):
|
|
323
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="symbols", arguments={"file": args.file}))).content, title="Symbols")
|
|
324
|
+
elif args.command == "verify":
|
|
325
|
+
with task_spinner("Running verification test suite"):
|
|
326
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="verify"))).content, title="Verify")
|
|
327
|
+
elif args.command == "git":
|
|
328
|
+
with task_spinner("Analyzing Git repository state"):
|
|
329
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="git"))).content, title="Git")
|
|
330
|
+
elif args.command == "memory":
|
|
331
|
+
arguments = {"query": args.query}
|
|
332
|
+
if args.set:
|
|
333
|
+
arguments.update({"preference_key": args.set[0], "preference_value": args.set[1]})
|
|
334
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="memory", arguments=arguments))).content, title="Memory")
|
|
335
|
+
elif args.command == "tasks":
|
|
336
|
+
arguments = {"status": args.status} if hasattr(args, "status") else {}
|
|
337
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="tasks", arguments=arguments))).content, title="Tasks")
|
|
338
|
+
elif args.command == "task":
|
|
339
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="task", arguments={"id": args.id, "action": "show"}))).content, title=f"Task {args.id}")
|
|
340
|
+
elif args.command == "resume":
|
|
341
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="task", arguments={"id": args.id, "action": "resume"}))).content, title=f"Resume Task {args.id}")
|
|
342
|
+
elif args.command == "cancel":
|
|
343
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="cancel", arguments={"id": args.id, "reason": getattr(args, "reason", "CLI cancellation")}))) .content, title=f"Cancel Task {args.id}")
|
|
344
|
+
elif args.command == "sessions":
|
|
345
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="sessions", arguments={}))).content, title="Sessions")
|
|
346
|
+
elif args.command == "session":
|
|
347
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="session", arguments={"id": args.id}))).content, title=f"Session {args.id}")
|
|
348
|
+
elif args.command == "resume-session":
|
|
349
|
+
print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="resume-session", arguments={"id": args.id}))).content, title=f"Resume Session {args.id}")
|
|
350
|
+
elif args.command == "ci":
|
|
351
|
+
if not is_authenticated():
|
|
352
|
+
print_info("Authentication required. Please login first.")
|
|
353
|
+
return
|
|
354
|
+
client = github_client.GitHubClient()
|
|
355
|
+
runner = CIRunner(client, workspace=Path.cwd())
|
|
356
|
+
comment = asyncio.run(runner.run_pr(args.pr))
|
|
357
|
+
print_cli_output(comment, title="CI Comment")
|
|
358
|
+
else:
|
|
359
|
+
_handle_interactive_mode(runtime.auth, runtime.agent, runtime=runtime)
|
|
360
|
+
finally:
|
|
361
|
+
runtime.audit.print_summary()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _handle_model_command(
|
|
365
|
+
workspace: Path, provider_arg: str | None = None, model_arg: str | None = None
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Handle interactive model manager, subcommands ('list', 'current'), and direct CLI args."""
|
|
368
|
+
pm = ProviderManager(workspace)
|
|
369
|
+
|
|
370
|
+
# Subcommand: pulse model current
|
|
371
|
+
if provider_arg and provider_arg.lower() == "current":
|
|
372
|
+
active_prov, active_mod, warning = pm.validate_active_selection()
|
|
373
|
+
if warning:
|
|
374
|
+
print_warning(warning)
|
|
375
|
+
spec = pm.get_provider_spec(active_prov)
|
|
376
|
+
meta = pm.get_model_metadata(active_prov, active_mod)
|
|
377
|
+
providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
|
|
378
|
+
print_current_model_card(
|
|
379
|
+
spec.display_name,
|
|
380
|
+
active_mod,
|
|
381
|
+
meta,
|
|
382
|
+
spec.env_var,
|
|
383
|
+
providers_status.get(active_prov, False),
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Subcommand: pulse model list
|
|
388
|
+
if provider_arg and provider_arg.lower() == "list":
|
|
389
|
+
active_prov, active_mod, warning = pm.validate_active_selection()
|
|
390
|
+
if warning:
|
|
391
|
+
print_warning(warning)
|
|
392
|
+
if model_arg:
|
|
393
|
+
try:
|
|
394
|
+
spec = pm.get_provider_spec(model_arg)
|
|
395
|
+
print_model_selection(
|
|
396
|
+
spec.display_name,
|
|
397
|
+
spec.available_models,
|
|
398
|
+
spec.default_model,
|
|
399
|
+
active_mod if active_prov == spec.key else "",
|
|
400
|
+
)
|
|
401
|
+
except ValueError as error:
|
|
402
|
+
print_error(str(error))
|
|
403
|
+
sys.exit(1)
|
|
404
|
+
else:
|
|
405
|
+
providers = pm.list_providers()
|
|
406
|
+
print_all_models_list(providers, active_prov, active_mod)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
# Direct provider setting: pulse model openrouter [qwen/qwen3-coder]
|
|
410
|
+
if provider_arg:
|
|
411
|
+
try:
|
|
412
|
+
spec = pm.get_provider_spec(provider_arg)
|
|
413
|
+
except ValueError as error:
|
|
414
|
+
print_error(str(error))
|
|
415
|
+
sys.exit(1)
|
|
416
|
+
|
|
417
|
+
target_model = model_arg or spec.default_model
|
|
418
|
+
saved_prov, saved_mod = pm.save_selection(spec.key, target_model)
|
|
419
|
+
providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
|
|
420
|
+
print_provider_changed_card(
|
|
421
|
+
spec.display_name,
|
|
422
|
+
saved_mod,
|
|
423
|
+
spec.env_var,
|
|
424
|
+
providers_status.get(saved_prov, False),
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
selection = _prompt_provider_and_model(pm)
|
|
429
|
+
if selection is None:
|
|
430
|
+
return
|
|
431
|
+
chosen_key, chosen_model = selection
|
|
432
|
+
spec = pm.get_provider_spec(chosen_key)
|
|
433
|
+
saved_prov, saved_mod = pm.save_selection(chosen_key, chosen_model)
|
|
434
|
+
providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
|
|
435
|
+
print_provider_changed_card(
|
|
436
|
+
spec.display_name,
|
|
437
|
+
saved_mod,
|
|
438
|
+
spec.env_var,
|
|
439
|
+
providers_status.get(saved_prov, False),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _prompt_provider_and_model(pm: ProviderManager) -> tuple[str, str] | None:
|
|
444
|
+
"""Prompt for one provider/model pair without saving partial selection."""
|
|
445
|
+
active_prov, active_mod, warning = pm.validate_active_selection()
|
|
446
|
+
if warning:
|
|
447
|
+
print_warning(warning)
|
|
448
|
+
|
|
449
|
+
providers = pm.list_providers()
|
|
450
|
+
print_provider_selection(providers, active_prov)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
selection = (
|
|
454
|
+
input("Select provider number (1-6) or key [Enter keeps active]: ")
|
|
455
|
+
.strip()
|
|
456
|
+
.lower()
|
|
457
|
+
)
|
|
458
|
+
except (KeyboardInterrupt, EOFError):
|
|
459
|
+
print("\nCancelled model selection.")
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
if not selection:
|
|
463
|
+
chosen_key = active_prov
|
|
464
|
+
elif selection.isdigit():
|
|
465
|
+
idx = int(selection)
|
|
466
|
+
if 1 <= idx <= len(providers):
|
|
467
|
+
chosen_key = providers[idx - 1]["key"]
|
|
468
|
+
else:
|
|
469
|
+
print_error("Invalid provider selection index.")
|
|
470
|
+
return None
|
|
471
|
+
else:
|
|
472
|
+
try:
|
|
473
|
+
chosen_key = pm.get_provider_spec(selection).key
|
|
474
|
+
except ValueError as error:
|
|
475
|
+
print_error(str(error))
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
spec = pm.get_provider_spec(chosen_key)
|
|
479
|
+
print_model_selection(
|
|
480
|
+
spec.display_name,
|
|
481
|
+
spec.available_models,
|
|
482
|
+
spec.default_model,
|
|
483
|
+
active_mod if active_prov == spec.key else "",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
model_choice = input(
|
|
488
|
+
f"Select model number (1-{len(spec.available_models)}) or enter custom model [Enter keeps default]: "
|
|
489
|
+
).strip()
|
|
490
|
+
except (KeyboardInterrupt, EOFError):
|
|
491
|
+
print("\nCancelled model selection.")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
if not model_choice:
|
|
495
|
+
chosen_model = spec.default_model
|
|
496
|
+
elif model_choice.isdigit():
|
|
497
|
+
m_idx = int(model_choice)
|
|
498
|
+
if 1 <= m_idx <= len(spec.available_models):
|
|
499
|
+
chosen_model = spec.available_models[m_idx - 1].name
|
|
500
|
+
else:
|
|
501
|
+
print_error("Invalid model selection index.")
|
|
502
|
+
return None
|
|
503
|
+
elif model_choice.lower() == "c":
|
|
504
|
+
try:
|
|
505
|
+
chosen_model = input("Enter custom model identifier: ").strip()
|
|
506
|
+
except (KeyboardInterrupt, EOFError):
|
|
507
|
+
print("\nCancelled model selection.")
|
|
508
|
+
return
|
|
509
|
+
if not chosen_model:
|
|
510
|
+
chosen_model = spec.default_model
|
|
511
|
+
else:
|
|
512
|
+
chosen_model = model_choice
|
|
513
|
+
|
|
514
|
+
return chosen_key, chosen_model
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _handle_login_command() -> bool:
|
|
518
|
+
"""Handle `pulse login` experience."""
|
|
519
|
+
if is_authenticated():
|
|
520
|
+
user = get_current_user()
|
|
521
|
+
email = user.email if user else "user"
|
|
522
|
+
print_success(f"Already signed in as {email}.")
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
user = login()
|
|
527
|
+
if user:
|
|
528
|
+
print_signed_in(user.name, user.email)
|
|
529
|
+
return True
|
|
530
|
+
return False
|
|
531
|
+
except UserCancelledError:
|
|
532
|
+
print_error("Authentication was cancelled in the browser.")
|
|
533
|
+
sys.exit(1)
|
|
534
|
+
except AuthTimeoutError:
|
|
535
|
+
print_error("Authentication timed out waiting for browser callback.")
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
except StateMismatchError:
|
|
538
|
+
print_error("Authentication state mismatch. Possible security issue.")
|
|
539
|
+
sys.exit(1)
|
|
540
|
+
except AuthError as e:
|
|
541
|
+
print_error(f"Authentication failed: {e}")
|
|
542
|
+
sys.exit(1)
|
|
543
|
+
except ValueError as e:
|
|
544
|
+
print_error(f"Configuration error: {e}")
|
|
545
|
+
print_info(
|
|
546
|
+
"Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your .env file.\n"
|
|
547
|
+
"See .env.example and the README for setup instructions."
|
|
548
|
+
)
|
|
549
|
+
sys.exit(1)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _run_provider_onboarding(workspace: Path) -> bool:
|
|
553
|
+
"""Connect a provider/model/key immediately after successful login."""
|
|
554
|
+
print_info("Authentication complete. Connect your BYOK model provider.")
|
|
555
|
+
manager = ProviderManager(workspace)
|
|
556
|
+
selection = _prompt_provider_and_model(manager)
|
|
557
|
+
if selection is None:
|
|
558
|
+
print_warning("Provider setup skipped. Run /keys or /model at any time.")
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
provider, model = selection
|
|
562
|
+
store = ProviderKeyStore(workspace)
|
|
563
|
+
status = next(item for item in store.statuses() if item.provider == provider)
|
|
564
|
+
if status.configured:
|
|
565
|
+
print_info(
|
|
566
|
+
f"Using the existing {provider} credential from {status.source}."
|
|
567
|
+
)
|
|
568
|
+
elif not _prompt_and_store_provider_key(store, provider, rotating=False):
|
|
569
|
+
print_warning("Provider setup was not saved because no key was stored.")
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
saved_provider, saved_model = manager.save_selection(provider, model)
|
|
573
|
+
spec = manager.get_provider_spec(saved_provider)
|
|
574
|
+
print_provider_changed_card(
|
|
575
|
+
spec.display_name,
|
|
576
|
+
saved_model,
|
|
577
|
+
spec.env_var,
|
|
578
|
+
True,
|
|
579
|
+
)
|
|
580
|
+
print_success("BYOK setup complete. Your next message will use this provider.")
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _active_provider_has_key(workspace: Path) -> bool:
|
|
585
|
+
manager = ProviderManager(workspace)
|
|
586
|
+
active_provider, _ = manager.get_active_selection()
|
|
587
|
+
return any(
|
|
588
|
+
status.provider == active_provider and status.configured
|
|
589
|
+
for status in ProviderKeyStore(workspace).statuses()
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _read_hidden_provider_key(provider: str) -> str | None:
|
|
594
|
+
"""Read a secret only when the terminal can suppress input echo."""
|
|
595
|
+
try:
|
|
596
|
+
with warnings.catch_warnings():
|
|
597
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
598
|
+
return getpass.getpass(f"Enter the {provider} API key (input hidden): ")
|
|
599
|
+
except getpass.GetPassWarning:
|
|
600
|
+
print_error(
|
|
601
|
+
"Secure hidden input is unavailable in this terminal; the key was not read."
|
|
602
|
+
)
|
|
603
|
+
except (KeyboardInterrupt, EOFError):
|
|
604
|
+
print_warning("API key entry cancelled.")
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _prompt_and_store_provider_key(
|
|
609
|
+
store: ProviderKeyStore, provider: str, *, rotating: bool
|
|
610
|
+
) -> bool:
|
|
611
|
+
value = _read_hidden_provider_key(provider)
|
|
612
|
+
if value is None:
|
|
613
|
+
return False
|
|
614
|
+
try:
|
|
615
|
+
variable = store.rotate(provider, value) if rotating else store.set(provider, value)
|
|
616
|
+
except ProviderKeyError as error:
|
|
617
|
+
print_error(str(error))
|
|
618
|
+
return False
|
|
619
|
+
action = "Rotated" if rotating else "Stored"
|
|
620
|
+
print_success(f"{action} {variable} in the OS credential vault.")
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _print_provider_key_statuses(store: ProviderKeyStore) -> None:
|
|
625
|
+
table = Table(title="Provider API keys (secret values are never displayed)")
|
|
626
|
+
table.add_column("#", justify="right", style="dim")
|
|
627
|
+
table.add_column("Provider", style="cyan")
|
|
628
|
+
table.add_column("Environment variable")
|
|
629
|
+
table.add_column("State")
|
|
630
|
+
table.add_column("Source")
|
|
631
|
+
for index, status in enumerate(store.statuses(), 1):
|
|
632
|
+
table.add_row(
|
|
633
|
+
str(index),
|
|
634
|
+
status.provider,
|
|
635
|
+
status.environment_variable,
|
|
636
|
+
"Configured" if status.configured else "Missing",
|
|
637
|
+
status.source,
|
|
638
|
+
)
|
|
639
|
+
print_cli_output(table, title="Provider keys")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _run_keys_manager(workspace: Path) -> None:
|
|
643
|
+
"""Interactive provider-key status, rotation, and removal manager."""
|
|
644
|
+
store = ProviderKeyStore(workspace)
|
|
645
|
+
while True:
|
|
646
|
+
_print_provider_key_statuses(store)
|
|
647
|
+
statuses = store.statuses()
|
|
648
|
+
try:
|
|
649
|
+
choice = input(
|
|
650
|
+
"Select provider number or key to manage [Enter exits]: "
|
|
651
|
+
).strip().lower()
|
|
652
|
+
except (KeyboardInterrupt, EOFError):
|
|
653
|
+
print("\nKey manager closed.")
|
|
654
|
+
return
|
|
655
|
+
if not choice:
|
|
656
|
+
return
|
|
657
|
+
if choice.isdigit() and 1 <= int(choice) <= len(statuses):
|
|
658
|
+
status = statuses[int(choice) - 1]
|
|
659
|
+
else:
|
|
660
|
+
status = next((item for item in statuses if item.provider == choice), None)
|
|
661
|
+
if status is None:
|
|
662
|
+
print_error("Unknown provider selection.")
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
actions = "[R]otate [D]elete [Enter] back" if status.configured else "[S]et [Enter] back"
|
|
666
|
+
try:
|
|
667
|
+
action = input(f"{status.provider}: {actions}: ").strip().lower()
|
|
668
|
+
except (KeyboardInterrupt, EOFError):
|
|
669
|
+
print("\nKey manager closed.")
|
|
670
|
+
return
|
|
671
|
+
if action in {"s", "set"} and not status.configured:
|
|
672
|
+
_prompt_and_store_provider_key(store, status.provider, rotating=False)
|
|
673
|
+
elif action in {"r", "rotate"} and status.configured:
|
|
674
|
+
_prompt_and_store_provider_key(store, status.provider, rotating=True)
|
|
675
|
+
elif action in {"d", "delete", "remove"} and status.configured:
|
|
676
|
+
confirm = input(
|
|
677
|
+
f"Remove the stored {status.provider} key? [y/N]: "
|
|
678
|
+
).strip().lower()
|
|
679
|
+
if confirm in {"y", "yes"}:
|
|
680
|
+
variable, removed, environment_still_set = store.remove(status.provider)
|
|
681
|
+
if removed:
|
|
682
|
+
print_success(f"Removed {variable} from secure local storage.")
|
|
683
|
+
if environment_still_set:
|
|
684
|
+
print_warning(
|
|
685
|
+
f"{variable} remains set by the process environment."
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _handle_keys_command(workspace: Path, args: object) -> None:
|
|
690
|
+
"""Handle provider keys without ever echoing secret values."""
|
|
691
|
+
store = ProviderKeyStore(workspace)
|
|
692
|
+
command = getattr(args, "keys_command", None)
|
|
693
|
+
try:
|
|
694
|
+
if command is None:
|
|
695
|
+
_run_keys_manager(workspace)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
if command == "list":
|
|
699
|
+
_print_provider_key_statuses(store)
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
provider = str(getattr(args, "provider", ""))
|
|
703
|
+
if command in {"set", "rotate"}:
|
|
704
|
+
stored = _prompt_and_store_provider_key(
|
|
705
|
+
store,
|
|
706
|
+
provider,
|
|
707
|
+
rotating=command == "rotate",
|
|
708
|
+
)
|
|
709
|
+
if not stored:
|
|
710
|
+
raise SystemExit(2)
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
if command == "remove":
|
|
714
|
+
variable, removed, environment_still_set = store.remove(provider)
|
|
715
|
+
if removed:
|
|
716
|
+
print_success(f"Removed {variable} from secure local storage.")
|
|
717
|
+
else:
|
|
718
|
+
print_warning(f"{variable} was not present in managed storage.")
|
|
719
|
+
if environment_still_set:
|
|
720
|
+
print_warning(
|
|
721
|
+
f"{variable} is still configured in the process environment; "
|
|
722
|
+
"remove it from your shell or secret manager separately."
|
|
723
|
+
)
|
|
724
|
+
return
|
|
725
|
+
except ProviderKeyError as error:
|
|
726
|
+
print_error(str(error))
|
|
727
|
+
raise SystemExit(2) from error
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _show_whoami() -> None:
|
|
731
|
+
"""Print current user profile info."""
|
|
732
|
+
user = get_current_user()
|
|
733
|
+
if user:
|
|
734
|
+
if user.name and user.name != user.email:
|
|
735
|
+
print_info(f"{user.name} ({user.email})")
|
|
736
|
+
else:
|
|
737
|
+
print_info(user.email)
|
|
738
|
+
else:
|
|
739
|
+
print_info("Not signed in. Run `pulse login` to sign in.")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def approve_in_cli(proposal: EditProposal) -> bool:
|
|
743
|
+
verification_msg = f"Proposed edit: {proposal.file_path}\n{proposal.unified_diff or '(no changes)'}"
|
|
744
|
+
print_verification(verification_msg)
|
|
745
|
+
answer = input("Apply this edit? [y/N] ").strip().lower()
|
|
746
|
+
return answer in {"y", "yes"}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def print_doctor(
|
|
750
|
+
config,
|
|
751
|
+
provider,
|
|
752
|
+
workspace: Path,
|
|
753
|
+
*,
|
|
754
|
+
production: bool = False,
|
|
755
|
+
target: str = "local",
|
|
756
|
+
as_json: bool = False,
|
|
757
|
+
) -> bool:
|
|
758
|
+
if production:
|
|
759
|
+
from pulse.production import run_production_checks
|
|
760
|
+
|
|
761
|
+
report = run_production_checks(
|
|
762
|
+
workspace,
|
|
763
|
+
config,
|
|
764
|
+
provider_configured=provider.is_configured,
|
|
765
|
+
target=target,
|
|
766
|
+
)
|
|
767
|
+
if as_json:
|
|
768
|
+
print(json.dumps(report.to_dict(), separators=(",", ":")))
|
|
769
|
+
return report.passed
|
|
770
|
+
|
|
771
|
+
table = Table(title=f"Pulse production doctor ({target})")
|
|
772
|
+
table.add_column("Check", style="cyan")
|
|
773
|
+
table.add_column("State")
|
|
774
|
+
table.add_column("Detail")
|
|
775
|
+
table.add_column("Remediation")
|
|
776
|
+
for check in report.checks:
|
|
777
|
+
state = "OK" if check.ok else "Warning" if not check.blocking else "BLOCKED"
|
|
778
|
+
table.add_row(check.name, state, check.detail, "" if check.ok else check.remediation)
|
|
779
|
+
print_cli_output(table, title="Production doctor")
|
|
780
|
+
return report.passed
|
|
781
|
+
|
|
782
|
+
api_key_env_var = getattr(provider, "api_key_env_var", "Provider API key")
|
|
783
|
+
checks = [
|
|
784
|
+
("Workspace", str(workspace), workspace.exists()),
|
|
785
|
+
("agent.config.json", str(workspace / "agent.config.json"), (workspace / "agent.config.json").exists()),
|
|
786
|
+
("Provider key", api_key_env_var, provider.is_configured),
|
|
787
|
+
("uv command", shutil.which("uv") or "not on PATH", shutil.which("uv") is not None),
|
|
788
|
+
("pulse command", shutil.which("pulse") or "not on PATH", shutil.which("pulse") is not None),
|
|
789
|
+
("Single model mode", config.mode, config.mode == "single-model"),
|
|
790
|
+
("Configured provider", config.model.provider, bool(config.model.provider)),
|
|
791
|
+
("Configured model", config.model.name, bool(config.model.name)),
|
|
792
|
+
("Maximum output tokens", str(config.model.max_tokens), config.model.max_tokens > 0),
|
|
793
|
+
]
|
|
794
|
+
|
|
795
|
+
table = Table(title="Pulse doctor")
|
|
796
|
+
table.add_column("Check", style="cyan")
|
|
797
|
+
table.add_column("Value")
|
|
798
|
+
table.add_column("State")
|
|
799
|
+
|
|
800
|
+
for name, value, ok in checks:
|
|
801
|
+
table.add_row(name, value, "OK" if ok else "Needs attention")
|
|
802
|
+
|
|
803
|
+
passed = all(ok for _, _, ok in checks)
|
|
804
|
+
if as_json:
|
|
805
|
+
print(
|
|
806
|
+
json.dumps(
|
|
807
|
+
{
|
|
808
|
+
"target": "development",
|
|
809
|
+
"passed": passed,
|
|
810
|
+
"checks": [
|
|
811
|
+
{"name": name, "value": value, "ok": ok}
|
|
812
|
+
for name, value, ok in checks
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
separators=(",", ":"),
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
else:
|
|
819
|
+
print_cli_output(table, title="Doctor")
|
|
820
|
+
return passed
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def print_mutations(events: list[dict[str, object]]) -> None:
|
|
824
|
+
if not events:
|
|
825
|
+
print_info("No tracked mutations found.")
|
|
826
|
+
return
|
|
827
|
+
|
|
828
|
+
table = Table(title="Pulse mutations")
|
|
829
|
+
table.add_column("Transaction", style="cyan")
|
|
830
|
+
table.add_column("Time")
|
|
831
|
+
table.add_column("Action")
|
|
832
|
+
table.add_column("File")
|
|
833
|
+
table.add_column("Command")
|
|
834
|
+
for event in events:
|
|
835
|
+
table.add_row(
|
|
836
|
+
str(event.get("transaction_id", ""))[:8],
|
|
837
|
+
str(event.get("timestamp", "")),
|
|
838
|
+
str(event.get("action", "")),
|
|
839
|
+
str(event.get("file_path", "")),
|
|
840
|
+
str(event.get("command") or ""),
|
|
841
|
+
)
|
|
842
|
+
print_cli_output(table, title="Mutations")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _handle_chat_command(workspace: Path, args: object) -> None:
|
|
846
|
+
"""Dispatch pulse chat subcommands."""
|
|
847
|
+
cm = ConversationManager(workspace)
|
|
848
|
+
chat_cmd = getattr(args, "chat_cmd", None)
|
|
849
|
+
|
|
850
|
+
if chat_cmd == "new" or chat_cmd is None:
|
|
851
|
+
title = getattr(args, "title", None)
|
|
852
|
+
conv = cm.create(title=title)
|
|
853
|
+
print_chat_created(conv)
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
if chat_cmd == "list":
|
|
857
|
+
conversations = cm.list_all()
|
|
858
|
+
active = cm.get_active()
|
|
859
|
+
active_id = active.id if active else ""
|
|
860
|
+
print_chat_list(conversations, active_id)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
if chat_cmd == "switch":
|
|
864
|
+
conv = _resolve_conversation(cm, args.id)
|
|
865
|
+
if conv is None:
|
|
866
|
+
return
|
|
867
|
+
cm.switch(conv.id)
|
|
868
|
+
print_chat_switched(conv)
|
|
869
|
+
return
|
|
870
|
+
|
|
871
|
+
if chat_cmd == "delete":
|
|
872
|
+
conv = _resolve_conversation(cm, args.id)
|
|
873
|
+
if conv is None:
|
|
874
|
+
return
|
|
875
|
+
confirm = input(f'Delete conversation "{conv.title}"? [y/N] ').strip().lower()
|
|
876
|
+
if confirm in {"y", "yes"}:
|
|
877
|
+
cm.delete(conv.id)
|
|
878
|
+
print_info(f'Conversation "{conv.title}" deleted.')
|
|
879
|
+
else:
|
|
880
|
+
print_info("Deletion cancelled.")
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
if chat_cmd == "rename":
|
|
884
|
+
conv = _resolve_conversation(cm, args.id)
|
|
885
|
+
if conv is None:
|
|
886
|
+
return
|
|
887
|
+
updated = cm.rename(conv.id, args.title)
|
|
888
|
+
print_info(f'Conversation renamed to "{updated.title}".')
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
if chat_cmd == "export":
|
|
892
|
+
conv = _resolve_conversation(cm, args.id)
|
|
893
|
+
if conv is None:
|
|
894
|
+
return
|
|
895
|
+
output_path = Path(args.output) if getattr(args, "output", None) else None
|
|
896
|
+
fmt = getattr(args, "fmt", "md")
|
|
897
|
+
try:
|
|
898
|
+
saved_path = cm.export(conv.id, output_path=output_path, fmt=fmt)
|
|
899
|
+
print_chat_exported(saved_path)
|
|
900
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
901
|
+
except Exception as exc: # noqa: BLE001
|
|
902
|
+
# Intentionally broad at CLI boundary to gracefully report user errors.
|
|
903
|
+
print_error(f"Export failed: {exc}")
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
if chat_cmd == "search":
|
|
907
|
+
results = cm.search(args.query)
|
|
908
|
+
active = cm.get_active()
|
|
909
|
+
active_id = active.id if active else ""
|
|
910
|
+
print_chat_search_results(results, args.query, active_id)
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
# Unknown subcommand — show list
|
|
914
|
+
conversations = cm.list_all()
|
|
915
|
+
active = cm.get_active()
|
|
916
|
+
active_id = active.id if active else ""
|
|
917
|
+
print_chat_list(conversations, active_id)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _resolve_conversation(cm: ConversationManager, id_prefix: str):
|
|
921
|
+
"""Resolve a conversation by full ID or unique prefix. Returns None on failure."""
|
|
922
|
+
all_convs = cm.list_all()
|
|
923
|
+
matches = [c for c in all_convs if c.id == id_prefix or c.id.startswith(id_prefix)]
|
|
924
|
+
if not matches:
|
|
925
|
+
print_error(f"No conversation found matching ID prefix: {id_prefix!r}")
|
|
926
|
+
return None
|
|
927
|
+
if len(matches) > 1:
|
|
928
|
+
print_error(
|
|
929
|
+
f"Ambiguous prefix {id_prefix!r} matches {len(matches)} conversations. "
|
|
930
|
+
"Please use a longer prefix."
|
|
931
|
+
)
|
|
932
|
+
return None
|
|
933
|
+
return matches[0]
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _handle_interactive_mode(auth, agent, runtime=None) -> None:
|
|
937
|
+
"""Handle the responsive interactive shell with conversation tracking."""
|
|
938
|
+
del auth # Authentication state is read from the shared workspace store.
|
|
939
|
+
workspace = Path.cwd()
|
|
940
|
+
cm = ConversationManager(workspace)
|
|
941
|
+
prompt = InteractivePrompt(workspace, _build_parser())
|
|
942
|
+
|
|
943
|
+
# Restore last active conversation or create a fresh one
|
|
944
|
+
active_conv = cm.get_active()
|
|
945
|
+
if active_conv is None:
|
|
946
|
+
active_conv = cm.create()
|
|
947
|
+
is_first_message = active_conv.turn_count == 0
|
|
948
|
+
|
|
949
|
+
print_banner()
|
|
950
|
+
if not is_authenticated():
|
|
951
|
+
print_auth_prompt()
|
|
952
|
+
answer = input("Sign in now with Google? [Y/n] ").strip().lower()
|
|
953
|
+
if answer not in {"n", "no"}:
|
|
954
|
+
if _handle_login_command():
|
|
955
|
+
_run_provider_onboarding(workspace)
|
|
956
|
+
runtime = build_runtime(workspace, load_agent_config(workspace))
|
|
957
|
+
agent = runtime.agent
|
|
958
|
+
else:
|
|
959
|
+
print_info("Continuing unauthenticated.")
|
|
960
|
+
else:
|
|
961
|
+
user = get_current_user()
|
|
962
|
+
if user:
|
|
963
|
+
print_signed_in(user.name, user.email)
|
|
964
|
+
if not _active_provider_has_key(workspace):
|
|
965
|
+
print_warning("The active model provider has no API key configured.")
|
|
966
|
+
answer = input("Configure BYOK now? [Y/n] ").strip().lower()
|
|
967
|
+
if answer not in {"n", "no"} and _run_provider_onboarding(workspace):
|
|
968
|
+
runtime = build_runtime(workspace, load_agent_config(workspace))
|
|
969
|
+
agent = runtime.agent
|
|
970
|
+
|
|
971
|
+
# Show active conversation info
|
|
972
|
+
print_chat_card(active_conv)
|
|
973
|
+
print_info("Type / to open the command menu, or /help to see every command.")
|
|
974
|
+
|
|
975
|
+
while True:
|
|
976
|
+
try:
|
|
977
|
+
user_input = prompt.read(active_conv.title)
|
|
978
|
+
if not user_input:
|
|
979
|
+
continue
|
|
980
|
+
normalized = user_input.lower()
|
|
981
|
+
if normalized in {"exit", "quit", "/exit", "/quit"}:
|
|
982
|
+
break
|
|
983
|
+
if normalized in {"help", "?", "/help", "/?"}:
|
|
984
|
+
print_help_screen(interactive=True)
|
|
985
|
+
continue
|
|
986
|
+
if normalized == "/clear":
|
|
987
|
+
print("\033[2J\033[H", end="")
|
|
988
|
+
continue
|
|
989
|
+
if user_input.startswith("/"):
|
|
990
|
+
try:
|
|
991
|
+
command_args = parse_slash_command(user_input)
|
|
992
|
+
except ValueError as error:
|
|
993
|
+
print_error(str(error))
|
|
994
|
+
continue
|
|
995
|
+
if not command_args:
|
|
996
|
+
continue
|
|
997
|
+
try:
|
|
998
|
+
main(command_args)
|
|
999
|
+
except KeyboardInterrupt:
|
|
1000
|
+
print_warning("Command cancelled.")
|
|
1001
|
+
except SystemExit as error:
|
|
1002
|
+
# argparse and command handlers use SystemExit for ordinary
|
|
1003
|
+
# user errors; a REPL command must never terminate the shell.
|
|
1004
|
+
if error.code not in {None, 0}:
|
|
1005
|
+
print_warning(f"Command finished with exit code {error.code}.")
|
|
1006
|
+
|
|
1007
|
+
refreshed = cm.get_active()
|
|
1008
|
+
if refreshed is not None:
|
|
1009
|
+
active_conv = refreshed
|
|
1010
|
+
is_first_message = active_conv.turn_count == 0
|
|
1011
|
+
|
|
1012
|
+
# Model and key changes must take effect on the very next prompt.
|
|
1013
|
+
if command_args[0] in {"model", "keys"}:
|
|
1014
|
+
runtime = build_runtime(workspace, load_agent_config(workspace))
|
|
1015
|
+
agent = runtime.agent
|
|
1016
|
+
continue
|
|
1017
|
+
|
|
1018
|
+
# Capture stdout to record the assistant response
|
|
1019
|
+
captured = io.StringIO()
|
|
1020
|
+
real_stdout = sys.stdout
|
|
1021
|
+
sys.stdout = captured
|
|
1022
|
+
interrupted = False
|
|
1023
|
+
try:
|
|
1024
|
+
with thinking_spinner():
|
|
1025
|
+
agent.ask(user_input, auto_approve_reads=True)
|
|
1026
|
+
except KeyboardInterrupt:
|
|
1027
|
+
interrupted = True
|
|
1028
|
+
finally:
|
|
1029
|
+
sys.stdout = real_stdout
|
|
1030
|
+
output = captured.getvalue()
|
|
1031
|
+
# Print to real stdout so user sees the answer
|
|
1032
|
+
print(output, end="")
|
|
1033
|
+
|
|
1034
|
+
if interrupted:
|
|
1035
|
+
print_warning("Request cancelled. Your session is still active.")
|
|
1036
|
+
continue
|
|
1037
|
+
|
|
1038
|
+
# Auto-title on first message in a fresh conversation
|
|
1039
|
+
if is_first_message:
|
|
1040
|
+
active_conv = cm.auto_title(active_conv.id, user_input)
|
|
1041
|
+
is_first_message = False
|
|
1042
|
+
|
|
1043
|
+
# Record turns
|
|
1044
|
+
cm.add_turn(active_conv.id, "user", user_input)
|
|
1045
|
+
if output.strip():
|
|
1046
|
+
cm.add_turn(active_conv.id, "assistant", output.strip())
|
|
1047
|
+
|
|
1048
|
+
print_session_footer(
|
|
1049
|
+
provider=runtime.config.model.provider if runtime else "pulse",
|
|
1050
|
+
model=runtime.config.model.name if runtime else "default",
|
|
1051
|
+
conversation=active_conv.title,
|
|
1052
|
+
)
|
|
1053
|
+
except (KeyboardInterrupt, EOFError):
|
|
1054
|
+
print("\nExiting Pulse REPL.")
|
|
1055
|
+
break
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1059
|
+
try:
|
|
1060
|
+
_run_main(argv)
|
|
1061
|
+
except SystemExit:
|
|
1062
|
+
raise
|
|
1063
|
+
except KeyboardInterrupt:
|
|
1064
|
+
print_warning("Command cancelled.")
|
|
1065
|
+
raise SystemExit(130) from None
|
|
1066
|
+
except (EOFError, OSError, RuntimeError, ValueError, json.JSONDecodeError):
|
|
1067
|
+
print_error("Pulse could not complete the request. Check configuration and inputs.")
|
|
1068
|
+
raise SystemExit(1) from None
|
|
1069
|
+
except Exception: # noqa: BLE001
|
|
1070
|
+
print_error("Pulse could not complete the request. Check configuration and inputs.")
|
|
1071
|
+
raise SystemExit(1) from None
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
if __name__ == "__main__":
|
|
1075
|
+
main()
|