rockycode 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 (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,198 @@
1
+ """Pure permission policy — no UI, no I/O, no async. The testable heart of the
2
+ opt-in approval layer.
3
+
4
+ `decide(mode, risk, args, workdir)` maps a tool's static risk tier
5
+ (safe|moderate|risky — see tools.RISK / Tool.risk) and the session's permission
6
+ mode (yolo|ask|careful) to one of {"allow", "ask"}. The TUI's approver runs this
7
+ first and only pops a modal on "ask"; bench/headless never even calls it (its
8
+ engine keeps the always-allow default).
9
+
10
+ `sniff_danger(tool, args)` is an advisory heuristic that flags remote-code-
11
+ execution / destructive patterns so the approval modal can highlight them — the
12
+ real defense against a "cheating" skill that tells the model to curl a virus,
13
+ applied at the layer where the actual command is visible.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from rockycode.engine.safety import classify_command
22
+
23
+ MODES = ("yolo", "ask", "careful")
24
+ RISKS = ("safe", "moderate", "risky")
25
+
26
+
27
+ READ_TOOLS = ("read_file", "grep", "glob")
28
+
29
+
30
+ def decide(mode: str, risk: str, args: dict, workdir: Path, tool: str = "", read_grants=()) -> str:
31
+ """Return "allow" | "ask" | "block". Never prompts; just classifies the call.
32
+
33
+ Command-level danger is judged FIRST, and it overrides the mode — this is the
34
+ same per-command classifier goal mode uses, lifted into the shared permission
35
+ layer so *chat* bash is judged by what the command DOES, not just that it's
36
+ "a bash call". So a benign `ls` and a `brew install` are no longer the same
37
+ decision:
38
+ - a "block"-tier command (e.g. `sudo rm -rf /`, `curl … | sudo sh`) → "block"
39
+ in EVERY mode, even yolo — it must never run unattended.
40
+ - an "ask"-tier command (install / privileged / network: brew/apt/sudo/…) →
41
+ "ask" in ask & careful — a fresh prompt every time. Paired with
42
+ session_grantable(), a per-tool "allow for this session" can't wave it
43
+ through. In yolo it still runs (you opted out of prompts).
44
+
45
+ Then the ordinary tier logic:
46
+ - yolo : allow everything else
47
+ - safe : allow — EXCEPT a read whose target escapes the workdir (secrets)
48
+ - risky : ask (bash/web_fetch/mcp__*) in both ask and careful
49
+ - moderate: `ask` allows an in-workdir write, else ask; `careful` always asks.
50
+ """
51
+ if tool == "bash":
52
+ action = classify_command(str(args.get("command", ""))).action
53
+ if action == "block":
54
+ return "block" # never runs — even in yolo
55
+ if action == "ask" and mode != "yolo":
56
+ return "ask" # dangerous cmd → always a prompt
57
+ if mode == "yolo":
58
+ return "allow"
59
+ if risk == "safe":
60
+ if tool in READ_TOOLS and _read_escapes_workdir(tool, args, workdir, read_grants):
61
+ return "ask"
62
+ return "allow"
63
+ if risk == "risky":
64
+ return "ask"
65
+ # moderate (write_file / edit_file / remember)
66
+ if mode == "careful":
67
+ return "ask"
68
+ return "allow" if _writes_inside(args, workdir) else "ask"
69
+
70
+
71
+ def session_grantable(tool: str, args: dict) -> bool:
72
+ """Whether a per-tool "allow for this session" grant may cover THIS call.
73
+
74
+ False for a dangerous bash command (install / privileged / network /
75
+ destructive): approving a benign `ls` for the session must NOT silently
76
+ green-light a later `brew install` or `sudo …`. Those keep prompting (or
77
+ stay blocked) every time, no matter the session allowlist. Everything else is
78
+ grantable as before."""
79
+ if tool == "bash":
80
+ return classify_command(str(args.get("command", ""))).action == "allow"
81
+ return True
82
+
83
+
84
+ def command_binary(command: str) -> str:
85
+ """The program a bash command runs — the unit a session grant is scoped to.
86
+
87
+ A bash session grant is NEVER "all bash": approving `lake build` grants the
88
+ `lake` BINARY (so `lake build`, `lake env lean`, … stop nagging during a
89
+ proof/test loop) but a later `curl`/`rm`/`sudo` is a different binary and
90
+ still prompts. Skips leading `VAR=val` assignments; returns the basename
91
+ (so `/usr/bin/lake` and `lake` are the same grant).
92
+
93
+ Returns "" for ANY command that chains, redirects, or substitutes
94
+ (`; | & > < ( ) { } ` $` or a newline) — a compound like `lake build; curl
95
+ evil` must not match a `lake` grant, or the chained command would ride in.
96
+ "" never matches a grant, so those always re-prompt.
97
+ """
98
+ import re
99
+ if any(c in command for c in "|&;<>(){}`$\n"):
100
+ return ""
101
+ for tok in command.strip().split():
102
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", tok):
103
+ continue # env assignment prefix
104
+ return tok.rsplit("/", 1)[-1]
105
+ return ""
106
+
107
+
108
+ def _read_escapes_workdir(tool: str, args: dict, workdir: Path, read_grants=()) -> bool:
109
+ """True if a read tool's target lies outside workdir AND isn't already granted
110
+ (→ gate it). glob has no path arg, so its pattern is judged instead; an
111
+ absolute/`~`/`..` pattern is treated as escaping. A path inside a granted root
112
+ doesn't re-prompt (approving a read widens the jail; see tools._jail)."""
113
+ if tool == "glob":
114
+ pat = args.get("pattern")
115
+ if not isinstance(pat, str):
116
+ return False
117
+ return pat.startswith(("/", "~")) or ".." in pat
118
+ p = args.get("path")
119
+ if tool == "grep" and not p:
120
+ p = "." # grep defaults to the workdir root — inside
121
+ if not isinstance(p, str) or not p:
122
+ return False
123
+ try:
124
+ target = Path(p)
125
+ if not target.is_absolute():
126
+ target = workdir / target
127
+ target = target.resolve()
128
+ wd = workdir.resolve()
129
+ except (OSError, ValueError, RuntimeError):
130
+ return True # unresolvable → fail-safe → ask
131
+ roots = (wd, *read_grants)
132
+ return not any(target == r or r in target.parents for r in roots)
133
+
134
+
135
+ def _writes_inside(args: dict, workdir: Path) -> bool:
136
+ """True only if args['path'] resolves to a location within workdir. Unknown
137
+ or missing path is treated as outside (fail-safe → ask)."""
138
+ p = args.get("path")
139
+ if not isinstance(p, str) or not p:
140
+ return False
141
+ try:
142
+ target = Path(p)
143
+ if not target.is_absolute():
144
+ target = workdir / target
145
+ target = target.resolve()
146
+ wd = workdir.resolve()
147
+ except (OSError, ValueError, RuntimeError):
148
+ return False
149
+ return target == wd or wd in target.parents
150
+
151
+
152
+ # Advisory only — these never block, they just surface a warning in the modal.
153
+ _DANGER = [
154
+ (re.compile(r"\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", re.I),
155
+ "pipes a download straight into a shell"),
156
+ (re.compile(r"\|\s*(sudo\s+)?(ba|z|da)?sh\b(\s|$)", re.I),
157
+ "pipes output into a shell"),
158
+ (re.compile(r"base64\s+-+d\w*\b.*\|\s*(ba|z)?sh", re.I | re.S),
159
+ "decodes base64 and runs it"),
160
+ (re.compile(r"eval\s+[\"']?\$\(", re.I),
161
+ "evals a command-substitution result"),
162
+ (re.compile(r"\brm\s+-[a-z]*r[a-z]*f?\s+(-{1,2}\S+\s+)*[~/]", re.I),
163
+ "recursive delete of a home/root path"),
164
+ (re.compile(r">>?\s*~?/?(\.(ssh|bashrc|zshrc|bash_profile|profile)\b|\.ssh/)", re.I),
165
+ "writes to a shell/ssh dotfile"),
166
+ (re.compile(r"\bcrontab\b", re.I),
167
+ "edits cron jobs"),
168
+ (re.compile(r"chmod\s+\+x\b[^&;|]*/tmp/", re.I),
169
+ "makes a /tmp file executable"),
170
+ (re.compile(r"\bnc\b[^|;&]*\s-\w*e\w*\b|/dev/tcp/", re.I),
171
+ "looks like a reverse shell"),
172
+ (re.compile(r"\b(?:ba|z|da)?sh\b\s+-[a-z]*c\b[^\n]*\$\(", re.I),
173
+ "runs a command-substitution result in a shell"),
174
+ (re.compile(r"<\(\s*(?:sudo\s+)?(?:curl|wget|fetch)\b", re.I),
175
+ "process-substitutes a network download into a command"),
176
+ (re.compile(r"\b(?:python[0-9.]*|perl|ruby|node|php)\b\s+-[a-z]*[ceE]\b[^\n]*"
177
+ r"(?:curl|wget|urllib|urlopen|requests|socket|https?://)", re.I),
178
+ "inline interpreter script that pulls from the network"),
179
+ ]
180
+
181
+
182
+ def sniff_danger(tool: str, args: dict) -> Optional[str]:
183
+ """Return a short reason if the call matches a known dangerous pattern, else
184
+ None. Checks bash commands, web_fetch URLs, and (defensively) MCP tool args."""
185
+ if tool == "bash":
186
+ blob = str(args.get("command", ""))
187
+ elif tool == "web_fetch":
188
+ blob = str(args.get("url", ""))
189
+ elif tool.startswith("mcp__"):
190
+ blob = str(args)
191
+ else:
192
+ return None
193
+ if not blob:
194
+ return None
195
+ for rx, why in _DANGER:
196
+ if rx.search(blob):
197
+ return why
198
+ return None
@@ -0,0 +1,249 @@
1
+ """Plan-mode policy: the read-only gate for interactive planning.
2
+
3
+ Plan mode is HOST state on the Engine (never a model-invoked tool — an
4
+ out-of-distribution mode tool invokes unreliably on DeepSeek, and schema churn
5
+ breaks the prefix cache; see docs/plan-mode-design.md). While it is on, every
6
+ tool call runs through gate() BEFORE the normal permission/approver flow:
7
+
8
+ pass — hand the call to the normal flow (permission.decide + modal):
9
+ every 'safe'-tier read, read-only-classified bash (which still ASKS,
10
+ never auto-allows — a read-only `cat ~/.ssh/id_rsa` must face a
11
+ human), and web_fetch (a read of the world, SSRF-guarded, still asks).
12
+ allow — run without asking: a write/edit whose RESOLVED target is exactly
13
+ the session's plan file. The one writable path in the mode.
14
+ deny — everything else that mutates. The message teaches the model where
15
+ to go instead (keep exploring, write the plan) rather than just
16
+ refusing, so the turn keeps moving.
17
+
18
+ This gate decides deny-vs-ask; it is NOT a security boundary (human approval,
19
+ the file-tool jail, and the secret-file refusals are). The bash classifier is
20
+ therefore a conservative whitelist, not a bulletproof shell parser: a sneaky
21
+ "read-only" command that slips through still lands in front of the user.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Verdict:
33
+ action: str # "pass" | "allow" | "deny"
34
+ message: str # model-facing text for deny; "" otherwise
35
+
36
+
37
+ PASS = Verdict("pass", "")
38
+
39
+ # Risky-tier tools that only READ the world — forwarded to the normal ask flow.
40
+ # (web_search/web_research are already 'safe'-tier and pass on risk alone.)
41
+ _WORLD_READS = frozenset({"web_fetch"})
42
+
43
+ _WRITE_MSG = (
44
+ "[blocked] plan mode is read-only — no code changes yet. The one writable "
45
+ "file is the plan: {plan}. Keep exploring with read-only tools and write "
46
+ "your plan there; the user approves it before any changes are made."
47
+ )
48
+ _BASH_MSG = (
49
+ "[blocked] plan mode is read-only and this command could change state. "
50
+ "Read-only commands (git log/diff/show/status/blame, ls, cat, rg, find …) "
51
+ "may run with approval — no redirects, chaining, or substitution. Write "
52
+ "your plan to {plan} when ready; the user approves it before any changes."
53
+ )
54
+ _TOOL_MSG = (
55
+ "[blocked] '{tool}' is not available in plan mode — it can change state. "
56
+ "Explore with read-only tools, then write your plan to {plan}; the user "
57
+ "approves it before any changes are made."
58
+ )
59
+
60
+
61
+ # The plan-mode instruction rides the USER turn — never the system prompt or
62
+ # the tool list — so toggling the mode leaves the cached prompt prefix
63
+ # byte-identical (DeepSeek prefix caching is full-prefix-match only).
64
+ _MARKER = (
65
+ "[Plan mode — planning only, no code changes. Explore with read-only tools "
66
+ "(read-only bash and web fetches still ask for approval). BRAINSTORM first: "
67
+ "if a decision that is genuinely the user's — scope, a tech choice, an "
68
+ "ambiguous requirement — would shape the plan, ask the single most "
69
+ "important question and stop; one question per turn; do NOT create the "
70
+ "plan file while brainstorming. If the user says to just write the plan, "
71
+ "draft immediately. DRAFT when answers stop changing the design: write the "
72
+ "plan to {path} — a few phases, each with concrete, verifiable steps — "
73
+ "then stop. That file is the only thing you may write; the user approves "
74
+ "the plan before any changes are made.]"
75
+ )
76
+
77
+
78
+ def marker(plan_file: Path) -> str:
79
+ """The per-turn plan-mode instruction (prepended to each user message)."""
80
+ return _MARKER.format(path=plan_file)
81
+
82
+
83
+ # ── plan-file parsing ─────────────────────────────────────────────────────────
84
+ # The marker stays format-LIGHT on purpose: dictating markdown shape costs
85
+ # tokens every turn and different models write plans differently. Instead the
86
+ # parser is liberal — a phase line is a markdown heading ('## Verify') OR a
87
+ # top-level numbered item ('2. Verify', bold or plain); its bullets / indented
88
+ # numbered lines are the phase's steps. This is what the future plan→goal
89
+ # handoff reads (goal.parse_plan drops heading lines, so it can't be reused).
90
+
91
+ _PHASE_RX = re.compile(r"^(?:#{1,6}\s+|\*{0,2}\d+[.)]\*{0,2}\s+)\s*(.+?)\s*$")
92
+ _STEP_RX = re.compile(r"^(?:\s+(?:[-*•]|\d+[.)])|[-*•])\s+(.+?)\s*$")
93
+
94
+
95
+ def parse_plan_file(text: str) -> list[tuple[str, list[str]]]:
96
+ """(phase title, [steps]) pairs from a drafted plan, shape-agnostic.
97
+
98
+ A document title (an H1 with no steps of its own, like '# Fix add()')
99
+ is dropped when real phases carry the steps: any step-less phase is
100
+ pruned as long as at least one phase has steps."""
101
+ phases: list[tuple[str, list[str]]] = []
102
+ for line in text.splitlines():
103
+ s = _STEP_RX.match(line)
104
+ if s and phases:
105
+ phases[-1][1].append(s.group(1).strip())
106
+ continue
107
+ p = _PHASE_RX.match(line)
108
+ if p:
109
+ title = p.group(1).strip().strip("*").rstrip("::").strip()
110
+ if title:
111
+ phases.append((title, []))
112
+ if any(steps for _, steps in phases):
113
+ phases = [(t, st) for t, st in phases if st]
114
+ return phases
115
+
116
+
117
+ def create_plan_file(workdir: Path, topic: str = "") -> Path:
118
+ """Create (and return) a fresh plan file under .rockycode/plans/.
119
+
120
+ The directory self-gitignores (a `*` .gitignore inside it) so drafts never
121
+ pollute the project's git status — delete that file to start committing
122
+ plans. Name: <date>-<topic-slug>.md, falling back to the time when no
123
+ topic was given; an existing non-empty file of the same name gets a -N
124
+ suffix instead of being reused (the turn-end gate watches THIS session's
125
+ file for changes, so it must start empty)."""
126
+ plans = workdir / ".rockycode" / "plans"
127
+ plans.mkdir(parents=True, exist_ok=True)
128
+ gitignore = plans / ".gitignore"
129
+ if not gitignore.exists():
130
+ gitignore.write_text("*\n")
131
+ slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")[:40] or time.strftime("%H%M")
132
+ base = f"{time.strftime('%Y-%m-%d')}-{slug}"
133
+ path, n = plans / f"{base}.md", 1
134
+ while path.exists() and path.stat().st_size > 0:
135
+ n += 1
136
+ path = plans / f"{base}-{n}.md"
137
+ path.touch()
138
+ return path
139
+
140
+
141
+ def gate(tool: str, args: dict, risk: str, plan_file: Path, workdir: Path) -> Verdict:
142
+ """Classify one tool call under plan mode. *risk* is the registry tier
143
+ ('safe'/'moderate'/'risky' — unknown tools default risky upstream)."""
144
+ if risk == "safe":
145
+ return PASS
146
+ if tool in ("write_file", "edit_file"):
147
+ if _is_plan_file(args.get("path"), plan_file, workdir):
148
+ return Verdict("allow", "")
149
+ return Verdict("deny", _WRITE_MSG.format(plan=plan_file))
150
+ if tool == "bash":
151
+ cmd = args.get("command")
152
+ if isinstance(cmd, str) and is_read_only_command(cmd):
153
+ return PASS
154
+ return Verdict("deny", _BASH_MSG.format(plan=plan_file))
155
+ if tool in _WORLD_READS:
156
+ return PASS
157
+ return Verdict("deny", _TOOL_MSG.format(tool=tool, plan=plan_file))
158
+
159
+
160
+ def _is_plan_file(path, plan_file: Path, workdir: Path) -> bool:
161
+ """True iff *path* RESOLVES to exactly the plan file — `..`, symlinks, and
162
+ absolute aliases all collapse first, so the carve-out can't be retargeted.
163
+ Any malformed/missing path fails safe (False → deny)."""
164
+ if not isinstance(path, str) or not path:
165
+ return False
166
+ p = Path(path)
167
+ if not p.is_absolute():
168
+ p = workdir / p
169
+ try:
170
+ return p.resolve() == plan_file.resolve()
171
+ except OSError:
172
+ return False
173
+
174
+
175
+ # ── read-only bash classification ────────────────────────────────────────────
176
+ # Whitelist, not blacklist: only commands we KNOW don't mutate may pass to the
177
+ # ask flow; everything unrecognized is denied. Rejected outright: redirects,
178
+ # command chaining, substitution, and multi-line — so pipes are the only
179
+ # composition, and every pipe segment must itself be read-only.
180
+
181
+ _META = re.compile(r">|;|&|\$\(|`|<\(")
182
+ # awk/find can execute subcommands without any shell metacharacter
183
+ _EMBEDDED_EXEC = re.compile(r"\bsystem\s*\(")
184
+
185
+ _READ_CMDS = frozenset({
186
+ "ls", "cat", "head", "tail", "wc", "stat", "file", "du", "tree", "pwd",
187
+ "which", "grep", "rg", "fd", "sort", "uniq", "cut", "diff", "realpath",
188
+ "basename", "dirname", "date", "nl", "column", "od", "strings",
189
+ "find", "sed", "awk", "git",
190
+ })
191
+ _GIT_READ_SUBS = frozenset({
192
+ "log", "diff", "show", "status", "blame", "shortlog", "describe",
193
+ "ls-files", "rev-parse", "grep", "reflog",
194
+ })
195
+ _FIND_WRITE_FLAGS = frozenset({
196
+ "-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls",
197
+ })
198
+
199
+
200
+ def is_read_only_command(command: str) -> bool:
201
+ """True iff *command* is a whitelisted read-only invocation (it still goes
202
+ through the normal ask flow — this never auto-allows)."""
203
+ cmd = command.strip()
204
+ if not cmd or "\n" in cmd or _META.search(cmd) or _EMBEDDED_EXEC.search(cmd):
205
+ return False
206
+ return all(_segment_read_only(seg) for seg in cmd.split("|"))
207
+
208
+
209
+ def _segment_read_only(segment: str) -> bool:
210
+ tokens = segment.split()
211
+ # skip leading VAR=value assignments (FOO=1 git log)
212
+ while tokens and re.match(r"^\w+=", tokens[0]):
213
+ tokens = tokens[1:]
214
+ if not tokens:
215
+ return False
216
+ name = tokens[0].rsplit("/", 1)[-1] # /usr/bin/git → git
217
+ if name not in _READ_CMDS:
218
+ return False
219
+ if name == "git":
220
+ return _git_read_only(tokens[1:])
221
+ if name == "find":
222
+ return not any(t in _FIND_WRITE_FLAGS for t in tokens[1:])
223
+ if name == "sed":
224
+ return not any(t.startswith("-i") for t in tokens[1:])
225
+ return True
226
+
227
+
228
+ def _git_read_only(tokens: list[str]) -> bool:
229
+ """git with a read-only subcommand. `-c` (can define alias/hook-ish config)
230
+ and `--output`/`--ext-diff` (write a file / run a command) are rejected even
231
+ on read subcommands."""
232
+ sub = None
233
+ i = 0
234
+ while i < len(tokens):
235
+ t = tokens[i]
236
+ if t in ("-c",) or t.startswith("--output") or t == "--ext-diff":
237
+ return False
238
+ if t in ("-C", "--git-dir", "--work-tree"):
239
+ i += 2 # flag takes a value
240
+ continue
241
+ if t.startswith("-"):
242
+ i += 1
243
+ continue
244
+ sub = t
245
+ break
246
+ if sub is None or sub not in _GIT_READ_SUBS:
247
+ return False
248
+ # write-capable flags can appear after the subcommand too (git log --output=x)
249
+ return not any(t.startswith("--output") or t == "--ext-diff" for t in tokens[i:])
@@ -0,0 +1,196 @@
1
+ """Provider profiles: switch which OpenAI-compatible endpoint + model rocky uses.
2
+
3
+ Anti-glue by design. Every provider here (DeepSeek, MiniMax, GLM, Kimi, …)
4
+ speaks the OpenAI chat-completions protocol, so the OpenAI SDK is the ONLY
5
+ compatibility layer — rocky never writes a per-provider adapter. A provider is
6
+ DATA, not code: a base_url, some model ids, which env var holds its key, and
7
+ which reasoning-param shape it wants. Adding one is a few lines here or in
8
+ `~/.rockycode/providers.toml`; a non-OpenAI-compatible provider is unsupported.
9
+
10
+ Regions: MiniMax / Kimi / GLM run SEPARATE international and China endpoints —
11
+ different base_url AND different key. That's modeled as multiple `endpoints` on
12
+ one provider (models shared), not duplicated entries. Each endpoint is pickable
13
+ as `<provider>` (the default) or `<provider>-<region>` (e.g. `kimi-cn`).
14
+
15
+ Keys are rocky-OWNED per endpoint: `ROCKYCODE_<NAME>_API_KEY` (intl) /
16
+ `ROCKYCODE_<NAME>_CN_API_KEY` (cn) — never the ambient `MINIMAX_API_KEY` etc.
17
+ The built-in `deepseek` reads the existing ROCKYCODE_API_KEY, so current setups
18
+ keep working with no new key.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from rockycode.onboarding import BASE_URL_ENV, DEFAULT_BASE_URL, KEY_ENV
28
+
29
+ _HOME = Path(os.environ.get("ROCKYCODE_HOME") or Path.home() / ".rockycode")
30
+ PROVIDERS_TOML = _HOME / "providers.toml"
31
+ _PLACEHOLDERS = {"", "replace-me", "sk-replace-me", "your-api-key"}
32
+
33
+
34
+ @dataclass
35
+ class Endpoint:
36
+ # `eid` is the EXPLICIT /model token — no magic default. Regional endpoints
37
+ # are spelled out (kimi-en / kimi-cn / minimax-en / minimax-cn), and where a
38
+ # provider's international arm is a different brand it gets that name (GLM's
39
+ # is z.ai → `zai`). `key_env` matches: ROCKYCODE_<EID_UPPER>_API_KEY.
40
+ eid: str
41
+ base_url: str
42
+ key_env: str
43
+
44
+ def key(self) -> Optional[str]:
45
+ v = (os.getenv(self.key_env) or "").strip()
46
+ return v if v and v.lower() not in _PLACEHOLDERS else None
47
+
48
+
49
+ @dataclass
50
+ class Provider:
51
+ name: str
52
+ models: list[str]
53
+ endpoints: list[Endpoint]
54
+ reasoning: str = "openai" # deepseek | openai | none
55
+ tools: str = "native" # native | off
56
+ label: str = ""
57
+ builtin: bool = True
58
+
59
+
60
+ @dataclass
61
+ class Choice:
62
+ """A flat, pickable (provider, endpoint, model). `id` is `<eid>:<model>`
63
+ (e.g. kimi-cn:kimi-k3 / zai:glm-5.2); `configured` = its key is set, which
64
+ is how the picker stays short (show only what you've keyed)."""
65
+ provider: Provider
66
+ endpoint: Endpoint
67
+ model: str
68
+
69
+ @property
70
+ def prov_id(self) -> str:
71
+ return self.endpoint.eid
72
+
73
+ @property
74
+ def id(self) -> str:
75
+ return f"{self.endpoint.eid}:{self.model}"
76
+
77
+ @property
78
+ def configured(self) -> bool:
79
+ return self.endpoint.key() is not None
80
+
81
+
82
+ def _builtins() -> dict[str, Provider]:
83
+ # NOTE: regional base_urls are best-effort — verify per provider when you
84
+ # first test one; correcting a URL is a one-line data edit here or in TOML.
85
+ return {
86
+ "deepseek": Provider(
87
+ name="deepseek", models=["deepseek-v4-pro", "deepseek-v4-flash"],
88
+ endpoints=[Endpoint("deepseek",
89
+ (os.getenv(BASE_URL_ENV) or "").strip() or DEFAULT_BASE_URL,
90
+ KEY_ENV)],
91
+ reasoning="deepseek", label="DeepSeek V4 — rocky's home model"),
92
+ "minimax": Provider(
93
+ name="minimax", models=["minimax-m3"],
94
+ endpoints=[
95
+ Endpoint("minimax-en", "https://api.minimaxi.chat/v1", "ROCKYCODE_MINIMAX_EN_API_KEY"),
96
+ Endpoint("minimax-cn", "https://api.minimax.chat/v1", "ROCKYCODE_MINIMAX_CN_API_KEY"),
97
+ ],
98
+ reasoning="openai", label="MiniMax M3"),
99
+ "kimi": Provider(
100
+ name="kimi", models=["kimi-k3"],
101
+ endpoints=[
102
+ Endpoint("kimi-en", "https://api.moonshot.ai/v1", "ROCKYCODE_KIMI_EN_API_KEY"),
103
+ Endpoint("kimi-cn", "https://api.moonshot.cn/v1", "ROCKYCODE_KIMI_CN_API_KEY"),
104
+ ],
105
+ reasoning="none", label="Kimi / Moonshot"),
106
+ "glm": Provider(
107
+ name="glm", models=["glm-5.2"],
108
+ endpoints=[
109
+ # international arm is a different brand — z.ai, not "glm"
110
+ Endpoint("zai", "https://api.z.ai/api/paas/v4", "ROCKYCODE_ZAI_EN_API_KEY"),
111
+ Endpoint("glm-cn", "https://open.bigmodel.cn/api/paas/v4", "ROCKYCODE_GLM_CN_API_KEY"),
112
+ ],
113
+ reasoning="none", label="GLM (z.ai intl / bigmodel.cn)"),
114
+ }
115
+
116
+
117
+ def _parse_toml(path: Path) -> dict[str, Provider]:
118
+ try:
119
+ import tomllib
120
+ data = tomllib.loads(path.read_text())
121
+ except FileNotFoundError:
122
+ return {}
123
+ except Exception: # noqa: BLE001 — a broken file must not crash startup
124
+ return {}
125
+ out: dict[str, Provider] = {}
126
+ for name, cfg in (data.get("providers") or data).items():
127
+ if not isinstance(cfg, dict):
128
+ continue
129
+ models = cfg.get("models") or ([cfg["model"]] if cfg.get("model") else [])
130
+ # endpoints: explicit list, or a single base_url/key_env pair. Each
131
+ # endpoint's `id` is its /model token; key_env defaults to
132
+ # ROCKYCODE_<ID_UPPER>_API_KEY.
133
+ def _kenv(eid: str) -> str:
134
+ return f"ROCKYCODE_{eid.upper().replace('-', '_')}_API_KEY"
135
+ eps_cfg = cfg.get("endpoints")
136
+ if eps_cfg:
137
+ eps = [Endpoint(e.get("id", name), e["base_url"],
138
+ e.get("key_env", _kenv(e.get("id", name))))
139
+ for e in eps_cfg if e.get("base_url")]
140
+ elif cfg.get("base_url"):
141
+ eps = [Endpoint(cfg.get("id", name), cfg["base_url"],
142
+ cfg.get("key_env", _kenv(cfg.get("id", name))))]
143
+ else:
144
+ continue
145
+ if not models or not eps:
146
+ continue
147
+ out[name] = Provider(name=name, models=list(models), endpoints=eps,
148
+ reasoning=cfg.get("reasoning", "openai"),
149
+ tools=cfg.get("tools", "native"),
150
+ label=cfg.get("label", ""), builtin=False)
151
+ return out
152
+
153
+
154
+ def discover() -> dict[str, Provider]:
155
+ providers = _builtins()
156
+ providers.update(_parse_toml(PROVIDERS_TOML))
157
+ return providers
158
+
159
+
160
+ def choices() -> list[Choice]:
161
+ """Every (provider, endpoint, model) as a flat pickable list."""
162
+ return [Choice(p, e, m) for p in discover().values()
163
+ for e in p.endpoints for m in p.models]
164
+
165
+
166
+ def configured_choices() -> list[Choice]:
167
+ """Only choices whose key is set — the SHORT list the picker shows by
168
+ default, so an EN/CN catalog of ~12 doesn't scroll. deepseek is always
169
+ included (it rides the default ROCKYCODE_API_KEY)."""
170
+ out = [c for c in choices() if c.configured or c.provider.name == "deepseek"]
171
+ return out
172
+
173
+
174
+ def resolve(spec: str) -> Optional[tuple[Provider, Endpoint, str]]:
175
+ """Resolve a /model arg to (provider, endpoint, model).
176
+
177
+ Forms: an endpoint id (`deepseek`, `kimi-cn`, `zai`), `endpoint:model`
178
+ (`kimi-cn:k3`, `zai:glm`), or a unique bare model substring. Returns None
179
+ if the endpoint is unknown or the model substring is ambiguous.
180
+ """
181
+ spec = spec.strip()
182
+ eid_part, _, model_part = spec.replace(":", " ").partition(" ")
183
+ eid_part, model_part = eid_part.strip(), model_part.strip()
184
+
185
+ by_eid = {e.eid: (p, e) for p in discover().values() for e in p.endpoints}
186
+ if eid_part in by_eid:
187
+ p, e = by_eid[eid_part]
188
+ if not model_part:
189
+ return p, e, p.models[0]
190
+ hits = [m for m in p.models if model_part.lower() in m.lower()]
191
+ return (p, e, hits[0]) if len(hits) == 1 else None
192
+
193
+ # bare model substring — must be unique across all providers (first endpoint)
194
+ matches = [(p, p.endpoints[0], m) for p in discover().values() for m in p.models
195
+ if spec.lower() in m.lower()]
196
+ return matches[0] if len(matches) == 1 else None