mystack-rig 1.0.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.
- cli/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
cli/__main__.py
ADDED
cli/agents.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coding agent definitions and local .rig scaffolding.
|
|
3
|
+
|
|
4
|
+
Supported agents: opencode, claude (Claude Code), cursor (Cursor CLI).
|
|
5
|
+
|
|
6
|
+
Layout under the project (committed, user-editable):
|
|
7
|
+
|
|
8
|
+
.rig/AGENTS.md # shared workspace rules
|
|
9
|
+
.rig/.opencode/opencode.json # OpenCode config (API key via env)
|
|
10
|
+
.rig/.opencode/mcp.json # OpenCode MCP servers
|
|
11
|
+
.rig/.claude/settings.json # Claude Code settings
|
|
12
|
+
.rig/.claude/mcp.json # Claude Code MCP servers (.mcp.json on remote)
|
|
13
|
+
.rig/.cursor/cli-config.json # Cursor CLI config
|
|
14
|
+
.rig/.cursor/mcp.json # Cursor CLI MCP servers
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
SUPPORTED_AGENTS = ("opencode", "claude", "cursor")
|
|
24
|
+
|
|
25
|
+
AGENT_LABELS = {
|
|
26
|
+
"opencode": "OpenCode",
|
|
27
|
+
"claude": "Claude Code",
|
|
28
|
+
"cursor": "Cursor CLI",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Per-agent vault var. A single selected agent still uses the legacy shared name
|
|
32
|
+
# so existing ``coding_agent_api_key`` values keep working.
|
|
33
|
+
AGENT_API_KEY_VARS = {
|
|
34
|
+
"opencode": "opencode_api_key",
|
|
35
|
+
"claude": "claude_api_key",
|
|
36
|
+
"cursor": "cursor_api_key",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Distinct env fallbacks so several agents are not given the same key.
|
|
40
|
+
AGENT_API_KEY_ENVS = {
|
|
41
|
+
"opencode": "RIG_OPENCODE_API_KEY",
|
|
42
|
+
"claude": "RIG_ANTHROPIC_API_KEY",
|
|
43
|
+
"cursor": "RIG_CURSOR_API_KEY",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
LEGACY_CODING_AGENT_API_KEY = "coding_agent_api_key"
|
|
47
|
+
|
|
48
|
+
# Env var name referenced from generated configs; set by authorize.yml on the VM.
|
|
49
|
+
CODING_AGENT_API_KEY_ENV = "CODING_AGENT_API_KEY"
|
|
50
|
+
|
|
51
|
+
DEFAULT_AGENTS_MD = """# Workspace rules
|
|
52
|
+
|
|
53
|
+
These rules apply to coding agents provisioned by rig for this workspace.
|
|
54
|
+
|
|
55
|
+
## Goals
|
|
56
|
+
- Prefer small, focused changes.
|
|
57
|
+
- Match existing project conventions before introducing new patterns.
|
|
58
|
+
- Do not commit secrets; use vaulted `.rig/vars.yml` values via the environment.
|
|
59
|
+
|
|
60
|
+
## Remote environment
|
|
61
|
+
- This workspace runs on a rig-managed remote VM.
|
|
62
|
+
- Project files live under `/workspace` (not `/root`); home is for tooling/dotfiles only.
|
|
63
|
+
- Git remotes use HTTPS with provider PATs from the environment / Ansible vars.
|
|
64
|
+
- Prefer `gh` (GitHub) or `glab` (GitLab) for MRs, reviews, and issue workflows.
|
|
65
|
+
|
|
66
|
+
## Safety
|
|
67
|
+
- Do not force-push to main/master unless explicitly requested.
|
|
68
|
+
- Ask before destructive operations (reset --hard, drop database, etc.).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# Built-in OpenCode Zen free model (no API key). Prefer a zero-retention free id.
|
|
72
|
+
OPENCODE_FREE_MODEL = "opencode/deepseek-v4.1-flash-free"
|
|
73
|
+
|
|
74
|
+
OPENCODE_CONFIG_FREE = {
|
|
75
|
+
"$schema": "https://opencode.ai/config.json",
|
|
76
|
+
"model": OPENCODE_FREE_MODEL,
|
|
77
|
+
"instructions": ["AGENTS.md"],
|
|
78
|
+
"mcp": {},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
OPENCODE_CONFIG_WITH_API_KEY = {
|
|
82
|
+
"$schema": "https://opencode.ai/config.json",
|
|
83
|
+
"model": "anthropic/claude-sonnet-4-5",
|
|
84
|
+
"provider": {
|
|
85
|
+
"anthropic": {
|
|
86
|
+
"options": {
|
|
87
|
+
"apiKey": "{env:CODING_AGENT_API_KEY}",
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"instructions": ["AGENTS.md"],
|
|
92
|
+
"mcp": {},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Default scaffold when no coding_agent_api_key / OPENCODE_API_KEY is configured.
|
|
96
|
+
OPENCODE_CONFIG = OPENCODE_CONFIG_FREE
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def opencode_config(has_api_key: bool = False) -> Dict[str, Any]:
|
|
100
|
+
"""Return OpenCode config for free models or a keyed Anthropic provider."""
|
|
101
|
+
import copy
|
|
102
|
+
return copy.deepcopy(
|
|
103
|
+
OPENCODE_CONFIG_WITH_API_KEY if has_api_key else OPENCODE_CONFIG_FREE
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sync_opencode_config(
|
|
108
|
+
rig_dir: str,
|
|
109
|
+
has_api_key: bool = False,
|
|
110
|
+
force: bool = False,
|
|
111
|
+
) -> Optional[str]:
|
|
112
|
+
"""
|
|
113
|
+
Write ``.rig/.opencode/opencode.json`` for free or keyed usage.
|
|
114
|
+
|
|
115
|
+
When ``force`` is False, an existing file is left unchanged.
|
|
116
|
+
Returns the relative path created/updated, or None if skipped.
|
|
117
|
+
"""
|
|
118
|
+
cfg_dir = os.path.join(os.path.abspath(rig_dir), ".opencode")
|
|
119
|
+
config_path = os.path.join(cfg_dir, "opencode.json")
|
|
120
|
+
if not force and os.path.exists(config_path):
|
|
121
|
+
return None
|
|
122
|
+
_write_json(config_path, opencode_config(has_api_key=has_api_key))
|
|
123
|
+
return ".rig/.opencode/opencode.json"
|
|
124
|
+
|
|
125
|
+
OPENCODE_MCP: Dict[str, Any] = {
|
|
126
|
+
"mcp": {
|
|
127
|
+
# Example (disabled). Uncomment / extend as needed:
|
|
128
|
+
# "context7": {
|
|
129
|
+
# "type": "remote",
|
|
130
|
+
# "url": "https://mcp.context7.com/mcp",
|
|
131
|
+
# "enabled": false,
|
|
132
|
+
# "headers": {"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"},
|
|
133
|
+
# },
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
CLAUDE_SETTINGS = {
|
|
138
|
+
"permissions": {
|
|
139
|
+
"allow": [
|
|
140
|
+
"Bash(*)",
|
|
141
|
+
"Edit(*)",
|
|
142
|
+
"Read(*)",
|
|
143
|
+
"Write(*)",
|
|
144
|
+
"Glob(*)",
|
|
145
|
+
"Grep(*)",
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
# API key is injected via /etc/profile.d/rig-coding-agent.sh and
|
|
149
|
+
# ~/.claude/credentials.env (CODING_AGENT_API_KEY / ANTHROPIC_API_KEY).
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
CLAUDE_MCP: Dict[str, Any] = {
|
|
153
|
+
"mcpServers": {
|
|
154
|
+
# Example (disabled). Add servers as needed for Claude Code:
|
|
155
|
+
# "filesystem": {
|
|
156
|
+
# "command": "npx",
|
|
157
|
+
# "args": ["-y", "@modelcontextprotocol/server-filesystem", "/root"],
|
|
158
|
+
# },
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Cursor CLI (~/.cursor/cli-config.json). API key via CURSOR_API_KEY on the VM.
|
|
163
|
+
CURSOR_CLI_CONFIG: Dict[str, Any] = {
|
|
164
|
+
"permissions": {
|
|
165
|
+
"allow": [
|
|
166
|
+
"Shell(*)",
|
|
167
|
+
"Read(*)",
|
|
168
|
+
"Write(*)",
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
CURSOR_MCP: Dict[str, Any] = {
|
|
174
|
+
"mcpServers": {},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def normalize_agent_type(value: Optional[str]) -> str:
|
|
179
|
+
raw = (value or "").strip().lower().replace(" ", "-").replace("_", "-")
|
|
180
|
+
aliases = {
|
|
181
|
+
"opencode": "opencode",
|
|
182
|
+
"open-code": "opencode",
|
|
183
|
+
"claude": "claude",
|
|
184
|
+
"claude-code": "claude",
|
|
185
|
+
"anthropic": "claude",
|
|
186
|
+
"cursor": "cursor",
|
|
187
|
+
"cursor-cli": "cursor",
|
|
188
|
+
"cursor-agent": "cursor",
|
|
189
|
+
}
|
|
190
|
+
agent = aliases.get(raw, raw)
|
|
191
|
+
if agent not in SUPPORTED_AGENTS:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Unsupported coding agent {value!r}. "
|
|
194
|
+
f"Choose one of: {', '.join(SUPPORTED_AGENTS)}"
|
|
195
|
+
)
|
|
196
|
+
return agent
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def agent_config_dir_name(agent_type: str) -> str:
|
|
200
|
+
agent = normalize_agent_type(agent_type)
|
|
201
|
+
if agent == "opencode":
|
|
202
|
+
return ".opencode"
|
|
203
|
+
if agent == "cursor":
|
|
204
|
+
return ".cursor"
|
|
205
|
+
return ".claude"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def agent_remote_paths(agent_type: str) -> Dict[str, str]:
|
|
209
|
+
"""Where scaffolded files land on the remote VM (as root)."""
|
|
210
|
+
agent = normalize_agent_type(agent_type)
|
|
211
|
+
if agent == "opencode":
|
|
212
|
+
return {
|
|
213
|
+
"config_dir": "/root/.config/opencode",
|
|
214
|
+
"config_file": "/root/.config/opencode/opencode.json",
|
|
215
|
+
"mcp_file": "/root/.config/opencode/mcp.json",
|
|
216
|
+
"rules_file": "/root/.config/opencode/AGENTS.md",
|
|
217
|
+
"env_file": "/root/.config/opencode/credentials.env",
|
|
218
|
+
}
|
|
219
|
+
if agent == "cursor":
|
|
220
|
+
return {
|
|
221
|
+
"config_dir": "/root/.cursor",
|
|
222
|
+
"config_file": "/root/.cursor/cli-config.json",
|
|
223
|
+
"mcp_file": "/root/.cursor/mcp.json",
|
|
224
|
+
"rules_file": "/workspace/AGENTS.md",
|
|
225
|
+
"env_file": "/root/.cursor/credentials.env",
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
"config_dir": "/root/.claude",
|
|
229
|
+
"config_file": "/root/.claude/settings.json",
|
|
230
|
+
"mcp_file": "/root/.mcp.json",
|
|
231
|
+
"rules_file": "/root/.claude/CLAUDE.md",
|
|
232
|
+
"env_file": "/root/.claude/credentials.env",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _write_json(path: str, data: Dict[str, Any]) -> None:
|
|
237
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
238
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
239
|
+
json.dump(data, handle, indent=2)
|
|
240
|
+
handle.write("\n")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _write_text(path: str, content: str) -> None:
|
|
244
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
245
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
246
|
+
handle.write(content if content.endswith("\n") else content + "\n")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def ensure_agent_scaffold(
|
|
250
|
+
rig_dir: str,
|
|
251
|
+
agent_type: str = "opencode",
|
|
252
|
+
force: bool = False,
|
|
253
|
+
has_api_key: bool = False,
|
|
254
|
+
) -> List[str]:
|
|
255
|
+
"""
|
|
256
|
+
Create missing agent config / MCP / AGENTS.md under ``rig_dir``.
|
|
257
|
+
|
|
258
|
+
For OpenCode, defaults to free Zen models when ``has_api_key`` is False.
|
|
259
|
+
Existing files are left untouched unless ``force`` is True.
|
|
260
|
+
Returns the list of paths that were created (relative to rig_dir parent).
|
|
261
|
+
"""
|
|
262
|
+
agent = normalize_agent_type(agent_type)
|
|
263
|
+
created: List[str] = []
|
|
264
|
+
rig_dir = os.path.abspath(rig_dir)
|
|
265
|
+
|
|
266
|
+
agents_md = os.path.join(rig_dir, "AGENTS.md")
|
|
267
|
+
if force or not os.path.exists(agents_md):
|
|
268
|
+
_write_text(agents_md, DEFAULT_AGENTS_MD)
|
|
269
|
+
created.append(os.path.join(".rig", "AGENTS.md"))
|
|
270
|
+
|
|
271
|
+
if agent == "opencode":
|
|
272
|
+
written = sync_opencode_config(
|
|
273
|
+
rig_dir, has_api_key=has_api_key, force=force
|
|
274
|
+
)
|
|
275
|
+
if written:
|
|
276
|
+
created.append(written)
|
|
277
|
+
cfg_dir = os.path.join(rig_dir, ".opencode")
|
|
278
|
+
mcp_path = os.path.join(cfg_dir, "mcp.json")
|
|
279
|
+
if force or not os.path.exists(mcp_path):
|
|
280
|
+
_write_json(mcp_path, OPENCODE_MCP)
|
|
281
|
+
created.append(".rig/.opencode/mcp.json")
|
|
282
|
+
elif agent == "cursor":
|
|
283
|
+
cfg_dir = os.path.join(rig_dir, ".cursor")
|
|
284
|
+
config_path = os.path.join(cfg_dir, "cli-config.json")
|
|
285
|
+
mcp_path = os.path.join(cfg_dir, "mcp.json")
|
|
286
|
+
if force or not os.path.exists(config_path):
|
|
287
|
+
_write_json(config_path, CURSOR_CLI_CONFIG)
|
|
288
|
+
created.append(".rig/.cursor/cli-config.json")
|
|
289
|
+
if force or not os.path.exists(mcp_path):
|
|
290
|
+
_write_json(mcp_path, CURSOR_MCP)
|
|
291
|
+
created.append(".rig/.cursor/mcp.json")
|
|
292
|
+
else:
|
|
293
|
+
cfg_dir = os.path.join(rig_dir, ".claude")
|
|
294
|
+
config_path = os.path.join(cfg_dir, "settings.json")
|
|
295
|
+
mcp_path = os.path.join(cfg_dir, "mcp.json")
|
|
296
|
+
if force or not os.path.exists(config_path):
|
|
297
|
+
_write_json(config_path, CLAUDE_SETTINGS)
|
|
298
|
+
created.append(".rig/.claude/settings.json")
|
|
299
|
+
if force or not os.path.exists(mcp_path):
|
|
300
|
+
_write_json(mcp_path, CLAUDE_MCP)
|
|
301
|
+
created.append(".rig/.claude/mcp.json")
|
|
302
|
+
|
|
303
|
+
return created
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def prompt_coding_agent(
|
|
307
|
+
default: str = "opencode",
|
|
308
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
309
|
+
) -> str:
|
|
310
|
+
"""Ask which coding agent to use (single choice)."""
|
|
311
|
+
ask = input_fn or input
|
|
312
|
+
try:
|
|
313
|
+
default = normalize_agent_type(default)
|
|
314
|
+
except ValueError:
|
|
315
|
+
default = "opencode"
|
|
316
|
+
choices = ", ".join(f"{a} ({AGENT_LABELS[a]})" for a in SUPPORTED_AGENTS)
|
|
317
|
+
answer = ask(f"Coding agent [{choices}] [{default}]: ").strip()
|
|
318
|
+
if not answer:
|
|
319
|
+
return default
|
|
320
|
+
return normalize_agent_type(answer)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def coerce_agent_types(value: Any) -> List[str]:
|
|
324
|
+
"""
|
|
325
|
+
Normalize a string, comma-separated string, or list into agent ids.
|
|
326
|
+
|
|
327
|
+
Order is preserved and duplicates are dropped. Empty input returns [].
|
|
328
|
+
"""
|
|
329
|
+
if value is None:
|
|
330
|
+
return []
|
|
331
|
+
if isinstance(value, str):
|
|
332
|
+
text = value.strip()
|
|
333
|
+
if not text:
|
|
334
|
+
return []
|
|
335
|
+
parts = [part.strip() for part in text.replace(" ", ",").split(",") if part.strip()]
|
|
336
|
+
elif isinstance(value, (list, tuple)):
|
|
337
|
+
parts = []
|
|
338
|
+
for item in value:
|
|
339
|
+
if item is None:
|
|
340
|
+
continue
|
|
341
|
+
text = str(item).strip()
|
|
342
|
+
if text:
|
|
343
|
+
parts.append(text)
|
|
344
|
+
else:
|
|
345
|
+
text = str(value).strip()
|
|
346
|
+
parts = [text] if text else []
|
|
347
|
+
|
|
348
|
+
selected: List[str] = []
|
|
349
|
+
seen = set()
|
|
350
|
+
for part in parts:
|
|
351
|
+
agent = normalize_agent_type(part)
|
|
352
|
+
if agent not in seen:
|
|
353
|
+
selected.append(agent)
|
|
354
|
+
seen.add(agent)
|
|
355
|
+
return selected
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def agent_default_field(agent_cfg: Optional[Dict[str, Any]] = None) -> Any:
|
|
359
|
+
"""``agent.default``, or the legacy ``agent.type`` string."""
|
|
360
|
+
agent_cfg = agent_cfg or {}
|
|
361
|
+
raw = agent_cfg.get("default")
|
|
362
|
+
if raw not in (None, ""):
|
|
363
|
+
return raw
|
|
364
|
+
return agent_cfg.get("type")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def agent_enabled_field(agent_cfg: Optional[Dict[str, Any]] = None) -> Any:
|
|
368
|
+
"""``agent.enabled``, or the legacy ``agent.types`` list."""
|
|
369
|
+
agent_cfg = agent_cfg or {}
|
|
370
|
+
if agent_cfg.get("enabled"):
|
|
371
|
+
return agent_cfg.get("enabled")
|
|
372
|
+
return agent_cfg.get("types")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def resolve_configured_agents(
|
|
376
|
+
agent_cfg: Optional[Dict[str, Any]] = None,
|
|
377
|
+
*,
|
|
378
|
+
coding_agent_type: Any = None,
|
|
379
|
+
coding_agent_types: Any = None,
|
|
380
|
+
) -> Tuple[List[str], str]:
|
|
381
|
+
"""
|
|
382
|
+
Return ``(installed_agents, default_attach_agent)``.
|
|
383
|
+
|
|
384
|
+
``agent.enabled`` / ``coding_agent_types`` is the install set.
|
|
385
|
+
``agent.default`` is the attach default when it is a member of that set.
|
|
386
|
+
Legacy ``agent.types`` and ``agent.type`` are still accepted.
|
|
387
|
+
"""
|
|
388
|
+
agent_cfg = agent_cfg or {}
|
|
389
|
+
types: List[str] = []
|
|
390
|
+
for candidate in (agent_enabled_field(agent_cfg), coding_agent_types):
|
|
391
|
+
if not candidate:
|
|
392
|
+
continue
|
|
393
|
+
try:
|
|
394
|
+
types = coerce_agent_types(candidate)
|
|
395
|
+
except ValueError:
|
|
396
|
+
types = []
|
|
397
|
+
if types:
|
|
398
|
+
break
|
|
399
|
+
if not types:
|
|
400
|
+
raw_type = agent_default_field(agent_cfg)
|
|
401
|
+
if raw_type:
|
|
402
|
+
try:
|
|
403
|
+
types = coerce_agent_types(raw_type)
|
|
404
|
+
except ValueError:
|
|
405
|
+
types = []
|
|
406
|
+
if not types and coding_agent_type:
|
|
407
|
+
try:
|
|
408
|
+
types = coerce_agent_types(coding_agent_type)
|
|
409
|
+
except ValueError:
|
|
410
|
+
types = []
|
|
411
|
+
if not types:
|
|
412
|
+
types = ["opencode"]
|
|
413
|
+
|
|
414
|
+
primary: Optional[str] = None
|
|
415
|
+
raw_primary = agent_default_field(agent_cfg)
|
|
416
|
+
if isinstance(raw_primary, str) and raw_primary.strip():
|
|
417
|
+
try:
|
|
418
|
+
primary = normalize_agent_type(raw_primary)
|
|
419
|
+
except ValueError:
|
|
420
|
+
primary = None
|
|
421
|
+
if primary not in types:
|
|
422
|
+
primary = types[0]
|
|
423
|
+
return types, primary
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def apply_agent_selection(
|
|
427
|
+
agent_cfg: Dict[str, Any],
|
|
428
|
+
agent_types: Sequence[str],
|
|
429
|
+
default: Any = None,
|
|
430
|
+
) -> List[str]:
|
|
431
|
+
"""
|
|
432
|
+
Write ``default``, ``enabled``, and credential var names onto an agent config dict.
|
|
433
|
+
|
|
434
|
+
``default`` is the attach default. The requested name is used when that
|
|
435
|
+
agent was selected, otherwise the first selected agent.
|
|
436
|
+
A single agent keeps ``credentials_var: coding_agent_api_key``.
|
|
437
|
+
Several agents each get their own vault var under ``credentials``.
|
|
438
|
+
"""
|
|
439
|
+
selected = coerce_agent_types(agent_types) or ["opencode"]
|
|
440
|
+
primary: Optional[str] = None
|
|
441
|
+
if isinstance(default, str) and default.strip():
|
|
442
|
+
try:
|
|
443
|
+
primary = normalize_agent_type(default)
|
|
444
|
+
except ValueError:
|
|
445
|
+
primary = None
|
|
446
|
+
if primary not in selected:
|
|
447
|
+
primary = selected[0]
|
|
448
|
+
agent_cfg["default"] = primary
|
|
449
|
+
agent_cfg.pop("type", None)
|
|
450
|
+
agent_cfg["enabled"] = list(selected)
|
|
451
|
+
agent_cfg.pop("types", None)
|
|
452
|
+
if len(selected) == 1:
|
|
453
|
+
agent_cfg["credentials_var"] = LEGACY_CODING_AGENT_API_KEY
|
|
454
|
+
agent_cfg.pop("credentials", None)
|
|
455
|
+
else:
|
|
456
|
+
agent_cfg["credentials"] = {
|
|
457
|
+
agent: AGENT_API_KEY_VARS[agent] for agent in selected
|
|
458
|
+
}
|
|
459
|
+
agent_cfg["credentials_var"] = AGENT_API_KEY_VARS[primary]
|
|
460
|
+
return selected
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def parse_agent_selection(
|
|
464
|
+
answer: str,
|
|
465
|
+
*,
|
|
466
|
+
default: Sequence[str],
|
|
467
|
+
) -> List[str]:
|
|
468
|
+
"""Parse 'all', indexes, or names the same way git project selection works."""
|
|
469
|
+
try:
|
|
470
|
+
defaults = coerce_agent_types(list(default)) or ["opencode"]
|
|
471
|
+
except ValueError:
|
|
472
|
+
defaults = ["opencode"]
|
|
473
|
+
raw = (answer or "").strip()
|
|
474
|
+
if not raw:
|
|
475
|
+
return defaults
|
|
476
|
+
if raw.lower() in ("all", "*"):
|
|
477
|
+
return list(SUPPORTED_AGENTS)
|
|
478
|
+
|
|
479
|
+
selected: List[str] = []
|
|
480
|
+
seen = set()
|
|
481
|
+
tokens = [token.strip() for token in raw.replace(" ", ",").split(",") if token.strip()]
|
|
482
|
+
if not tokens:
|
|
483
|
+
return defaults
|
|
484
|
+
for token in tokens:
|
|
485
|
+
if token.isdigit():
|
|
486
|
+
index = int(token) - 1
|
|
487
|
+
if not 0 <= index < len(SUPPORTED_AGENTS):
|
|
488
|
+
raise ValueError(
|
|
489
|
+
f"Coding agent number {token} is out of range. "
|
|
490
|
+
f"Choose 1-{len(SUPPORTED_AGENTS)}, names, or all."
|
|
491
|
+
)
|
|
492
|
+
agent = SUPPORTED_AGENTS[index]
|
|
493
|
+
else:
|
|
494
|
+
agent = normalize_agent_type(token)
|
|
495
|
+
if agent not in seen:
|
|
496
|
+
selected.append(agent)
|
|
497
|
+
seen.add(agent)
|
|
498
|
+
return selected
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def prompt_coding_agents(
|
|
502
|
+
default: Any = "opencode",
|
|
503
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
504
|
+
) -> List[str]:
|
|
505
|
+
"""
|
|
506
|
+
Ask which coding agents to install.
|
|
507
|
+
|
|
508
|
+
Accepts numbers, names, or ``all``, like the git project picker.
|
|
509
|
+
Enter keeps ``default``.
|
|
510
|
+
"""
|
|
511
|
+
ask = input_fn or input
|
|
512
|
+
try:
|
|
513
|
+
defaults = coerce_agent_types(default) or ["opencode"]
|
|
514
|
+
except ValueError:
|
|
515
|
+
defaults = ["opencode"]
|
|
516
|
+
print("Coding agents:")
|
|
517
|
+
for index, agent in enumerate(SUPPORTED_AGENTS, start=1):
|
|
518
|
+
print(f" {index}. {agent} ({AGENT_LABELS[agent]})")
|
|
519
|
+
default_label = ",".join(defaults)
|
|
520
|
+
answer = ask(
|
|
521
|
+
"Coding agents to include (numbers, names, or all) "
|
|
522
|
+
f"[{default_label}]: "
|
|
523
|
+
)
|
|
524
|
+
return parse_agent_selection(answer, default=defaults)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def prompt_default_agent(
|
|
528
|
+
selected: Sequence[str],
|
|
529
|
+
default: Any = None,
|
|
530
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
531
|
+
) -> str:
|
|
532
|
+
"""
|
|
533
|
+
Ask which selected agent ``rig attach`` should open.
|
|
534
|
+
|
|
535
|
+
A single selected agent is returned without a prompt. Numbers match the
|
|
536
|
+
catalog shown by ``prompt_coding_agents`` (not a renumbered sub-list).
|
|
537
|
+
"""
|
|
538
|
+
agents = coerce_agent_types(list(selected)) or ["opencode"]
|
|
539
|
+
if len(agents) == 1:
|
|
540
|
+
return agents[0]
|
|
541
|
+
preferred = agents[0]
|
|
542
|
+
if isinstance(default, str) and default.strip():
|
|
543
|
+
try:
|
|
544
|
+
candidate = normalize_agent_type(default)
|
|
545
|
+
except ValueError:
|
|
546
|
+
candidate = None
|
|
547
|
+
if candidate in agents:
|
|
548
|
+
preferred = candidate
|
|
549
|
+
by_number = {
|
|
550
|
+
str(index): agent
|
|
551
|
+
for index, agent in enumerate(SUPPORTED_AGENTS, start=1)
|
|
552
|
+
if agent in agents
|
|
553
|
+
}
|
|
554
|
+
print("Default agent for rig attach:")
|
|
555
|
+
for number, agent in by_number.items():
|
|
556
|
+
print(f" {number}. {agent} ({AGENT_LABELS[agent]})")
|
|
557
|
+
ask = input_fn or input
|
|
558
|
+
answer = ask(f"Default agent [{preferred}]: ").strip()
|
|
559
|
+
if not answer:
|
|
560
|
+
return preferred
|
|
561
|
+
if answer.isdigit():
|
|
562
|
+
numbered = by_number.get(answer)
|
|
563
|
+
if not numbered:
|
|
564
|
+
raise ValueError(
|
|
565
|
+
f"Default agent number {answer} is not one of the selected agents "
|
|
566
|
+
f"({', '.join(agents)})"
|
|
567
|
+
)
|
|
568
|
+
return numbered
|
|
569
|
+
chosen = normalize_agent_type(answer)
|
|
570
|
+
if chosen not in agents:
|
|
571
|
+
raise ValueError(
|
|
572
|
+
f"Default agent {chosen!r} was not selected. "
|
|
573
|
+
f"Choose one of: {', '.join(agents)}"
|
|
574
|
+
)
|
|
575
|
+
return chosen
|