sid-harness 0.3.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 (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/cli.py ADDED
@@ -0,0 +1,937 @@
1
+ """``sid`` CLI entry point.
2
+
3
+ sid run --task "<goal>" --dir <path> [--model auto] [--verify] [--context]
4
+ sid session start|resume|replay
5
+ sid tools [--mcp CMD] [--json]
6
+ sid policy --dir <path> [--json]
7
+
8
+ One engine (the shared SID native loop), SID as the only model backend.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import getpass
15
+ import json
16
+ import os
17
+ import shlex
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ from . import __version__, config
23
+ from .client import SIDClient, SIDClientError
24
+ from .local_workspace import LocalWorkspace, LocalWorkspaceError
25
+ from .runner import RunError, RunOutcome, execute_run
26
+ from .session import Session, SessionError, SessionStoreError
27
+ from .session.bounds import BoundsError, SessionBounds
28
+ from .workspace import WorkspaceError
29
+
30
+
31
+ def _add_common_options(parser: argparse.ArgumentParser) -> None:
32
+ parser.add_argument("--base-url", default=None, help="SID /v1 base URL (env SID_BASE_URL).")
33
+ parser.add_argument("--api-key", default=None, help="Your SID key (env SID_API_KEY).")
34
+ parser.add_argument("--model", default="auto", help="Model id or 'auto' (default).")
35
+ parser.add_argument("--routing-profile", default=None, help="X-Routing-Profile (default: agent).")
36
+ parser.add_argument("--criteria", action="append", default=None, help="Acceptance criterion (repeatable).")
37
+ parser.add_argument("--out-of-scope", action="append", default=None, help="Out-of-scope item (repeatable).")
38
+ parser.add_argument("--max-tokens", type=int, default=8000)
39
+ parser.add_argument("--reasoning-effort", default=None)
40
+ parser.add_argument("--verify", action="store_true", help="Also review the produced diff via /v1/verify/patch.")
41
+ parser.add_argument(
42
+ "--write",
43
+ action="store_true",
44
+ help="Apply the loop's changed files to the real working tree (default: read-only).",
45
+ )
46
+ parser.add_argument(
47
+ "--allow-delete",
48
+ action="store_true",
49
+ help=(
50
+ "Explicitly approve deletions the run proposes (default: deny). "
51
+ "Deletions are never applied silently."
52
+ ),
53
+ )
54
+ parser.add_argument(
55
+ "--test-cmd",
56
+ default=None,
57
+ metavar="CMD",
58
+ help=(
59
+ "Human-approved test command to run locally in --dir (SUB-80). "
60
+ "Split into an argv list and executed without a shell, with a "
61
+ "bounded timeout and bounded output."
62
+ ),
63
+ )
64
+ parser.add_argument("--receipt", default=None, help="Receipt JSON path (default: <dir>/.sid/runs/<run_id>.json).")
65
+ parser.add_argument("--json", action="store_true", help="Print the receipt JSON to stdout.")
66
+ parser.add_argument("--quiet", action="store_true")
67
+ parser.add_argument(
68
+ "--context",
69
+ action="store_true",
70
+ help="Prepend repo-scale retrieved context for the task (SUB-55; read-only).",
71
+ )
72
+ parser.add_argument(
73
+ "--context-k",
74
+ type=int,
75
+ default=4,
76
+ help="Number of retrieved context chunks to prepend (default: 4).",
77
+ )
78
+ parser.add_argument(
79
+ "--mcp",
80
+ action="append",
81
+ default=None,
82
+ metavar="CMD",
83
+ help="Connect an MCP stdio server (e.g. --mcp 'npx -y @some/mcp') and expose its tools (repeatable).",
84
+ )
85
+ parser.add_argument(
86
+ "--bounds-config",
87
+ default=None,
88
+ help="JSON file overriding loop bounds (turns/tool calls/workspace size).",
89
+ )
90
+ parser.add_argument(
91
+ "--no-diagnostics",
92
+ dest="diagnostics",
93
+ action="store_false",
94
+ default=True,
95
+ help="Ignore the editor/LSP diagnostics feed for this run (SUB-96).",
96
+ )
97
+
98
+
99
+ def _add_project_options(parser: argparse.ArgumentParser) -> None:
100
+ """SUB-94: project rules/skills/subagents/plan on the CLI run paths."""
101
+ parser.add_argument(
102
+ "--skills",
103
+ action="append",
104
+ default=None,
105
+ metavar="NAME",
106
+ help=(
107
+ "Load a named skill from the project's .opencode/skills (repeatable; "
108
+ "SUB-94). Without --skills, .sid/config 'skills' applies."
109
+ ),
110
+ )
111
+ parser.add_argument(
112
+ "--skills-root",
113
+ default=None,
114
+ metavar="DIR",
115
+ help="Skills directory to load from (default: nearest .opencode/skills).",
116
+ )
117
+ parser.add_argument(
118
+ "--subagent",
119
+ action="append",
120
+ default=None,
121
+ metavar="TASK",
122
+ help=(
123
+ "Run TASK as a parallel subagent through the same loop and feed its "
124
+ "findings to the main run (repeatable; SUB-94)."
125
+ ),
126
+ )
127
+ parser.add_argument(
128
+ "--plan",
129
+ action="store_true",
130
+ help="Run read-only plan mode: propose a plan, write nothing (SUB-94).",
131
+ )
132
+ parser.add_argument(
133
+ "--todo",
134
+ action="append",
135
+ default=None,
136
+ metavar="TEXT",
137
+ help="Seed the run's todo list (repeatable; SUB-94).",
138
+ )
139
+
140
+
141
+ def _add_session_options(parser: argparse.ArgumentParser) -> None:
142
+ parser.add_argument(
143
+ "--approve",
144
+ action="store_true",
145
+ help="Approve the proposed diff for --write without prompting.",
146
+ )
147
+
148
+
149
+ def _add_git_write_options(parser: argparse.ArgumentParser) -> None:
150
+ """Explicit opt-in for the git write path (SUB-95). Default stays read-only."""
151
+ parser.add_argument(
152
+ "--git-write",
153
+ action="store_true",
154
+ help=(
155
+ "Opt in to the git write path: create a branch, commit the approved "
156
+ "diff, push the branch, and open a PR with gh. Never main/master, "
157
+ "never forced; each step needs its own approval."
158
+ ),
159
+ )
160
+ parser.add_argument(
161
+ "--git-branch",
162
+ default=None,
163
+ help="Branch to create/commit/push (default: sid/<run_id>).",
164
+ )
165
+ parser.add_argument(
166
+ "--git-message",
167
+ default=None,
168
+ help="Commit message and PR body (default: the task text).",
169
+ )
170
+ parser.add_argument(
171
+ "--git-base",
172
+ default=None,
173
+ help="Pull-request base branch (default: main). The PR target is not a push.",
174
+ )
175
+ parser.add_argument(
176
+ "--no-pr",
177
+ action="store_true",
178
+ help="Push the branch but do not open a pull request.",
179
+ )
180
+ parser.add_argument(
181
+ "--approve",
182
+ action="store_true",
183
+ help=(
184
+ "Approve each pending step without prompting (diff apply and every "
185
+ "git-write step). Without it, a non-interactive run fails closed."
186
+ ),
187
+ )
188
+
189
+
190
+ def build_parser() -> argparse.ArgumentParser:
191
+ parser = argparse.ArgumentParser(
192
+ prog="sid",
193
+ description="SID first-party local coding harness (v1) — drives SID's native loop.",
194
+ )
195
+ parser.add_argument("--version", action="version", version=f"sid-harness {__version__}")
196
+ sub = parser.add_subparsers(dest="command")
197
+
198
+ setup = sub.add_parser(
199
+ "setup",
200
+ help="Verify your key with /v1/me, save it, and choose a default project dir.",
201
+ )
202
+ setup.add_argument("--api-key", default=None, help="Your SID key (env SID_API_KEY).")
203
+ setup.add_argument("--base-url", default=None, help="SID /v1 base URL (env SID_BASE_URL).")
204
+ setup.add_argument("--dir", default=None, help="Default project directory (default: current dir).")
205
+ setup.add_argument(
206
+ "--non-interactive",
207
+ action="store_true",
208
+ help="Never prompt; use flags, env, config, or the current directory.",
209
+ )
210
+ setup.add_argument("--json", action="store_true")
211
+
212
+ run = sub.add_parser("run", help="Run one bounded task against SID's native loop.")
213
+ run.add_argument("--task", required=True)
214
+ run.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
215
+ _add_common_options(run)
216
+ _add_project_options(run)
217
+ _add_git_write_options(run)
218
+
219
+ tui = sub.add_parser("tui", help="Interactive terminal loop over the same engine.")
220
+ tui.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
221
+ _add_common_options(tui)
222
+ _add_project_options(tui)
223
+ _add_git_write_options(tui)
224
+
225
+ status = sub.add_parser(
226
+ "status",
227
+ help="Show local git/worktree status for --dir (read-only, no model call).",
228
+ )
229
+ status.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
230
+ status.add_argument("--diff", action="store_true", help="Also print `git diff`.")
231
+ status.add_argument("--json", action="store_true")
232
+
233
+ session = sub.add_parser(
234
+ "session",
235
+ help="Durable local sessions: start, resume, replay (approve-before-apply on --write).",
236
+ )
237
+ session_sub = session.add_subparsers(dest="session_command")
238
+
239
+ session_start = session_sub.add_parser("start", help="Start a durable session and run once.")
240
+ session_start.add_argument("--task", required=True)
241
+ session_start.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
242
+ _add_common_options(session_start)
243
+ _add_session_options(session_start)
244
+
245
+ session_resume = session_sub.add_parser("resume", help="Continue an existing session.")
246
+ session_resume.add_argument("--session", required=True)
247
+ session_resume.add_argument("--task", default=None, help="New task (default: the session's task).")
248
+ session_resume.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
249
+ _add_common_options(session_resume)
250
+ _add_session_options(session_resume)
251
+
252
+ session_replay = session_sub.add_parser("replay", help="Print a session's run receipts (no model call).")
253
+ session_replay.add_argument("--session", required=True)
254
+ session_replay.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
255
+ session_replay.add_argument("--json", action="store_true")
256
+
257
+ tools = sub.add_parser(
258
+ "tools",
259
+ help="List the loop's tool contract (base + optional MCP servers).",
260
+ )
261
+ tools.add_argument("--mcp", action="append", default=None, metavar="CMD")
262
+ tools.add_argument("--json", action="store_true")
263
+
264
+ policy = sub.add_parser(
265
+ "policy",
266
+ help="Show the platform worktree/gate policy for --dir (read-only, no model call).",
267
+ )
268
+ policy.add_argument("--dir", default=None, help="Project directory (default: `sid setup`'s dir).")
269
+ policy.add_argument("--json", action="store_true")
270
+
271
+ selftest = sub.add_parser(
272
+ "selftest",
273
+ help="Drive one loop turn against a local stub endpoint and write a receipt.",
274
+ )
275
+ selftest.add_argument("--receipt", default=None)
276
+
277
+ bridge = sub.add_parser(
278
+ "bridge",
279
+ help="Run the localhost filesystem bridge so the Studio web UI can read --dir.",
280
+ )
281
+ _add_bridge_options(bridge, auto_open=False)
282
+
283
+ companion = sub.add_parser(
284
+ "companion",
285
+ help="Start the bridge and open the Studio with one-click pairing (read-only by default).",
286
+ )
287
+ _add_bridge_options(companion, auto_open=True)
288
+
289
+ lsp = sub.add_parser(
290
+ "lsp",
291
+ help="Run the SID language server on stdio; it feeds diagnostics into the loop (SUB-96).",
292
+ )
293
+ lsp.add_argument(
294
+ "--dir",
295
+ default=None,
296
+ help="Workspace root (default: the initialize request's root, else cwd).",
297
+ )
298
+
299
+ editor = sub.add_parser(
300
+ "editor",
301
+ help="Install the bundled VS Code extension (SUB-96).",
302
+ )
303
+ editor_sub = editor.add_subparsers(dest="editor_command")
304
+ editor_install = editor_sub.add_parser(
305
+ "install",
306
+ help="Copy the bundled VS Code extension into VS Code's extensions directory.",
307
+ )
308
+ editor_install.add_argument("--target-dir", default=None, help="Override the extensions directory.")
309
+ editor_install.add_argument("--force", action="store_true", help="Refresh an existing install.")
310
+ editor_install.add_argument("--json", action="store_true")
311
+
312
+ apply_cmd = sub.add_parser(
313
+ "apply",
314
+ help="Apply an approved run receipt's readback to --dir through the same write path.",
315
+ )
316
+ apply_cmd.add_argument("--dir", required=True)
317
+ apply_cmd.add_argument("--receipt", required=True)
318
+ apply_cmd.add_argument(
319
+ "--approve",
320
+ action="store_true",
321
+ help="Explicit human approval; without it nothing is written.",
322
+ )
323
+ apply_cmd.add_argument("--json", action="store_true")
324
+ return parser
325
+
326
+
327
+ def _add_bridge_options(parser: argparse.ArgumentParser, *, auto_open: bool) -> None:
328
+ parser.add_argument("--dir", required=True)
329
+ parser.add_argument("--port", type=int, default=8787)
330
+ parser.add_argument("--token", default=None, help="Fixed token (default: generated per launch).")
331
+ parser.add_argument("--pair-code", default=None, help="Fixed pairing code (default: generated per launch).")
332
+ parser.add_argument("--pair-ttl", type=float, default=600)
333
+ parser.add_argument(
334
+ "--write",
335
+ action="store_true",
336
+ help="Make write mode available (default: read-only); the user still grants it explicitly in the Studio.",
337
+ )
338
+ parser.add_argument("--state-dir", default=None, help="Receipts/grant state directory (default: ~/.sid/bridge).")
339
+ parser.add_argument(
340
+ "--studio-url",
341
+ default=None,
342
+ help="Studio page that opens (companion default: https://app.superintelligencedog.com/studio.html).",
343
+ )
344
+ parser.add_argument(
345
+ "--allow-origin",
346
+ action="append",
347
+ default=None,
348
+ metavar="ORIGIN",
349
+ help="Add a trusted Studio origin (self-hosted); repeatable.",
350
+ )
351
+ parser.add_argument("--base-url", default=None, help="SID /v1 base URL used for runs (never taken from the browser).")
352
+ if auto_open:
353
+ parser.add_argument("--no-open", action="store_true", help="Do not open the browser (print the pair URL).")
354
+
355
+
356
+ def _file_link(path: Path) -> str:
357
+ resolved = Path(path).resolve()
358
+ try:
359
+ return resolved.as_uri()
360
+ except ValueError: # pragma: no cover - unusual non-file path
361
+ return str(resolved)
362
+
363
+
364
+ def _render(outcome: RunOutcome, *, as_json: bool, quiet: bool) -> None:
365
+ receipt = outcome.receipt
366
+ if as_json:
367
+ print(json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=False))
368
+ return
369
+ if not quiet:
370
+ print(f"model: {receipt['model']} steps: {receipt['steps']} tool_calls: {receipt['tool_calls']}")
371
+ print(f"changed: {', '.join(receipt['changed']) or '(none)'}")
372
+ if receipt.get("context"):
373
+ print(f"context: {receipt['context'].get('chunks_used', 0)} chunk(s)")
374
+ if receipt.get("diff"):
375
+ print("\n--- diff ---")
376
+ print(receipt["diff"].rstrip("\n"))
377
+ print(f"\n{receipt['final_response']}")
378
+ if receipt.get("verify"):
379
+ print(f"\nverify: {receipt['verify'].get('verdict')}")
380
+ if receipt["tests_executed"]:
381
+ print(
382
+ f"tests_executed: true backend: {receipt.get('test_backend')} "
383
+ f"exit: {receipt.get('test_exit_code')} passed: {receipt.get('test_passed')}"
384
+ )
385
+ else:
386
+ print(f"tests_executed: false ({receipt.get('test', {}).get('detail')})")
387
+ git_write = receipt.get("git_write")
388
+ if git_write:
389
+ print(
390
+ f"git_write: {git_write.get('status')} branch: {git_write.get('branch')} "
391
+ f"commit: {git_write.get('commit')} pr: {git_write.get('pr_url')}"
392
+ )
393
+ if git_write.get("reason"):
394
+ print(f"git_write detail: {git_write['reason']}")
395
+ print(f"receipt: {_file_link(outcome.receipt_path)}")
396
+
397
+
398
+ def _make_client(args: argparse.Namespace) -> SIDClient:
399
+ return SIDClient(
400
+ base_url=getattr(args, "base_url", None),
401
+ api_key=getattr(args, "api_key", None),
402
+ routing_profile=getattr(args, "routing_profile", None),
403
+ )
404
+
405
+
406
+ def _cmd_setup(args: argparse.Namespace) -> int:
407
+ """Verify the key against ``/v1/me``, then persist config + default dir."""
408
+ saved = config.load_config()
409
+ api_key = (
410
+ args.api_key
411
+ or os.environ.get("SID_API_KEY")
412
+ or (saved.get("api_key") if isinstance(saved.get("api_key"), str) else "")
413
+ )
414
+ if not api_key and not args.non_interactive and sys.stdin.isatty():
415
+ api_key = getpass.getpass("SID API key: ").strip()
416
+ if not api_key:
417
+ print(
418
+ "BLOCKED: no SID API key. Pass --api-key, set SID_API_KEY, or run "
419
+ "interactively.",
420
+ file=sys.stderr,
421
+ )
422
+ return 2
423
+
424
+ base_url = args.base_url or os.environ.get("SID_BASE_URL") or None
425
+ try:
426
+ client = SIDClient(base_url=base_url, api_key=api_key)
427
+ account = client.me()
428
+ except SIDClientError as exc:
429
+ print(f"BLOCKED: key verification failed: {exc}", file=sys.stderr)
430
+ return 2
431
+
432
+ default_dir = args.dir
433
+ if not default_dir:
434
+ existing = saved.get("default_dir")
435
+ default_dir = existing if isinstance(existing, str) and existing else None
436
+ if not default_dir and not args.non_interactive and sys.stdin.isatty():
437
+ answer = input(f"Default project directory [{Path.cwd()}]: ").strip()
438
+ default_dir = answer or str(Path.cwd())
439
+ if not default_dir:
440
+ default_dir = str(Path.cwd())
441
+ directory = Path(default_dir).expanduser().resolve()
442
+ if not directory.is_dir():
443
+ print(f"BLOCKED: default dir is not an existing directory: {directory}", file=sys.stderr)
444
+ return 2
445
+
446
+ merged = dict(saved)
447
+ merged.update(
448
+ {
449
+ "api_key": api_key,
450
+ "base_url": client.base_url,
451
+ "default_dir": str(directory),
452
+ "api_key_id": account.get("api_key_id"),
453
+ "plan": account.get("plan"),
454
+ "verified_at": datetime.now(timezone.utc).isoformat(),
455
+ }
456
+ )
457
+ path = config.save_config(merged)
458
+ payload = {
459
+ "ok": True,
460
+ "config": str(path),
461
+ "base_url": client.base_url,
462
+ "default_dir": str(directory),
463
+ "api_key_id": account.get("api_key_id"),
464
+ "plan": account.get("plan"),
465
+ }
466
+ if args.json:
467
+ print(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
468
+ return 0
469
+ print(f"key verified: {account.get('name') or 'SID key'} (plan: {account.get('plan') or 'unknown'})")
470
+ print(f"saved: {path}")
471
+ print(f"default dir: {directory}")
472
+ print("next: sid run --task \"...\"")
473
+ return 0
474
+
475
+
476
+ def _apply_default_dir(args: argparse.Namespace) -> bool:
477
+ if getattr(args, "dir", None):
478
+ return True
479
+ configured = config.get_default_dir()
480
+ if configured:
481
+ args.dir = configured
482
+ return True
483
+ print(
484
+ "BLOCKED: no --dir given and no default dir saved. "
485
+ "Run `sid setup --dir <path>` first.",
486
+ file=sys.stderr,
487
+ )
488
+ return False
489
+
490
+
491
+ def _resolve_bounds(args: argparse.Namespace) -> SessionBounds:
492
+ return SessionBounds.resolve(config_path=getattr(args, "bounds_config", None))
493
+
494
+
495
+ def _split_command(command: str) -> list[str]:
496
+ """Split an ``--mcp`` command line, Windows-path aware.
497
+
498
+ POSIX ``shlex.split`` eats backslashes, which corrupts a Windows executable
499
+ path (``C:\\Python\\python.exe``). On Windows, tokenize without POSIX rules
500
+ and strip surrounding quotes from each token.
501
+ """
502
+ command = (command or "").strip()
503
+ if not command:
504
+ return []
505
+ lexer = shlex.shlex(command, posix=(os.name != "nt"))
506
+ lexer.whitespace_split = True
507
+ lexer.commenters = ""
508
+ tokens = list(lexer)
509
+ if os.name == "nt":
510
+ tokens = [
511
+ token[1:-1]
512
+ if len(token) >= 2 and token[0] == token[-1] and token[0] in ("'", '"')
513
+ else token
514
+ for token in tokens
515
+ ]
516
+ return tokens
517
+
518
+
519
+ def _approved_test_argv(args: argparse.Namespace) -> list[str] | None:
520
+ """The ``--test-cmd`` command as an argv LIST (never a shell string)."""
521
+ command = getattr(args, "test_cmd", None)
522
+ if not command:
523
+ return None
524
+ tokens = _split_command(command)
525
+ return tokens or None
526
+
527
+
528
+ def _build_registry(args: argparse.Namespace):
529
+ """Return (registry, closers) for any --mcp servers; (None, []) when none."""
530
+ commands = getattr(args, "mcp", None)
531
+ if not commands:
532
+ return None, []
533
+ from .tools import MCPClient, StdioTransport, ToolRegistry, register_mcp_tools
534
+
535
+ registry = ToolRegistry()
536
+ closers = []
537
+ for command in commands:
538
+ parts = _split_command(command)
539
+ if not parts:
540
+ continue
541
+ mcp = MCPClient(StdioTransport(parts[0], parts[1:]))
542
+ mcp.initialize()
543
+ register_mcp_tools(registry, mcp)
544
+ closers.append(mcp)
545
+ return registry, closers
546
+
547
+
548
+ def _git_write_approver(args: argparse.Namespace):
549
+ """One approval per git step; fail closed without a TTY and without --approve."""
550
+ explicit = bool(getattr(args, "approve", False))
551
+
552
+ def approve(step):
553
+ print(f"[git-write] {step.render()}", file=sys.stderr)
554
+ if explicit:
555
+ return True
556
+ if not sys.stdin.isatty():
557
+ print(
558
+ "BLOCKED: --git-write needs an interactive approval (or --approve) "
559
+ "and stdin is not a terminal; no git step was taken.",
560
+ file=sys.stderr,
561
+ )
562
+ return False
563
+ print(f"Approve {step.name}? [y/N] ", end="", file=sys.stderr, flush=True)
564
+ try:
565
+ answer = input()
566
+ except (EOFError, KeyboardInterrupt):
567
+ return False
568
+ return answer.strip().lower() in ("y", "yes")
569
+
570
+ return approve
571
+
572
+
573
+ def _run_common(args: argparse.Namespace, task: str, *, reporter) -> RunOutcome:
574
+ client = _make_client(args)
575
+ bounds = _resolve_bounds(args)
576
+ registry, closers = _build_registry(args)
577
+ git_write = bool(getattr(args, "git_write", False))
578
+ try:
579
+ return execute_run(
580
+ task=task,
581
+ directory=args.dir,
582
+ client=client,
583
+ model=args.model,
584
+ criteria=args.criteria,
585
+ out_of_scope=args.out_of_scope,
586
+ test_argv=_approved_test_argv(args),
587
+ max_tokens=args.max_tokens,
588
+ reasoning_effort=args.reasoning_effort,
589
+ verify=args.verify,
590
+ write=getattr(args, "write", False),
591
+ allow_delete=getattr(args, "allow_delete", False),
592
+ git_write=git_write,
593
+ git_branch=getattr(args, "git_branch", None),
594
+ git_message=getattr(args, "git_message", None),
595
+ git_base=getattr(args, "git_base", None),
596
+ git_pr=not getattr(args, "no_pr", False),
597
+ git_approve=_git_write_approver(args) if git_write else None,
598
+ receipt_path=args.receipt,
599
+ reporter=reporter,
600
+ context=getattr(args, "context", False),
601
+ context_k=getattr(args, "context_k", 4),
602
+ registry=registry,
603
+ bounds=bounds,
604
+ diagnostics=getattr(args, "diagnostics", True),
605
+ skills=getattr(args, "skills", None),
606
+ skills_root=getattr(args, "skills_root", None),
607
+ subagents=getattr(args, "subagent", None),
608
+ plan=bool(getattr(args, "plan", False)),
609
+ todos=getattr(args, "todo", None),
610
+ )
611
+ finally:
612
+ for mcp in closers:
613
+ mcp.close()
614
+
615
+
616
+ def _cmd_run(args: argparse.Namespace) -> int:
617
+ reporter = (lambda _message: None) if args.quiet else (lambda message: print(message, file=sys.stderr))
618
+ try:
619
+ outcome = _run_common(args, args.task, reporter=reporter)
620
+ except (SIDClientError, WorkspaceError, LocalWorkspaceError, BoundsError, RunError) as exc:
621
+ print(f"BLOCKED: {exc}", file=sys.stderr)
622
+ return 2
623
+ _render(outcome, as_json=args.json, quiet=args.quiet)
624
+ return 0
625
+
626
+
627
+ def _cmd_status(args: argparse.Namespace) -> int:
628
+ try:
629
+ workspace = LocalWorkspace.open(args.dir)
630
+ except LocalWorkspaceError as exc:
631
+ print(f"BLOCKED: {exc}", file=sys.stderr)
632
+ return 2
633
+ info = workspace.git
634
+ status = workspace.status()
635
+ if args.json:
636
+ payload = {"git": info.as_dict(), "status": status}
637
+ if args.diff:
638
+ payload["diff"] = workspace.diff()
639
+ print(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
640
+ return 0
641
+ print(f"dir: {workspace.root}")
642
+ if not info.is_repo:
643
+ print("git: not a repository")
644
+ return 0
645
+ kind = "worktree" if info.is_worktree else "checkout"
646
+ print(f"git: {kind} branch: {info.branch or '(detached)'} head: {(info.head or '')[:12]}")
647
+ print(f"root: {info.root}")
648
+ if status:
649
+ print(f"status ({len(status)}):")
650
+ for line in status:
651
+ print(f" {line}")
652
+ else:
653
+ print("status: clean")
654
+ if args.diff:
655
+ print("\n--- diff ---")
656
+ print(workspace.diff().rstrip("\n"))
657
+ return 0
658
+
659
+
660
+ def _cmd_tools(args: argparse.Namespace) -> int:
661
+ from .tools import MCPClient, StdioTransport, ToolRegistry, register_mcp_tools
662
+ from .tools.registry import BASE_TOOL_NAMES
663
+
664
+ registry = ToolRegistry()
665
+ closers = []
666
+ try:
667
+ for command in args.mcp or []:
668
+ parts = _split_command(command)
669
+ if not parts:
670
+ continue
671
+ mcp = MCPClient(StdioTransport(parts[0], parts[1:]))
672
+ mcp.initialize()
673
+ register_mcp_tools(registry, mcp)
674
+ closers.append(mcp)
675
+ except Exception as exc:
676
+ print(f"BLOCKED: {exc}", file=sys.stderr)
677
+ return 2
678
+ finally:
679
+ for mcp in closers:
680
+ mcp.close()
681
+
682
+ payload = {
683
+ "base": list(BASE_TOOL_NAMES),
684
+ "extensions": registry.extension_names(),
685
+ "all": registry.names(),
686
+ }
687
+ if args.json:
688
+ print(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
689
+ return 0
690
+ print(f"base tools ({len(payload['base'])}): {', '.join(payload['base'])}")
691
+ if payload["extensions"]:
692
+ print(f"extension tools ({len(payload['extensions'])}): {', '.join(payload['extensions'])}")
693
+ else:
694
+ print("extension tools: none (pass --mcp '<command>' to add a server's tools)")
695
+ return 0
696
+
697
+
698
+ def _cmd_policy(args: argparse.Namespace) -> int:
699
+ from .policy import policy_report
700
+
701
+ report = policy_report(args.dir)
702
+ if args.json:
703
+ print(json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False))
704
+ return 0
705
+ worktree = report["worktree"]
706
+ gates = report["gates"]
707
+ print(f"dir: {worktree['directory']}")
708
+ if not worktree["is_repo"]:
709
+ print("policy: not a git repository (no platform policy applies)")
710
+ return 0
711
+ kind = "MAIN checkout (integration-only)" if worktree["is_main_checkout"] else "session worktree"
712
+ print(f"worktree: {kind} branch: {worktree['branch'] or '(detached)'}")
713
+ print(f"reason: {worktree['reason']}")
714
+ print(f"gates on disk: {len(gates['gate_files'])} wired: {len(gates['wired_gates'])}")
715
+ if gates["missing_wired"]:
716
+ print(f"missing wired gates: {', '.join(gates['missing_wired'])}")
717
+ return 0
718
+
719
+
720
+ def _session_approver(explicit: bool):
721
+ def approve(proposal):
722
+ print(proposal.render())
723
+ if explicit:
724
+ return True
725
+ if not sys.stdin.isatty():
726
+ print(
727
+ "BLOCKED: --write without --approve and stdin is not a terminal; "
728
+ "changes were not applied.",
729
+ file=sys.stderr,
730
+ )
731
+ return False
732
+ answer = input("Apply these changes to the working tree? [y/N] ").strip().lower()
733
+ return answer in ("y", "yes")
734
+
735
+ return approve
736
+
737
+
738
+ def _render_session(outcome, *, as_json: bool, quiet: bool) -> None:
739
+ receipt = outcome.receipt
740
+ if as_json:
741
+ print(json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=False))
742
+ return
743
+ if not quiet:
744
+ print(f"session: {receipt['session']['session_id']} status: {receipt['status']}")
745
+ print(f"model: {receipt['model']} steps: {receipt['steps']} tool_calls: {receipt['tool_calls']}")
746
+ print(f"changed: {', '.join(receipt['changed']) or '(none)'}")
747
+ decision = "approved" if outcome.review.approved else "refused"
748
+ print(f"apply: {decision} ({outcome.review.reason})")
749
+ print(f"\n{receipt['final_response']}")
750
+ print(f"receipt: {_file_link(outcome.receipt_path)}")
751
+
752
+
753
+ def _cmd_session(args: argparse.Namespace) -> int:
754
+ command = getattr(args, "session_command", None)
755
+ if command is None:
756
+ print("BLOCKED: choose a session subcommand: start | resume | replay", file=sys.stderr)
757
+ return 2
758
+ try:
759
+ if command == "replay":
760
+ session = Session.load(args.dir, args.session)
761
+ runs = session.replay()
762
+ if args.json:
763
+ print(json.dumps(runs, indent=2, sort_keys=True, ensure_ascii=False))
764
+ else:
765
+ print(
766
+ f"session: {session.session_id} status: {session.record['status']} "
767
+ f"runs: {len(runs)}"
768
+ )
769
+ for index, run in enumerate(runs):
770
+ changed = ", ".join(run.get("changed") or []) or "(none)"
771
+ print(f" [{index}] {run['run_id']} status={run.get('status')} changed={changed}")
772
+ return 0
773
+
774
+ bounds = _resolve_bounds(args)
775
+ client = _make_client(args)
776
+ if command == "start":
777
+ session = Session.start(args.dir, task=args.task, bounds=bounds)
778
+ task = None
779
+ else:
780
+ session = Session.resume(args.dir, args.session, bounds=bounds)
781
+ task = args.task
782
+ reporter = (
783
+ (lambda _message: None)
784
+ if args.quiet
785
+ else (lambda message: print(message, file=sys.stderr))
786
+ )
787
+ outcome = session.run(
788
+ client=client,
789
+ task=task,
790
+ criteria=args.criteria,
791
+ out_of_scope=args.out_of_scope,
792
+ test_argv=_approved_test_argv(args),
793
+ model=args.model,
794
+ max_tokens=args.max_tokens,
795
+ reasoning_effort=args.reasoning_effort,
796
+ verify=args.verify,
797
+ write=args.write,
798
+ allow_delete=getattr(args, "allow_delete", False),
799
+ approve=_session_approver(args.approve) if args.write else None,
800
+ reporter=reporter,
801
+ diagnostics=getattr(args, "diagnostics", True),
802
+ )
803
+ except (
804
+ SIDClientError,
805
+ WorkspaceError,
806
+ LocalWorkspaceError,
807
+ SessionError,
808
+ SessionStoreError,
809
+ BoundsError,
810
+ ) as exc:
811
+ print(f"BLOCKED: {exc}", file=sys.stderr)
812
+ return 2
813
+ _render_session(outcome, as_json=args.json, quiet=args.quiet)
814
+ return 0
815
+
816
+
817
+ def _cmd_editor(args: argparse.Namespace) -> int:
818
+ if getattr(args, "editor_command", None) != "install":
819
+ print("BLOCKED: choose an editor subcommand: install", file=sys.stderr)
820
+ return 2
821
+ from .editor import EditorError, install
822
+
823
+ try:
824
+ result = install(args.target_dir, force=args.force)
825
+ except EditorError as exc:
826
+ print(f"BLOCKED: {exc}", file=sys.stderr)
827
+ return 2
828
+ if args.json:
829
+ print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False))
830
+ return 0
831
+ print(f"extension {result['status']}: {result['extension']}")
832
+ print("reload VS Code to activate the SID commands.")
833
+ return 0
834
+
835
+
836
+ def _cmd_apply(args: argparse.Namespace) -> int:
837
+ from .editor import EditorError, apply_receipt
838
+
839
+ try:
840
+ result = apply_receipt(args.dir, args.receipt, approve=args.approve)
841
+ except (EditorError, LocalWorkspaceError) as exc:
842
+ print(f"BLOCKED: {exc}", file=sys.stderr)
843
+ return 2
844
+ if args.json:
845
+ print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False))
846
+ elif result["status"] == "applied":
847
+ print(f"applied: {', '.join(result['applied'])}")
848
+ elif result["status"] == "nothing-to-apply":
849
+ print("nothing to apply: the receipt carries no changed files")
850
+ else:
851
+ print(f"{result['status']}: no files written")
852
+ return 2 if result["status"] in ("approval-required", "refused-by-policy") else 0
853
+
854
+
855
+ def main(argv: list[str] | None = None) -> int:
856
+ parser = build_parser()
857
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
858
+ if args.command == "setup":
859
+ return _cmd_setup(args)
860
+ if args.command in {"run", "tui", "status", "session", "policy"} and not _apply_default_dir(args):
861
+ return 2
862
+ if args.command == "run":
863
+ return _cmd_run(args)
864
+ if args.command == "tui":
865
+ from .tui import run_tui
866
+
867
+ return run_tui(args)
868
+ if args.command == "status":
869
+ return _cmd_status(args)
870
+ if args.command == "session":
871
+ return _cmd_session(args)
872
+ if args.command == "tools":
873
+ return _cmd_tools(args)
874
+ if args.command == "policy":
875
+ return _cmd_policy(args)
876
+ if args.command == "selftest":
877
+ from .selftest import run_selftest
878
+
879
+ try:
880
+ outcome = run_selftest(receipt_path=args.receipt)
881
+ except Exception as exc: # selftest is a diagnostic; report and fail closed
882
+ print(f"selftest FAILED: {exc}", file=sys.stderr)
883
+ return 1
884
+ print(f"selftest OK — receipt: {_file_link(outcome.receipt_path)}")
885
+ return 0
886
+ if args.command == "bridge":
887
+ from .bridge import ALLOWED_ORIGINS, main as bridge_main
888
+
889
+ argv = ["--dir", args.dir, "--port", str(args.port), "--pair-ttl", str(args.pair_ttl)]
890
+ if args.token:
891
+ argv += ["--token", args.token]
892
+ if args.pair_code:
893
+ argv += ["--pair-code", args.pair_code]
894
+ if args.write:
895
+ argv.append("--write")
896
+ if args.state_dir:
897
+ argv += ["--state-dir", args.state_dir]
898
+ if args.studio_url:
899
+ argv += ["--studio-url", args.studio_url]
900
+ else:
901
+ argv += ["--studio-url", "https://app.superintelligencedog.com/studio.html"]
902
+ for origin in args.allow_origin or []:
903
+ if origin not in ALLOWED_ORIGINS:
904
+ argv += ["--allow-origin", origin]
905
+ if args.base_url:
906
+ argv += ["--base-url", args.base_url]
907
+ return bridge_main(argv)
908
+ if args.command == "companion":
909
+ from .companion import DEFAULT_STUDIO_URL, main as companion_main
910
+
911
+ argv = ["--dir", args.dir, "--port", str(args.port), "--pair-ttl", str(args.pair_ttl)]
912
+ argv += ["--studio-url", args.studio_url or DEFAULT_STUDIO_URL]
913
+ if args.token:
914
+ argv += ["--token", args.token]
915
+ if args.pair_code:
916
+ argv += ["--pair-code", args.pair_code]
917
+ if args.write:
918
+ argv.append("--write")
919
+ if args.state_dir:
920
+ argv += ["--state-dir", args.state_dir]
921
+ if args.no_open:
922
+ argv.append("--no-open")
923
+ for origin in args.allow_origin or []:
924
+ argv += ["--allow-origin", origin]
925
+ if args.base_url:
926
+ argv += ["--base-url", args.base_url]
927
+ return companion_main(argv)
928
+ if args.command == "lsp":
929
+ from .lsp import serve_stdio
930
+
931
+ return serve_stdio(args.dir)
932
+ if args.command == "editor":
933
+ return _cmd_editor(args)
934
+ if args.command == "apply":
935
+ return _cmd_apply(args)
936
+ parser.print_help()
937
+ return 2