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/main.py ADDED
@@ -0,0 +1,1234 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main entry point for the remote development environment CLI
4
+ """
5
+
6
+ import argparse
7
+ import sys
8
+ import os
9
+ from typing import List, Optional
10
+
11
+ # Add the src directory to the path so we can import our modules
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + '/..')
13
+
14
+ from cli.storage import StorageManager
15
+ from cli.config import RigConfig
16
+ from cli.encrypt import interactive_decrypt, interactive_encrypt
17
+ from cli import DISTRIBUTION_NAME
18
+ from cli import log as log
19
+ from cli.last_env import clear_last_env, remember_last_env, resolve_env_name
20
+ from cli.last_session import (
21
+ agent_args_request_session,
22
+ clear_last_session,
23
+ read_last_session,
24
+ resume_args_for_agent,
25
+ )
26
+ from cli.remote_dev import (
27
+ IMPLEMENTED_CLOUD_PROVIDERS,
28
+ REMOTE_WORKSPACE_ROOT,
29
+ ProjectConfig,
30
+ RemoteDevEnvironment,
31
+ host_block,
32
+ interactive_env_init,
33
+ normalize_cloud_provider,
34
+ )
35
+
36
+
37
+ def _load_credentials() -> bool:
38
+ """Load ~/.rig/config into the environment when vars are unset."""
39
+ try:
40
+ RigConfig().apply_to_environ()
41
+ return True
42
+ except Exception as exc:
43
+ log.warning("Could not load ~/.rig/config: %s", exc)
44
+ return False
45
+
46
+ # Import package version. The two libraries export the same function name;
47
+ # bind them separately so the type checker does not see a redefinition.
48
+ try:
49
+ from importlib.metadata import version as package_version
50
+ __version__ = package_version(DISTRIBUTION_NAME)
51
+ except ImportError:
52
+ try:
53
+ from importlib_metadata import version as package_version_fallback
54
+ __version__ = package_version_fallback(DISTRIBUTION_NAME)
55
+ except ImportError:
56
+ __version__ = "0.1.0"
57
+
58
+
59
+ def _cmd_create(args: argparse.Namespace) -> int:
60
+ _load_credentials()
61
+
62
+ storage_manager = None
63
+ try:
64
+ storage_manager = StorageManager()
65
+ except ValueError:
66
+ log.warning("Storage manager not available; state will not be saved")
67
+
68
+ try:
69
+ project = ProjectConfig(os.getcwd())
70
+ project.load()
71
+ host_cfg = host_block(project.config or {})
72
+ if getattr(args, "provider", None):
73
+ host_cfg["provider"] = normalize_cloud_provider(args.provider)
74
+ provider = host_cfg.get("provider")
75
+ if provider not in IMPLEMENTED_CLOUD_PROVIDERS:
76
+ available = ", ".join(sorted(IMPLEMENTED_CLOUD_PROVIDERS))
77
+ log.error(
78
+ "Cloud provider '%s' is not available yet (supported now: %s)",
79
+ provider,
80
+ available,
81
+ )
82
+ return 1
83
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
84
+ if not hetzner_token:
85
+ log.error(
86
+ "Hetzner credentials missing. "
87
+ "Run 'rig init' or set HETZNER_CLOUD_API_TOKEN"
88
+ )
89
+ return 1
90
+ if getattr(args, "type", None):
91
+ host_cfg["server_type"] = args.type
92
+ if getattr(args, "availability_zone", None):
93
+ host_cfg["availability_zone"] = args.availability_zone
94
+ host_cfg.pop("region", None)
95
+ if getattr(args, "image", None):
96
+ host_cfg["image"] = args.image
97
+
98
+ env = RemoteDevEnvironment(
99
+ name=args.name,
100
+ config={"host": host_cfg},
101
+ storage_manager=storage_manager,
102
+ )
103
+ force_system = bool(
104
+ getattr(args, "force_system", False)
105
+ or getattr(args, "force_playbooks", False)
106
+ )
107
+ force_workspace = bool(
108
+ getattr(args, "force_workspace", False)
109
+ or getattr(args, "force_playbooks", False)
110
+ )
111
+ if not env.setup(project_dir=os.getcwd()):
112
+ return 1
113
+
114
+ # Provision via Ansible (system.yml then workspace.yml) with live output.
115
+ # Matching snapshots skip covered playbooks unless --force-* is set.
116
+ if not env.provision(
117
+ project_dir=os.getcwd(),
118
+ stream=True,
119
+ force_system=force_system,
120
+ force_workspace=force_workspace,
121
+ ):
122
+ return 1
123
+
124
+ log.info("Environment '%s' created", env.name)
125
+ remember_last_env(env.name)
126
+ if getattr(args, "no_attach", False):
127
+ log.info("Skipped agent attach; reconnect with: rig attach / rig dev")
128
+ log.debug("Reconnect: rig attach %s", env.name)
129
+ return 0
130
+
131
+ return env.attach_to_agent(project_dir=os.getcwd())
132
+ except Exception as e:
133
+ log.error("Error creating environment: %s", e)
134
+ return 1
135
+
136
+
137
+ def _merge_agent_args(
138
+ continue_: bool = False,
139
+ session=None,
140
+ resume=None,
141
+ remainder=None,
142
+ ) -> list:
143
+ """Merge convenience flags with remainder args for the remote agent."""
144
+ agent_args: list = []
145
+ if continue_:
146
+ agent_args.append("--continue")
147
+ if session:
148
+ agent_args.extend(["--session", session])
149
+ if resume is not None:
150
+ # ``--resume`` alone opens the picker; ``--resume ID`` picks a session.
151
+ agent_args.append("--resume")
152
+ if resume:
153
+ agent_args.append(resume)
154
+ extra = list(remainder or [])
155
+ if extra and extra[0] == "--":
156
+ extra = extra[1:]
157
+ agent_args.extend(extra)
158
+ return agent_args
159
+
160
+
161
+ def _attach_name_and_remainder(
162
+ args: argparse.Namespace,
163
+ remainder_attr: str = "agent_args",
164
+ ):
165
+ """
166
+ Split env name from trailing argv.
167
+
168
+ Argparse can put flag-like tokens into the optional ``name`` positional
169
+ when users write ``rig attach -- --continue``; move those into remainder.
170
+ """
171
+ name = getattr(args, "name", None)
172
+ remainder = list(getattr(args, remainder_attr, None) or [])
173
+ if name and str(name).startswith("-"):
174
+ remainder = [name] + remainder
175
+ name = None
176
+ if remainder and remainder[0] == "--":
177
+ remainder = remainder[1:]
178
+ return name, remainder
179
+
180
+
181
+ def _cmd_attach(args: argparse.Namespace) -> int:
182
+ _load_credentials()
183
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
184
+ if not hetzner_token:
185
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
186
+ return 1
187
+
188
+ positional_name, remainder = _attach_name_and_remainder(args)
189
+ name = resolve_env_name(
190
+ positional_name,
191
+ getattr(args, "name_flag", None),
192
+ )
193
+ if not name:
194
+ log.error("Environment name is required (no last environment remembered)")
195
+ log.info("Usage: rig attach [name] [-c|--continue] [-- …agent args]")
196
+ return 1
197
+
198
+ agent_args = _merge_agent_args(
199
+ continue_=getattr(args, "agent_continue", False),
200
+ session=getattr(args, "agent_session", None),
201
+ resume=getattr(args, "agent_resume", None),
202
+ remainder=remainder,
203
+ )
204
+
205
+ storage_manager = None
206
+ try:
207
+ storage_manager = StorageManager()
208
+ except ValueError:
209
+ log.debug("Storage manager not available; resolving resources via Hetzner API")
210
+
211
+ try:
212
+ env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
213
+ if not env.resolve_resources() or not env.vm_ip:
214
+ log.error("Could not resolve environment '%s' (no VM IP found)", name)
215
+ return 1
216
+
217
+ ssh_key_path = f"/tmp/{env.ssh_key_name}"
218
+ if not os.path.exists(ssh_key_path):
219
+ log.error("SSH private key not found at %s", ssh_key_path)
220
+ log.error("Re-run create on this machine, or restore the key before attaching")
221
+ return 1
222
+
223
+ project = ProjectConfig(os.getcwd())
224
+ project.load()
225
+ from cli.remote_dev import configured_agents
226
+ from cli.agents import normalize_agent_type
227
+
228
+ configured, default_agent = configured_agents(project)
229
+ requested = getattr(args, "agent", None)
230
+ if requested:
231
+ try:
232
+ agent_type = normalize_agent_type(requested)
233
+ except ValueError as exc:
234
+ log.error("%s", exc)
235
+ return 1
236
+ if agent_type not in configured:
237
+ log.error(
238
+ "Coding agent '%s' is not enabled for this workspace "
239
+ "(enabled: %s)",
240
+ agent_type,
241
+ ", ".join(configured),
242
+ )
243
+ return 1
244
+ else:
245
+ agent_type = default_agent
246
+
247
+ # Bare ``rig attach`` reopens the last remembered session when present.
248
+ # With several agents installed, resume the one that owns that session
249
+ # unless --agent picked a different one.
250
+ if not agent_args_request_session(agent_args):
251
+ stored = read_last_session(env.name)
252
+ if stored:
253
+ session_id, stored_agent = stored
254
+ resume_agent = stored_agent or agent_type
255
+ if (
256
+ not requested
257
+ and stored_agent
258
+ and stored_agent in configured
259
+ ):
260
+ agent_type = stored_agent
261
+ resume_agent = stored_agent
262
+ if requested and stored_agent and stored_agent != agent_type:
263
+ resume_agent = None
264
+ elif stored_agent and stored_agent != agent_type:
265
+ resume_agent = None
266
+ if resume_agent:
267
+ injected = resume_args_for_agent(resume_agent, session_id)
268
+ agent_args = injected + list(agent_args)
269
+ log.info(
270
+ "Resuming last %s session %s",
271
+ resume_agent,
272
+ session_id,
273
+ )
274
+ if len(configured) > 1:
275
+ log.info(
276
+ "Workspace coding agents: %s (attaching to %s)",
277
+ ", ".join(configured),
278
+ agent_type,
279
+ )
280
+
281
+ env.ensure_firewall_access(project_dir=os.getcwd())
282
+ remember_last_env(env.name)
283
+ if agent_args:
284
+ log.debug("Agent args: %s", " ".join(agent_args))
285
+ return env.attach_to_agent(
286
+ project_dir=os.getcwd(),
287
+ agent_type=str(agent_type),
288
+ agent_args=agent_args,
289
+ )
290
+ except Exception as e:
291
+ log.error("Error attaching to environment: %s", e)
292
+ return 1
293
+
294
+
295
+ def _cmd_ssh(args: argparse.Namespace) -> int:
296
+ """Open a shell on an existing environment, or run one remote command."""
297
+ _load_credentials()
298
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
299
+ if not hetzner_token:
300
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
301
+ return 1
302
+
303
+ tokens = list(getattr(args, "ssh_args", None) or [])
304
+ positional_name = None
305
+ remainder: list = []
306
+ if tokens and tokens[0] == "--":
307
+ remainder = tokens[1:]
308
+ elif tokens:
309
+ positional_name = tokens[0]
310
+ remainder = tokens[1:]
311
+ if remainder and remainder[0] == "--":
312
+ remainder = remainder[1:]
313
+ name = resolve_env_name(
314
+ positional_name,
315
+ getattr(args, "name_flag", None),
316
+ )
317
+ if not name:
318
+ log.error("Environment name is required (no last environment remembered)")
319
+ log.info("Usage: rig ssh [name] [-- command]")
320
+ return 1
321
+
322
+ storage_manager = None
323
+ try:
324
+ storage_manager = StorageManager()
325
+ except ValueError:
326
+ log.debug("Storage manager not available; resolving resources via Hetzner API")
327
+
328
+ try:
329
+ env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
330
+ if not env.resolve_resources() or not env.vm_ip:
331
+ log.error("Could not resolve environment '%s' (no VM IP found)", name)
332
+ return 1
333
+
334
+ ssh_key_path = f"/tmp/{env.ssh_key_name}"
335
+ if not os.path.exists(ssh_key_path):
336
+ log.error("SSH private key not found at %s", ssh_key_path)
337
+ log.error("Re-run create on this machine, or restore the key before connecting")
338
+ return 1
339
+
340
+ env.ensure_firewall_access(project_dir=os.getcwd())
341
+ remember_last_env(env.name)
342
+ import shlex
343
+ remote = shlex.join(remainder) if remainder else None
344
+ return env.open_ssh(remote_command=remote)
345
+ except Exception as e:
346
+ log.error("Error connecting to environment: %s", e)
347
+ return 1
348
+
349
+
350
+ def _cmd_apply(args: argparse.Namespace) -> int:
351
+ """Re-run lifecycle playbooks on an existing environment."""
352
+ _load_credentials()
353
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
354
+ if not hetzner_token:
355
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
356
+ return 1
357
+
358
+ name = resolve_env_name(
359
+ getattr(args, "name", None),
360
+ getattr(args, "name_flag", None),
361
+ )
362
+ if not name:
363
+ log.error("Environment name is required (no last environment remembered)")
364
+ log.info(
365
+ "Usage: rig apply [name] "
366
+ "[--provision|--configure|--authorize|--materialize]"
367
+ )
368
+ return 1
369
+
370
+ # Legacy flag aliases.
371
+ want_provision = getattr(args, "provision", False) or getattr(args, "system", False)
372
+ want_configure = getattr(args, "configure", False)
373
+ want_authorize = getattr(args, "authorize", False) or getattr(args, "authnz", False)
374
+ want_materialize = getattr(args, "materialize", False)
375
+
376
+ selected = []
377
+ if want_provision:
378
+ selected.extend(["provision", "configure", "authorize"])
379
+ if want_configure and "configure" not in selected:
380
+ selected.append("configure")
381
+ if want_authorize and not want_provision and not want_configure and not want_materialize:
382
+ selected = ["authorize"]
383
+ elif want_authorize and "authorize" not in selected:
384
+ selected.append("authorize")
385
+ if want_materialize:
386
+ if "authorize" not in selected and not want_provision:
387
+ selected.append("authorize")
388
+ if "materialize" not in selected:
389
+ selected.append("materialize")
390
+ if not selected:
391
+ # Default apply: re-authorize then materialize.
392
+ selected = ["authorize", "materialize"]
393
+
394
+ # Preserve lifecycle order.
395
+ order = ("provision", "configure", "authorize", "materialize")
396
+ playbooks = tuple(stage for stage in order if stage in selected)
397
+
398
+ storage_manager = None
399
+ try:
400
+ storage_manager = StorageManager()
401
+ except ValueError:
402
+ log.debug("Storage manager not available; resolving resources via Hetzner API")
403
+
404
+ try:
405
+ env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
406
+ if not env.resolve_resources() or not env.vm_ip:
407
+ log.error("Could not resolve environment '%s' (no VM IP found)", name)
408
+ return 1
409
+
410
+ ssh_key_path = f"/tmp/{env.ssh_key_name}"
411
+ if not os.path.exists(ssh_key_path):
412
+ log.error("SSH private key not found at %s", ssh_key_path)
413
+ log.error("Re-run create on this machine, or restore the key before applying")
414
+ return 1
415
+
416
+ env.ensure_firewall_access(project_dir=os.getcwd())
417
+ remember_last_env(env.name)
418
+ ok = env.apply_playbooks(
419
+ project_dir=os.getcwd(),
420
+ stream=True,
421
+ playbooks=playbooks,
422
+ soft_fail_materialize=False,
423
+ action="apply",
424
+ )
425
+ return 0 if ok else 1
426
+ except Exception as e:
427
+ log.error("Error applying playbooks: %s", e)
428
+ return 1
429
+
430
+
431
+ def _cmd_dev(args: argparse.Namespace) -> int:
432
+ _load_credentials()
433
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
434
+ if not hetzner_token:
435
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
436
+ return 1
437
+
438
+ name = resolve_env_name(
439
+ getattr(args, "name", None),
440
+ getattr(args, "name_flag", None),
441
+ )
442
+ if not name:
443
+ log.error("Environment name is required (no last environment remembered)")
444
+ log.info("Usage: rig dev [name] or rig dev --name <name>")
445
+ return 1
446
+
447
+ storage_manager = None
448
+ try:
449
+ storage_manager = StorageManager()
450
+ except ValueError:
451
+ log.debug("Storage manager not available; resolving resources via Hetzner API")
452
+
453
+ try:
454
+ env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
455
+ if not env.resolve_resources() or not env.vm_ip:
456
+ log.error("Could not resolve environment '%s' (no VM IP found)", name)
457
+ return 1
458
+
459
+ env.ensure_firewall_access(project_dir=os.getcwd())
460
+ remember_last_env(env.name)
461
+ from cli.remote_dev import configured_editors, normalize_editor_type
462
+
463
+ project = ProjectConfig(os.getcwd())
464
+ project.load()
465
+ enabled, default_editor = configured_editors(project)
466
+ requested = getattr(args, "editor", None)
467
+ editor: Optional[str] = None
468
+ editor_fallbacks: Optional[List[str]] = None
469
+ if requested:
470
+ try:
471
+ editor = normalize_editor_type(requested)
472
+ except ValueError as exc:
473
+ log.error("%s", exc)
474
+ return 1
475
+ if enabled and editor not in enabled:
476
+ log.error(
477
+ "Editor '%s' is not enabled for this workspace (enabled: %s)",
478
+ editor,
479
+ ", ".join(enabled),
480
+ )
481
+ return 1
482
+ editor_fallbacks = []
483
+ else:
484
+ editor = default_editor
485
+ editor_fallbacks = list(enabled) if enabled else None
486
+ if len(enabled) > 1 and editor:
487
+ log.info(
488
+ "Workspace editors: %s (opening %s)",
489
+ ", ".join(enabled),
490
+ editor,
491
+ )
492
+ return env.open_vscode(
493
+ remote_path=getattr(args, "path", None) or REMOTE_WORKSPACE_ROOT,
494
+ project_dir=os.getcwd(),
495
+ browser=bool(getattr(args, "browser", False)),
496
+ editor=editor,
497
+ editor_fallbacks=editor_fallbacks,
498
+ )
499
+ except Exception as e:
500
+ log.error("Error opening VS Code: %s", e)
501
+ return 1
502
+
503
+
504
+ def _cmd_list(_args: argparse.Namespace) -> int:
505
+ _load_credentials()
506
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
507
+ if not hetzner_token:
508
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
509
+ return 1
510
+
511
+ storage_manager = None
512
+ try:
513
+ storage_manager = StorageManager()
514
+ except ValueError:
515
+ pass
516
+
517
+ try:
518
+ env = RemoteDevEnvironment(storage_manager=storage_manager)
519
+ environments = env.list_environments()
520
+ if not environments:
521
+ log.info("No remote environments")
522
+ return 0
523
+ log.info("Remote environments:")
524
+ for env_info in environments:
525
+ log.info(" - %s (%s)", env_info.get("name"), env_info.get("status"))
526
+ return 0
527
+ except Exception as e:
528
+ log.error("Error listing environments: %s", e)
529
+ return 1
530
+
531
+
532
+ def _cmd_destroy(args: argparse.Namespace) -> int:
533
+ _load_credentials()
534
+ hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
535
+ if not hetzner_token:
536
+ log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
537
+ return 1
538
+
539
+ names: list = []
540
+ for value in (getattr(args, "names", None) or []):
541
+ if value and value not in names:
542
+ names.append(value)
543
+ # Back-compat: --name can still be passed (once or repeated).
544
+ for value in (getattr(args, "name_flags", None) or []):
545
+ if value and value not in names:
546
+ names.append(value)
547
+ if getattr(args, "name", None):
548
+ if args.name not in names:
549
+ names.append(args.name)
550
+
551
+ if not names:
552
+ last = resolve_env_name()
553
+ if last:
554
+ names.append(last)
555
+ log.info("Using last environment '%s'", last)
556
+ else:
557
+ log.error("At least one environment name is required (no last environment remembered)")
558
+ log.info("Usage: rig destroy [name ...] or rig destroy --name <name>")
559
+ return 1
560
+
561
+ storage_manager = None
562
+ try:
563
+ storage_manager = StorageManager()
564
+ except ValueError:
565
+ log.debug("Storage manager not available; resolving resources via Hetzner API")
566
+
567
+ failures = []
568
+ for name in names:
569
+ try:
570
+ env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
571
+ env.cleanup(project_dir=os.getcwd())
572
+ clear_last_env(name)
573
+ clear_last_session(name)
574
+ log.info("Environment '%s' destroyed", name)
575
+ except Exception as e:
576
+ log.error("Error destroying environment '%s': %s", name, e)
577
+ failures.append(name)
578
+
579
+ if failures:
580
+ log.error("Failed to destroy: %s", ", ".join(failures))
581
+ return 1
582
+ return 0
583
+
584
+
585
+ def _cache_scope_flags(args: argparse.Namespace) -> tuple:
586
+ """Return ``(include_system, include_workspace)`` from CLI flags."""
587
+ want_system = bool(getattr(args, "system", False))
588
+ want_workspace = bool(getattr(args, "workspace", False))
589
+ if want_system and not want_workspace:
590
+ return True, False
591
+ if want_workspace and not want_system:
592
+ return False, True
593
+ # Neither or both → both kinds.
594
+ return True, True
595
+
596
+
597
+ def _cache_select_snapshots(
598
+ images,
599
+ args: argparse.Namespace,
600
+ ) -> tuple:
601
+ """
602
+ Build ``(keep, targets, label)`` for cache list/clear.
603
+
604
+ Without ``--all``: targets are unused (current omitted into keep).
605
+ With ``--all``: targets include current; keep is empty.
606
+ ``--system`` / ``--workspace`` narrow the kind(s).
607
+ """
608
+ from cli.snapshots import (
609
+ describe_snapshot,
610
+ find_unused_snapshots,
611
+ package_version,
612
+ system_content_hash,
613
+ system_hash_inputs_from_project,
614
+ workspace_content_hash,
615
+ )
616
+
617
+ include_system, include_workspace = _cache_scope_flags(args)
618
+ clear_all = bool(getattr(args, "all", False))
619
+
620
+ scope_bits = []
621
+ if include_system:
622
+ scope_bits.append("system")
623
+ if include_workspace:
624
+ scope_bits.append("workspace")
625
+ scope = "+".join(scope_bits) or "snapshot"
626
+
627
+ if clear_all:
628
+ targets = []
629
+ for image in images:
630
+ if image.get("type") and image.get("type") != "snapshot":
631
+ continue
632
+ meta = describe_snapshot(image)
633
+ kind = meta.get("kind")
634
+ if kind == "system" and include_system:
635
+ meta["reason"] = "all"
636
+ targets.append(meta)
637
+ elif kind == "workspace" and include_workspace:
638
+ # --all --workspace: every workspace snapshot (any project).
639
+ # Narrow to current project name when rig.yml is available.
640
+ meta["reason"] = "all"
641
+ targets.append(meta)
642
+ # Optionally restrict workspace --all to the current project when possible.
643
+ if include_workspace and not include_system:
644
+ project_dir = os.getcwd()
645
+ project = ProjectConfig(project_dir)
646
+ if project.load() and project.config:
647
+ ws_name = project.config.get("name") or os.path.basename(
648
+ project_dir.rstrip(os.sep)
649
+ )
650
+ from cli.snapshots import sanitize_snapshot_token
651
+
652
+ cur = sanitize_snapshot_token(str(ws_name), max_len=40)
653
+ targets = [
654
+ m
655
+ for m in targets
656
+ if m.get("kind") != "workspace" or m.get("workspace") == cur
657
+ ]
658
+ label = f"{scope} snapshot(s)"
659
+ return [], targets, label
660
+
661
+ project_dir = os.getcwd()
662
+ project = ProjectConfig(project_dir)
663
+ if not project.load() or not project.config:
664
+ raise RuntimeError(
665
+ f"No rig.yml in {project_dir}; cannot determine current snapshots "
666
+ "(use --all to include every matching snapshot)"
667
+ )
668
+
669
+ workspace_name = project.config.get("name") or os.path.basename(
670
+ project_dir.rstrip(os.sep)
671
+ )
672
+ workspace_hash = workspace_content_hash(project_dir, project=project)
673
+ version = package_version()
674
+ sys_inputs = system_hash_inputs_from_project(project)
675
+ system_hash = system_content_hash(
676
+ project=project,
677
+ var_map=sys_inputs.get("var_map"),
678
+ project_dir=project_dir,
679
+ )
680
+
681
+ split = find_unused_snapshots(
682
+ images,
683
+ current_version=version,
684
+ current_system_hash=system_hash,
685
+ current_workspace_name=str(workspace_name),
686
+ current_workspace_hash=workspace_hash,
687
+ include_system=include_system,
688
+ include_workspace=include_workspace,
689
+ )
690
+ label = f"unused {scope} snapshot(s)"
691
+ return split["keep"], split["unused"], label
692
+
693
+
694
+ def _cmd_cache(args: argparse.Namespace) -> int:
695
+ """List or clear Hetzner snapshots (unused by default; --all includes current)."""
696
+ action = getattr(args, "action", None) or "clear"
697
+ if action not in ("clear", "list"):
698
+ log.error("Unknown cache action %r (supported: clear, list)", action)
699
+ return 1
700
+
701
+ _load_credentials()
702
+ hetzner_token = os.getenv("HETZNER_CLOUD_API_TOKEN")
703
+ if not hetzner_token:
704
+ log.error(
705
+ "Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN"
706
+ )
707
+ return 1
708
+
709
+ assume_yes = bool(getattr(args, "yes", False))
710
+
711
+ try:
712
+ env = RemoteDevEnvironment(storage_manager=None)
713
+ images = env.manager.list_snapshots()
714
+ except Exception as exc:
715
+ log.error("Failed to list snapshots: %s", exc)
716
+ return 1
717
+
718
+ try:
719
+ keep, targets, label = _cache_select_snapshots(images, args)
720
+ except RuntimeError as exc:
721
+ log.error("%s", exc)
722
+ return 1
723
+
724
+ if keep:
725
+ log.info("Current (kept) snapshot(s):")
726
+ for meta in keep:
727
+ log.info(
728
+ " %s (id=%s)",
729
+ meta.get("description"),
730
+ meta.get("id"),
731
+ )
732
+
733
+ if not targets and not keep:
734
+ log.info("No matching snapshots")
735
+ return 0
736
+
737
+ if targets:
738
+ heading = "All" if getattr(args, "all", False) else "Unused"
739
+ log.info("%s (%s):", heading, len(targets))
740
+ for meta in targets:
741
+ reason = meta.get("reason")
742
+ if reason and reason != "all":
743
+ log.info(
744
+ " %s (id=%s) — %s",
745
+ meta.get("description"),
746
+ meta.get("id"),
747
+ reason,
748
+ )
749
+ else:
750
+ log.info(
751
+ " %s (id=%s)",
752
+ meta.get("description"),
753
+ meta.get("id"),
754
+ )
755
+ elif action == "list":
756
+ log.info("No unused snapshots")
757
+ return 0
758
+
759
+ if action == "list":
760
+ return 0
761
+
762
+ if not targets:
763
+ log.info("No %s to clear", label)
764
+ return 0
765
+
766
+ if not assume_yes:
767
+ try:
768
+ answer = input(
769
+ f"Delete {len(targets)} {label}? [y/N]: "
770
+ ).strip().lower()
771
+ except EOFError:
772
+ answer = ""
773
+ if answer not in ("y", "yes"):
774
+ log.info("Cancelled")
775
+ return 0
776
+
777
+ failures = []
778
+ deleted = 0
779
+ for meta in targets:
780
+ image_id = meta.get("id")
781
+ description = meta.get("description") or image_id
782
+ if image_id is None:
783
+ continue
784
+ log.info("Deleting snapshot %s (id=%s)", description, image_id)
785
+ if not env.manager.delete_image(str(image_id)):
786
+ failures.append(str(description))
787
+ else:
788
+ deleted += 1
789
+
790
+ if failures:
791
+ log.error("Failed to delete: %s", ", ".join(failures))
792
+ return 1
793
+ log.info("Cleared %s %s", deleted, label)
794
+ return 0
795
+
796
+
797
+ def _cmd_encrypt(args: argparse.Namespace) -> int:
798
+ try:
799
+ personal = bool(getattr(args, "personal", False))
800
+ ciphertext = interactive_encrypt(
801
+ stdin_name=args.stdin_name,
802
+ personal=personal if personal else None,
803
+ )
804
+ except (ValueError, RuntimeError) as exc:
805
+ log.error("%s", exc)
806
+ return 1
807
+ if ciphertext:
808
+ print(ciphertext)
809
+ return 0
810
+
811
+
812
+ def _cmd_decrypt(args: argparse.Namespace) -> int:
813
+ try:
814
+ plaintext = interactive_decrypt(name=args.name)
815
+ except (ValueError, RuntimeError) as exc:
816
+ log.error("%s", exc)
817
+ return 1
818
+ print(plaintext, end="" if plaintext.endswith("\n") else "\n")
819
+ return 0
820
+
821
+
822
+ def _cmd_vars_list(args: argparse.Namespace) -> int:
823
+ from cli.encrypt import list_all_vault_vars
824
+
825
+ rows = list_all_vault_vars()
826
+ if not rows:
827
+ print("No vaulted variables found")
828
+ return 0
829
+ for name, scope in rows:
830
+ print(f"{name}\t{scope}")
831
+ return 0
832
+
833
+
834
+ def _cmd_vars_export_shared_key(args: argparse.Namespace) -> int:
835
+ from cli.encrypt import export_shared_data_key
836
+
837
+ try:
838
+ key = export_shared_data_key()
839
+ except (ValueError, RuntimeError) as exc:
840
+ log.error("%s", exc)
841
+ return 1
842
+ print(key)
843
+ return 0
844
+
845
+
846
+ def main(argv=None):
847
+ """Main CLI entry point"""
848
+ parser = argparse.ArgumentParser(
849
+ description="RIG - Remote Development Environment CLI"
850
+ )
851
+ parser.add_argument("--version", action="version", version=f"rig {__version__}")
852
+ parser.add_argument(
853
+ "-v",
854
+ "--verbose",
855
+ action="count",
856
+ default=0,
857
+ help=(
858
+ "More detail. -v shows each playbook task and debug logs; "
859
+ "-vv, -vvv, and -vvvv pass that verbosity to ansible-playbook"
860
+ ),
861
+ )
862
+ parser.add_argument(
863
+ "-q",
864
+ "--quiet",
865
+ action="store_true",
866
+ help="Show warnings and errors only",
867
+ )
868
+
869
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
870
+
871
+ subparsers.add_parser(
872
+ "init",
873
+ help="Interactively initialize a multi-repo environment from nearby git projects",
874
+ )
875
+
876
+ create_parser = subparsers.add_parser(
877
+ "create",
878
+ help="Create a remote environment, run Ansible playbooks, attach to agent",
879
+ )
880
+ create_parser.add_argument("--name", help="Environment name")
881
+ create_parser.add_argument(
882
+ "--provider",
883
+ default=None,
884
+ help=(
885
+ "Cloud provider (default: from rig.yml host.provider, else hetzner). "
886
+ "hetzner is available; aws and gcp are recognized but not yet"
887
+ ),
888
+ )
889
+ create_parser.add_argument(
890
+ "--type",
891
+ default=None,
892
+ help="Hetzner server type (default: from rig.yml host.server_type, else cax11)",
893
+ )
894
+ create_parser.add_argument(
895
+ "--availability-zone",
896
+ "--region",
897
+ dest="availability_zone",
898
+ default=None,
899
+ help="Hetzner location / AZ (default: from rig.yml host.availability_zone, else hel1)",
900
+ )
901
+ create_parser.add_argument(
902
+ "--image",
903
+ default=None,
904
+ help="Boot image (default: from rig.yml host.image, else ubuntu-24.04)",
905
+ )
906
+ create_parser.add_argument(
907
+ "--no-attach",
908
+ action="store_true",
909
+ help="Skip attaching to the coding agent TTY after provisioning",
910
+ )
911
+ create_parser.add_argument(
912
+ "--force-playbooks",
913
+ action="store_true",
914
+ help=(
915
+ "Run provision/configure/materialize even when booting from a "
916
+ "matching snapshot (authorize always runs)"
917
+ ),
918
+ )
919
+ create_parser.add_argument(
920
+ "--force-system",
921
+ action="store_true",
922
+ help="Force provision+configure even when booting from a system snapshot",
923
+ )
924
+ create_parser.add_argument(
925
+ "--force-workspace",
926
+ action="store_true",
927
+ help="Force materialize even when it would otherwise be skipped",
928
+ )
929
+
930
+ apply_parser = subparsers.add_parser(
931
+ "apply",
932
+ help=(
933
+ "Re-run lifecycle playbooks on an existing env "
934
+ "(default: authorize then materialize)"
935
+ ),
936
+ )
937
+ apply_parser.add_argument(
938
+ "name",
939
+ nargs="?",
940
+ help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
941
+ )
942
+ apply_parser.add_argument(
943
+ "--name",
944
+ dest="name_flag",
945
+ help="Environment name (kept for compatibility)",
946
+ )
947
+ apply_parser.add_argument(
948
+ "--provision",
949
+ action="store_true",
950
+ help="Run provision → configure → authorize",
951
+ )
952
+ apply_parser.add_argument(
953
+ "--configure",
954
+ action="store_true",
955
+ help="Run configure.yml",
956
+ )
957
+ apply_parser.add_argument(
958
+ "--authorize",
959
+ action="store_true",
960
+ help="Run authorize.yml only (re-apply credentials)",
961
+ )
962
+ apply_parser.add_argument(
963
+ "--materialize",
964
+ action="store_true",
965
+ help="Run authorize then materialize (or materialize with other flags)",
966
+ )
967
+ # Legacy aliases
968
+ apply_parser.add_argument(
969
+ "--system",
970
+ action="store_true",
971
+ help=argparse.SUPPRESS,
972
+ )
973
+ apply_parser.add_argument(
974
+ "--authnz",
975
+ action="store_true",
976
+ help=argparse.SUPPRESS,
977
+ )
978
+
979
+ attach_parser = subparsers.add_parser(
980
+ "attach",
981
+ help=(
982
+ "Attach to a remote coding-agent TTY (uses last env if name omitted; "
983
+ "reopens the last remembered agent session when no --resume/--session/-c)"
984
+ ),
985
+ )
986
+ attach_parser.add_argument(
987
+ "name",
988
+ nargs="?",
989
+ help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
990
+ )
991
+ attach_parser.add_argument(
992
+ "--name",
993
+ dest="name_flag",
994
+ help="Environment name (kept for compatibility)",
995
+ )
996
+ attach_parser.add_argument(
997
+ "-c",
998
+ "--continue",
999
+ dest="agent_continue",
1000
+ action="store_true",
1001
+ help="Continue the last coding-agent session (forwards --continue)",
1002
+ )
1003
+ attach_parser.add_argument(
1004
+ "-s",
1005
+ "--session",
1006
+ dest="agent_session",
1007
+ metavar="ID",
1008
+ help="Continue a specific OpenCode session id (forwards --session)",
1009
+ )
1010
+ attach_parser.add_argument(
1011
+ "--resume",
1012
+ dest="agent_resume",
1013
+ nargs="?",
1014
+ const="",
1015
+ metavar="ID",
1016
+ help="Resume a Claude Code / Cursor session (forwards --resume [ID])",
1017
+ )
1018
+ attach_parser.add_argument(
1019
+ "--agent",
1020
+ dest="agent",
1021
+ help=(
1022
+ "Coding agent to open when several are configured "
1023
+ "(opencode, claude, or cursor). Defaults to agent.default"
1024
+ ),
1025
+ )
1026
+ attach_parser.add_argument(
1027
+ "agent_args",
1028
+ nargs=argparse.REMAINDER,
1029
+ help="Extra args forwarded to opencode/claude/agent (put -- before flags)",
1030
+ )
1031
+
1032
+ ssh_parser = subparsers.add_parser(
1033
+ "ssh",
1034
+ help="Open an SSH shell on the remote env (uses last env if name omitted)",
1035
+ )
1036
+ ssh_parser.add_argument(
1037
+ "--name",
1038
+ dest="name_flag",
1039
+ help="Environment name (kept for compatibility)",
1040
+ )
1041
+ ssh_parser.add_argument(
1042
+ "ssh_args",
1043
+ nargs=argparse.REMAINDER,
1044
+ help="[name] [-- command]. Default: login shell in /workspace",
1045
+ )
1046
+
1047
+ dev_parser = subparsers.add_parser(
1048
+ "dev",
1049
+ help="Open VS Code / Cursor on the remote env (uses last env if name omitted)",
1050
+ description=(
1051
+ "Open VS Code or Cursor against the remote environment via Remote-SSH "
1052
+ "(default). Pass --browser to open code-server in a browser instead."
1053
+ ),
1054
+ )
1055
+ dev_parser.add_argument(
1056
+ "name",
1057
+ nargs="?",
1058
+ help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
1059
+ )
1060
+ dev_parser.add_argument(
1061
+ "--name",
1062
+ dest="name_flag",
1063
+ help="Environment name (kept for compatibility)",
1064
+ )
1065
+ dev_parser.add_argument(
1066
+ "--path",
1067
+ default=REMOTE_WORKSPACE_ROOT,
1068
+ help=f"Remote folder to open (default: {REMOTE_WORKSPACE_ROOT})",
1069
+ )
1070
+ dev_parser.add_argument(
1071
+ "--browser",
1072
+ action="store_true",
1073
+ help="Open code-server in the browser instead of local VS Code/Cursor",
1074
+ )
1075
+ dev_parser.add_argument(
1076
+ "--editor",
1077
+ help=(
1078
+ "Local editor to open when several are configured "
1079
+ "(code, code-insiders, cursor, or codium). "
1080
+ "Defaults to editor.default in rig.yml"
1081
+ ),
1082
+ )
1083
+
1084
+ subparsers.add_parser("list", help="List remote environments")
1085
+
1086
+ cache_parser = subparsers.add_parser(
1087
+ "cache",
1088
+ help="Manage Hetzner snapshots created after the configure (build) stage",
1089
+ description=(
1090
+ "List or clear VM snapshots after showing them. "
1091
+ "Default clear: unused only (older version or different hash); "
1092
+ "current snapshots are kept. "
1093
+ "--all includes current. "
1094
+ "--system / --workspace narrow the kind "
1095
+ "(combine with or without --all)."
1096
+ ),
1097
+ )
1098
+ cache_parser.add_argument(
1099
+ "--system",
1100
+ action="store_true",
1101
+ help="Only system snapshots (rig-{version}-{hash})",
1102
+ )
1103
+ cache_parser.add_argument(
1104
+ "--workspace",
1105
+ action="store_true",
1106
+ help="Only workspace snapshots (workspace-{name}-{hash}) for this project",
1107
+ )
1108
+ cache_parser.add_argument(
1109
+ "--all",
1110
+ action="store_true",
1111
+ help="Include current snapshots (default omits them / unused only)",
1112
+ )
1113
+ cache_parser.add_argument(
1114
+ "-y",
1115
+ "--yes",
1116
+ action="store_true",
1117
+ help="Delete without confirmation (clear only)",
1118
+ )
1119
+ cache_parser.add_argument(
1120
+ "action",
1121
+ nargs="?",
1122
+ default="clear",
1123
+ choices=["clear", "list"],
1124
+ help="list = show only; clear = list, confirm, delete (default: clear)",
1125
+ )
1126
+
1127
+ destroy_parser = subparsers.add_parser(
1128
+ "destroy",
1129
+ help=(
1130
+ "Destroy remote environments (runs finalize then destroy playbooks first; "
1131
+ "uses last env if name omitted)"
1132
+ ),
1133
+ )
1134
+ destroy_parser.add_argument(
1135
+ "names",
1136
+ nargs="*",
1137
+ metavar="name",
1138
+ help="Environment name(s) to destroy (defaults to last handled env in /tmp/.rig/last-env)",
1139
+ )
1140
+ destroy_parser.add_argument(
1141
+ "--name",
1142
+ action="append",
1143
+ dest="name_flags",
1144
+ help="Environment name (repeatable; kept for compatibility)",
1145
+ )
1146
+
1147
+ vars_parser = subparsers.add_parser(
1148
+ "vars",
1149
+ help="Manage vaulted values in .rig/vars.yml and vars.{identity}.yml",
1150
+ )
1151
+ vars_subparsers = vars_parser.add_subparsers(
1152
+ dest="vars_command",
1153
+ help="Vars actions",
1154
+ )
1155
+
1156
+ encrypt_parser = vars_subparsers.add_parser(
1157
+ "encrypt",
1158
+ help=(
1159
+ "Encrypt a value (shared vars.yml, or identity file with --personal / PAT names)"
1160
+ ),
1161
+ )
1162
+ encrypt_parser.add_argument(
1163
+ "--stdin-name",
1164
+ help="Variable name; read multiline value from stdin until Ctrl+D",
1165
+ )
1166
+ encrypt_parser.add_argument(
1167
+ "--personal",
1168
+ action="store_true",
1169
+ help="Store in .rig/vars.{identity}.yml (personal vault)",
1170
+ )
1171
+
1172
+ decrypt_parser = vars_subparsers.add_parser(
1173
+ "decrypt",
1174
+ help="Decrypt a variable from shared or personal vars (after master-key unlock)",
1175
+ )
1176
+ decrypt_parser.add_argument(
1177
+ "name",
1178
+ nargs="?",
1179
+ help="Variable name to decrypt (prompted if omitted)",
1180
+ )
1181
+
1182
+ vars_subparsers.add_parser(
1183
+ "list",
1184
+ help="List vaulted variable names and whether they are shared or identity",
1185
+ )
1186
+ vars_subparsers.add_parser(
1187
+ "export-shared-key",
1188
+ help="Print the shared data key (for teammate onboarding; requires unlock)",
1189
+ )
1190
+
1191
+ args = parser.parse_args(argv)
1192
+ verbosity = int(getattr(args, "verbose", 0) or 0)
1193
+ log.configure_logging(
1194
+ verbose=verbosity > 0,
1195
+ quiet=bool(getattr(args, "quiet", False)),
1196
+ verbosity=verbosity,
1197
+ )
1198
+
1199
+ if args.command == "init":
1200
+ return 0 if interactive_env_init() else 1
1201
+ if args.command == "create":
1202
+ return _cmd_create(args)
1203
+ if args.command == "apply":
1204
+ return _cmd_apply(args)
1205
+ if args.command == "attach":
1206
+ return _cmd_attach(args)
1207
+ if args.command == "ssh":
1208
+ return _cmd_ssh(args)
1209
+ if args.command == "dev":
1210
+ return _cmd_dev(args)
1211
+ if args.command == "list":
1212
+ return _cmd_list(args)
1213
+ if args.command == "cache":
1214
+ return _cmd_cache(args)
1215
+ if args.command == "destroy":
1216
+ return _cmd_destroy(args)
1217
+ if args.command == "vars":
1218
+ if args.vars_command == "encrypt":
1219
+ return _cmd_encrypt(args)
1220
+ if args.vars_command == "decrypt":
1221
+ return _cmd_decrypt(args)
1222
+ if args.vars_command == "list":
1223
+ return _cmd_vars_list(args)
1224
+ if args.vars_command == "export-shared-key":
1225
+ return _cmd_vars_export_shared_key(args)
1226
+ vars_parser.print_help()
1227
+ return 1
1228
+
1229
+ parser.print_help()
1230
+ return 1
1231
+
1232
+
1233
+ if __name__ == "__main__":
1234
+ raise SystemExit(main())