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 ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Local CLI for remote development environments
3
+ """
4
+
5
+ # PyPI project name. The console script stays ``rig``; ``rig`` itself is taken.
6
+ DISTRIBUTION_NAME = "mystack-rig"
cli/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ CLI entry point
3
+ """
4
+
5
+ from .main import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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