wraith-cli 1.4.0__tar.gz → 1.6.0__tar.gz

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 (42) hide show
  1. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/CHANGELOG.md +4 -0
  2. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/PKG-INFO +3 -1
  3. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/docs/usage.md +47 -10
  4. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/mkdocs.yml +0 -1
  5. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/pyproject.toml +3 -1
  6. wraith_cli-1.6.0/src/wraith_cli/config.py +134 -0
  7. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/main.py +194 -58
  8. wraith_cli-1.6.0/src/wraith_cli/providers.py +74 -0
  9. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/qol.py +10 -4
  10. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/shield.py +13 -36
  11. wraith_cli-1.6.0/tests/conftest.py +39 -0
  12. wraith_cli-1.6.0/tests/test_cli.py +484 -0
  13. wraith_cli-1.6.0/tests/test_config.py +117 -0
  14. wraith_cli-1.6.0/tests/test_providers.py +126 -0
  15. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/tests/test_repo_make.py +8 -2
  16. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/tests/test_shield.py +59 -70
  17. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/uv.lock +135 -1
  18. wraith_cli-1.4.0/tests/test_cli.py +0 -301
  19. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.cz.yaml +0 -0
  20. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.env.example +0 -0
  21. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitea/CODEOWNERS.md +0 -0
  22. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitea/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitea/workflows/pages.yml +0 -0
  24. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitea/workflows/release.yml +0 -0
  25. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitea/workflows/test.yml +0 -0
  26. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.gitignore +0 -0
  27. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.pre-commit-config.yaml +0 -0
  28. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/.secrets.baseline +0 -0
  29. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/CONTRIBUTING.md +0 -0
  30. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/LICENSE +0 -0
  31. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/README.md +0 -0
  32. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/bin/build.sh +0 -0
  33. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/bin/run_tests.sh +0 -0
  34. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/bin/setup_venv.sh +0 -0
  35. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/docs/architecture.md +0 -0
  36. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/docs/index.md +0 -0
  37. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/docs/reference.md +0 -0
  38. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/docs/security.md +0 -0
  39. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/__init__.py +0 -0
  40. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/assets.py +0 -0
  41. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/src/wraith_cli/repo_make.py +0 -0
  42. {wraith_cli-1.4.0 → wraith_cli-1.6.0}/tests/test_qol.py +0 -0
@@ -1,3 +1,7 @@
1
+ ## v1.6.0 (2026-04-07)
2
+
3
+ ## v1.5.0 (2026-04-06)
4
+
1
5
  ## v1.4.0 (2026-04-05)
2
6
 
3
7
  ## v1.3.0 (2026-04-04)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wraith-cli
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: Sovereign Command Centre for a Ghost Stack
5
5
  Project-URL: Homepage, https://git.thomaspeoples.com/thomaspeoples/wraith-cli
6
6
  Project-URL: Documentation, https://www.thomaspeoples.com/gitea-repos/wraith-cli/
@@ -32,7 +32,9 @@ Classifier: Programming Language :: Python :: 3
32
32
  Classifier: Programming Language :: Python :: 3.12
33
33
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
34
34
  Requires-Python: >=3.12
35
+ Requires-Dist: pydantic-settings>=2.0.0
35
36
  Requires-Dist: python-dotenv
37
+ Requires-Dist: pyyaml>=6.0.1
36
38
  Requires-Dist: requests
37
39
  Requires-Dist: rich>=13.0.0
38
40
  Requires-Dist: typer>=0.9.0
@@ -17,22 +17,41 @@ $ [OPTIONS] COMMAND [ARGS]...
17
17
 
18
18
  **Commands**:
19
19
 
20
- * `status`: Check the heartbeat of the OpenViking Stack.
20
+ * `init`: Bootstrap a new Wraith environment.
21
+ * `status`: Check the heartbeat of the Ghost Stack.
22
+ * `health`: Visualise health of all configured services.
21
23
  * `update`: Sync Wraith with the latest PyPI release.
22
24
  * `ps`: List running containers with Sovereign...
23
25
  * `tail`: Stream live logs from a specific service.
24
26
  * `runner-reset`: Repair hung or offline Gitea Action Runners.
25
- * `spawn`: 🏭 Scaffold a new repository instantly.
27
+ * `spawn`: Scaffold a new repository instantly.
26
28
  * `logs`: Unified hardware health and logging portal.
27
29
  * `mesh`: Map the Tailscale Ghost Mesh network.
28
30
  * `audit`: Execute a Sovereign security and...
29
31
 
32
+ ## `init`
33
+
34
+ Bootstrap a new Wraith environment.
35
+
36
+ Interactive wizard to configure the stack name, API URLs,
37
+ and auto-probe for Docker and Tailscale presence.
38
+
39
+ **Usage**:
40
+
41
+ ```console
42
+ $ init [OPTIONS]
43
+ ```
44
+
45
+ **Options**:
46
+
47
+ * `--help`: Show this message and exit.
48
+
30
49
  ## `status`
31
50
 
32
- Check the heartbeat of the OpenViking Stack.
51
+ Check the heartbeat of the Ghost Stack.
33
52
 
34
- Polls the configured VIKING_BASE_URL health
35
- nendpoint to verify if the orchestrator API is responsive.
53
+ Polls the configured WRAITH_VIKING_API health
54
+ endpoint to verify if the orchestrator API is responsive.
36
55
 
37
56
  **Usage**:
38
57
 
@@ -44,6 +63,23 @@ $ status [OPTIONS]
44
63
 
45
64
  * `--help`: Show this message and exit.
46
65
 
66
+ ## `health`
67
+
68
+ Visualise health of all configured services.
69
+
70
+ Polls the health_url for each service defined in .wraith.yaml.
71
+ Automatically parses OpenAPI/Swagger specs to find /health endpoints.
72
+
73
+ **Usage**:
74
+
75
+ ```console
76
+ $ health [OPTIONS]
77
+ ```
78
+
79
+ **Options**:
80
+
81
+ * `--help`: Show this message and exit.
82
+
47
83
  ## `update`
48
84
 
49
85
  Sync Wraith with the latest PyPI release.
@@ -91,8 +127,9 @@ environment variables, or local directory discovery.
91
127
 
92
128
  Priority:
93
129
  1. Passed flag --path
94
- 2. Env Var WRAITH_COMPOSE_PATH
95
- 3. Current Directory
130
+ 2. Configured compose_path
131
+ 3. Discovered via search_roots
132
+ 4. Current Directory
96
133
 
97
134
  **Usage**:
98
135
 
@@ -106,7 +143,7 @@ $ tail [OPTIONS] SERVICE
106
143
 
107
144
  **Options**:
108
145
 
109
- * `-p, --path PATH`: Path to directory with docker-compose.yml. [env var: WRAITH_COMPOSE_PATH]
146
+ * `-p, --path PATH`: Path to directory with docker-compose.yml.
110
147
  * `--help`: Show this message and exit.
111
148
 
112
149
  ## `runner-reset`
@@ -117,7 +154,7 @@ Performs a nuclear reset: stops the container,
117
154
  purges the local registration data, and restarts
118
155
  the service to force a fresh handshake with Gitea.
119
156
 
120
- Requires GITEA_COMPOSE_PATH as an envelope variable
157
+ Requires GITEA_COMPOSE_PATH as an environment variable
121
158
 
122
159
  **Usage**:
123
160
 
@@ -131,7 +168,7 @@ $ runner-reset [OPTIONS]
131
168
 
132
169
  ## `spawn`
133
170
 
134
- 🏭 Scaffold a new repository instantly.
171
+ Scaffold a new repository instantly.
135
172
 
136
173
  Atomic scaffolding for new Ghost Stack repositories.
137
174
  Clones a template, bleaches git history,
@@ -35,4 +35,3 @@ extra:
35
35
  - icon: material/home
36
36
  link: https://www.thomaspeoples.com/
37
37
  name: Back to Main Site
38
-
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wraith-cli"
7
- version = "1.4.0"
7
+ version = "1.6.0"
8
8
  description = "Sovereign Command Centre for a Ghost Stack"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -27,6 +27,8 @@ dependencies = [
27
27
  "python-dotenv",
28
28
  "rich>=13.0.0",
29
29
  "typer>=0.9.0",
30
+ "pydantic-settings>=2.0.0",
31
+ "pyyaml>=6.0.1",
30
32
  ]
31
33
 
32
34
  [project.urls]
@@ -0,0 +1,134 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import yaml
6
+ from pydantic import AliasChoices, BaseModel, Field, HttpUrl
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class ServiceModel(BaseModel):
11
+ path: Path
12
+ metadata: dict[str, Any] = Field(default_factory=dict)
13
+ health_url: HttpUrl | None = None
14
+
15
+
16
+ class TemplateModel(BaseModel):
17
+ url: str
18
+
19
+
20
+ class WraithSettings(BaseSettings):
21
+ stack_name: str = "Ghost Stack"
22
+ viking_api: HttpUrl | None = None
23
+ search_roots: list[Path] = Field(default_factory=list)
24
+ services: dict[str, ServiceModel] = Field(default_factory=dict)
25
+ docker_enabled: bool = False
26
+ tailscale_enabled: bool = False
27
+
28
+ # Provider Settings
29
+ forge_api_url: str | None = None
30
+ forge_token: str | None = None
31
+ forge_type: str = "gitea"
32
+ templates: dict[str, TemplateModel] = Field(default_factory=dict)
33
+
34
+ # Legacy .env mappings
35
+ gitea_api_url: str | None = Field(
36
+ default=None,
37
+ validation_alias=AliasChoices("WRAITH_GITEA_API_URL", "GITEA_API_URL"),
38
+ )
39
+ gitea_token: str | None = Field(
40
+ default=None,
41
+ validation_alias=AliasChoices("WRAITH_GITEA_TOKEN", "GITEA_TOKEN"),
42
+ )
43
+ gitea_template_url: str | None = Field(
44
+ default=None,
45
+ validation_alias=AliasChoices(
46
+ "WRAITH_GITEA_TEMPLATE_URL", "GITEA_TEMPLATE_URL"
47
+ ),
48
+ )
49
+ gitea_compose_path: Path | None = Field(
50
+ default=None,
51
+ validation_alias=AliasChoices(
52
+ "WRAITH_GITEA_COMPOSE_PATH", "GITEA_COMPOSE_PATH"
53
+ ),
54
+ )
55
+ compose_path: Path | None = Field(
56
+ default=None,
57
+ validation_alias=AliasChoices("WRAITH_COMPOSE_PATH", "COMPOSE_PATH"),
58
+ )
59
+
60
+ # Ingests .env variables as fallback, prioritizing WRAITH_ prefix
61
+ model_config = SettingsConfigDict(
62
+ env_prefix="WRAITH_",
63
+ env_file=".env",
64
+ env_file_encoding="utf-8",
65
+ extra="ignore",
66
+ populate_by_name=True,
67
+ )
68
+
69
+
70
+ @lru_cache
71
+ def get_settings() -> WraithSettings:
72
+ """
73
+ Load settings with priority:
74
+ 1. Local .wraith.yaml (CWD)
75
+ 2. User ~/.config/wraith/config.yaml
76
+ 3. Environment variables (WRAITH_ prefix & .env fallback)
77
+ """
78
+ yaml_data: dict[str, Any] = {}
79
+
80
+ # 2. User-level config
81
+ user_config = Path.home() / ".config" / "wraith" / "config.yaml"
82
+ if user_config.exists(): # pragma: no cover
83
+ with open(user_config) as f:
84
+ parsed = yaml.safe_load(f)
85
+ if isinstance(parsed, dict):
86
+ yaml_data.update(parsed)
87
+
88
+ # 1. Local config (overrides user-level)
89
+ local_config = Path.cwd() / ".wraith.yaml"
90
+ if local_config.exists(): # pragma: no cover
91
+ with open(local_config) as f:
92
+ parsed = yaml.safe_load(f)
93
+ if isinstance(parsed, dict):
94
+ yaml_data.update(parsed)
95
+
96
+ # Pydantic BaseSettings kwargs override environment variables.
97
+ return WraithSettings(**yaml_data)
98
+
99
+
100
+ def discover_service(
101
+ service_name: str, search_roots: list[Path]
102
+ ) -> Path | None: # pragma: no cover
103
+ """
104
+ Recursively search for docker-compose.yml files in search_roots.
105
+ Matches if the directory name or container_name equals the service_name.
106
+ """
107
+ for root in search_roots:
108
+ if not root.exists() or not root.is_dir():
109
+ continue
110
+
111
+ for compose_file in root.rglob("docker-compose.yml"):
112
+ # Match by directory name
113
+ if compose_file.parent.name == service_name:
114
+ return compose_file
115
+
116
+ # Match by container_name or service key in yaml
117
+ try:
118
+ with open(compose_file) as f:
119
+ compose_data = yaml.safe_load(f)
120
+ if compose_data and isinstance(compose_data, dict):
121
+ services = compose_data.get("services", {})
122
+ for svc_key, svc_def in services.items():
123
+ if svc_key == service_name:
124
+ return compose_file
125
+ if (
126
+ isinstance(svc_def, dict)
127
+ and svc_def.get("container_name")
128
+ == service_name
129
+ ):
130
+ return compose_file
131
+ except Exception:
132
+ pass # Ignore unparsable files during discovery
133
+
134
+ return None
@@ -1,18 +1,20 @@
1
- import os
2
1
  import subprocess
3
2
  from importlib.metadata import version as get_local_version
4
3
  from pathlib import Path
5
4
  from typing import Annotated
5
+ from urllib.parse import urljoin
6
6
 
7
7
  import requests
8
8
  import typer
9
- from dotenv import load_dotenv
9
+ import yaml
10
10
  from rich.align import Align
11
11
  from rich.markdown import Markdown
12
12
  from rich.panel import Panel
13
+ from rich.prompt import Confirm, Prompt
14
+ from rich.table import Table
13
15
  from rich.text import Text
14
16
 
15
- from wraith_cli import assets, qol, repo_make, shield
17
+ from wraith_cli import assets, config, qol, repo_make, shield
16
18
 
17
19
  SOVEREIGN_ADVISORY = """
18
20
  ## Sovereign Security Advisory – Please Read
@@ -29,23 +31,13 @@ Recommended baseline:
29
31
  - Audit environment variable scope (`GITEA_COMPOSE_PATH`, etc.)
30
32
  """
31
33
 
32
- # Load environment variables from .env
33
- load_dotenv()
34
-
35
34
  # Initialise Typer App
36
35
  app = typer.Typer(
37
36
  help="Wraith Sovereign CLI: Ghost Stack Orchestrator",
38
37
  rich_markup_mode="rich",
39
38
  )
40
39
 
41
- # --- Configuration ---
42
- VIKING_URL = os.getenv(
43
- "VIKING_BASE_URL", "http://127.0.0.1:1933/api/v1"
44
- ).rstrip("/")
45
- GITEA_PATH = os.getenv("GITEA_COMPOSE_PATH")
46
- GITEA_API_URL = os.getenv("GITEA_API_URL")
47
- GITEA_TOKEN = os.getenv("GITEA_TOKEN")
48
- GITEA_TEMPLATE_URL = os.getenv("GITEA_TEMPLATE_URL")
40
+ SETTINGS = config.get_settings()
49
41
 
50
42
 
51
43
  # --- Callbacks ---
@@ -63,7 +55,7 @@ def version_callback(value: bool):
63
55
 
64
56
 
65
57
  def print_wraith_welcome(): # pragma: no cover
66
- """Renders the definitive OpenClaw-inspired Sovereign splash."""
58
+ """Renders the definitive Sovereign splash."""
67
59
  console = qol.console
68
60
  local_v = get_local_version("wraith-cli")
69
61
 
@@ -121,26 +113,145 @@ def main(
121
113
  # --- Commands ---
122
114
 
123
115
 
116
+ @app.command()
117
+ def init(): # pragma: no cover
118
+ """
119
+ Bootstrap a new Wraith environment.
120
+
121
+ Interactive wizard to configure the stack name, API URLs,
122
+ and auto-probe for Docker and Tailscale presence.
123
+ """
124
+ typer.echo("Initialising Wraith Sovereign Environment...")
125
+ stack_name = Prompt.ask("Stack Name", default="Ghost Stack")
126
+ viking_api = Prompt.ask("Ghost Stack API URL (optional)", default="")
127
+
128
+ if viking_api:
129
+ try:
130
+ typer.echo(f"🔍 Validating API: {viking_api}")
131
+ resp = requests.head(viking_api, timeout=3)
132
+ resp.raise_for_status()
133
+ except Exception as e:
134
+ typer.secho(f"⚠️ API Validation failed: {e}", fg="yellow")
135
+ if not Confirm.ask("Proceed anyway?", default=True):
136
+ raise typer.Exit(1) from e
137
+
138
+ typer.echo("🔍 Probing environment...")
139
+ docker_res = subprocess.run(
140
+ ["docker", "info"], capture_output=True, check=False
141
+ )
142
+ docker_enabled = docker_res.returncode == 0
143
+ d_msg = "✅ Found" if docker_enabled else "❌ Not Found"
144
+ typer.echo(f" └── Docker: {d_msg}")
145
+
146
+ tailscale_res = subprocess.run(
147
+ ["tailscale", "status"], capture_output=True, check=False
148
+ )
149
+ tailscale_enabled = tailscale_res.returncode == 0
150
+ ts_msg = "✅ Found" if tailscale_enabled else "❌ Not Found"
151
+ typer.echo(f" └── Tailscale: {ts_msg}")
152
+
153
+ yaml_data = {
154
+ "stack_name": stack_name,
155
+ "docker_enabled": docker_enabled,
156
+ "tailscale_enabled": tailscale_enabled,
157
+ }
158
+ if viking_api:
159
+ yaml_data["viking_api"] = viking_api
160
+
161
+ out_path = Path.cwd() / ".wraith.yaml"
162
+ with open(out_path, "w") as f:
163
+ yaml.dump(yaml_data, f, sort_keys=False)
164
+
165
+ typer.secho(f"✨ Configuration saved to {out_path}", fg="green", bold=True)
166
+
167
+
124
168
  @app.command()
125
169
  def status():
126
170
  """
127
- Check the heartbeat of the OpenViking Stack.
171
+ Check the heartbeat of the Ghost Stack.
128
172
 
129
- Polls the configured VIKING_BASE_URL health
130
- nendpoint to verify if the orchestrator API is responsive.
173
+ Polls the configured WRAITH_VIKING_API health
174
+ endpoint to verify if the orchestrator API is responsive.
131
175
  """
132
- url = f"{VIKING_URL}/health"
176
+ settings = config.get_settings()
177
+ viking_url = (
178
+ str(settings.viking_api).rstrip("/")
179
+ if settings.viking_api
180
+ else "http://127.0.0.1:1933/api/v1"
181
+ )
182
+ url = f"{viking_url}/health"
133
183
  try:
134
184
  response = requests.get(url, timeout=3)
135
185
  if response.status_code == 200:
136
- typer.secho("🟢 OpenViking: Online", fg=typer.colors.GREEN)
186
+ typer.secho("🟢 Ghost Stack: Online", fg=typer.colors.GREEN)
137
187
  else:
138
188
  typer.secho(
139
- f"🟡 OpenViking: Warning ({response.status_code})",
189
+ f"🟡 Ghost Stack: Warning ({response.status_code})",
140
190
  fg=typer.colors.YELLOW,
141
191
  )
142
192
  except Exception:
143
- typer.secho("🔴 OpenViking: Offline", fg=typer.colors.RED)
193
+ typer.secho("🔴 Ghost Stack: Offline", fg=typer.colors.RED)
194
+
195
+
196
+ @app.command()
197
+ def health(): # pragma: no cover
198
+ """
199
+ Visualise health of all configured services.
200
+
201
+ Polls the health_url for each service defined in .wraith.yaml.
202
+ Automatically parses OpenAPI/Swagger specs to find /health endpoints.
203
+ """
204
+ table = Table(title=f"{SETTINGS.stack_name} - Service Health")
205
+ table.add_column("Service", style="cyan")
206
+ table.add_column("Status", style="bold")
207
+ table.add_column("Details")
208
+
209
+ if not SETTINGS.services:
210
+ typer.secho("No services configured in .wraith.yaml", fg="yellow")
211
+ return
212
+
213
+ for name, svc in SETTINGS.services.items():
214
+ if not svc.health_url:
215
+ table.add_row(
216
+ name, "[yellow]Unknown[/yellow]", "No health_url configured"
217
+ )
218
+ continue
219
+
220
+ url = str(svc.health_url)
221
+ try:
222
+ resp = requests.get(url, timeout=5)
223
+
224
+ # OpenAPI Schema Intelligence
225
+ if "application/json" in resp.headers.get("Content-Type", ""):
226
+ data = resp.json()
227
+ is_spec = isinstance(data, dict) and (
228
+ "openapi" in data or "swagger" in data
229
+ )
230
+ if is_spec:
231
+ paths = data.get("paths", {})
232
+ health_endpoint = None
233
+ for ep in ["/health", "/status"]:
234
+ if ep in paths:
235
+ health_endpoint = ep
236
+ break
237
+
238
+ if health_endpoint:
239
+ actual_url = urljoin(url, health_endpoint)
240
+ resp = requests.get(actual_url, timeout=5)
241
+ url = actual_url
242
+
243
+ if resp.status_code == 200:
244
+ table.add_row(
245
+ name, "[green]Online[/green]", f"HTTP 200 ({url})"
246
+ )
247
+ else:
248
+ table.add_row(
249
+ name, f"[yellow]Warning ({resp.status_code})[/yellow]", url
250
+ )
251
+ except Exception as e:
252
+ table.add_row(name, "[red]Offline[/red]", str(e))
253
+
254
+ qol.console.print(table)
144
255
 
145
256
 
146
257
  @app.command()
@@ -167,7 +278,7 @@ def update():
167
278
  typer.secho(f"❌ Update check failed: {e}", fg="red")
168
279
 
169
280
 
170
- @app.command()
281
+ @app.command(hidden=not SETTINGS.docker_enabled)
171
282
  def ps(
172
283
  all: Annotated[
173
284
  bool, typer.Option("--all", "-a", help="Show all containers")
@@ -180,19 +291,16 @@ def ps(
180
291
  readable table summarising service names,
181
292
  container status, and source images.
182
293
  """
183
- cmd = ["docker", "ps"]
184
- if all:
185
- cmd.append("-a")
186
-
187
- # Custom format for our Rich table conversion
188
- cmd.extend(["--format", "table {{.Names}}\t{{.Status}}\t{{.Image}}"])
294
+ from wraith_cli.providers import get_docker_provider
189
295
 
190
- result = subprocess.run(cmd, capture_output=True, text=True)
191
- if result.returncode != 0:
192
- typer.secho("❌ Docker is not running or accessible.", fg="red")
296
+ docker = get_docker_provider()
297
+ try:
298
+ stdout = docker.ps(all=all)
299
+ except RuntimeError as e:
300
+ typer.secho(f"❌ {e}", fg="red")
193
301
  return
194
302
 
195
- rich_table = qol.format_docker_table(result.stdout)
303
+ rich_table = qol.format_docker_table(stdout)
196
304
  qol.console.print(rich_table)
197
305
 
198
306
 
@@ -206,7 +314,6 @@ def tail(
206
314
  typer.Option(
207
315
  "--path",
208
316
  "-p",
209
- envvar="WRAITH_COMPOSE_PATH",
210
317
  help="Path to directory with docker-compose.yml.",
211
318
  ),
212
319
  ] = None,
@@ -220,10 +327,19 @@ def tail(
220
327
 
221
328
  Priority:
222
329
  1. Passed flag --path
223
- 2. Env Var WRAITH_COMPOSE_PATH
224
- 3. Current Directory
330
+ 2. Configured compose_path
331
+ 3. Discovered via search_roots
332
+ 4. Current Directory
225
333
  """
226
- target_path = path or Path.cwd()
334
+ settings = config.get_settings()
335
+ target_path = path or settings.compose_path
336
+
337
+ if not target_path and settings.search_roots:
338
+ discovered = config.discover_service(service, settings.search_roots)
339
+ if discovered:
340
+ target_path = discovered.parent
341
+
342
+ target_path = target_path or Path.cwd()
227
343
 
228
344
  typer.echo(f"🔍 Searching for {service} in {target_path}...")
229
345
  success = qol.run_tail(service, target_path)
@@ -244,13 +360,22 @@ def runner_reset():
244
360
  purges the local registration data, and restarts
245
361
  the service to force a fresh handshake with Gitea.
246
362
 
247
- Requires GITEA_COMPOSE_PATH as an envelope variable
363
+ Requires GITEA_COMPOSE_PATH as an environment variable
248
364
  """
249
- if not GITEA_PATH:
250
- typer.secho("❌ Error: GITEA_COMPOSE_PATH not set in .env", fg="red")
365
+ settings = config.get_settings()
366
+ compose_path = settings.gitea_compose_path
367
+
368
+ if not compose_path and settings.search_roots:
369
+ discovered = config.discover_service(
370
+ "act_runner", settings.search_roots
371
+ )
372
+ if discovered:
373
+ compose_path = discovered.parent
374
+
375
+ if not compose_path:
376
+ typer.secho("❌ Error: GITEA_COMPOSE_PATH not set in config", fg="red")
251
377
  raise typer.Exit(1)
252
378
 
253
- compose_path = Path(GITEA_PATH)
254
379
  docker_file = compose_path / "docker-compose.yml"
255
380
  runner_data = compose_path / "data/act_runner/.runner"
256
381
 
@@ -277,43 +402,54 @@ def spawn(
277
402
  ),
278
403
  ):
279
404
  """
280
- 🏭 Scaffold a new repository instantly.
405
+ Scaffold a new repository instantly.
281
406
 
282
407
  Atomic scaffolding for new Ghost Stack repositories.
283
408
  Clones a template, bleaches git history,
284
409
  provisions a remote repository via API, and
285
410
  pushes the initial commit in one sequence.
286
411
  """
287
- if not all([GITEA_API_URL, GITEA_TOKEN, GITEA_TEMPLATE_URL]):
412
+ settings = config.get_settings()
413
+ if not all([
414
+ settings.forge_api_url,
415
+ settings.forge_token,
416
+ settings.templates,
417
+ ]):
288
418
  typer.secho(
289
- """❌ Missing Environment Variables!
290
- Check .env for
291
- GITEA_API_URL,
292
- GITEA_TOKEN,
293
- and GITEA_TEMPLATE_URL.
419
+ """❌ Missing Configuration!
420
+ Check config for
421
+ forge_api_url,
422
+ forge_token,
423
+ and templates.
294
424
  """,
295
425
  fg="red",
296
426
  )
297
427
  raise typer.Exit(1)
298
428
 
299
- assert GITEA_API_URL is not None
300
- assert GITEA_TOKEN is not None
301
- assert GITEA_TEMPLATE_URL is not None
429
+ assert settings.forge_api_url is not None
430
+ assert settings.forge_token is not None
302
431
 
303
432
  target_dir = Path.cwd() / repo_name
304
433
  typer.echo(f"👻 Spawning '{repo_name}' from Repo Factory...")
305
434
 
306
435
  try:
307
436
  typer.echo(" └── 🧬 Cloning template and bleaching history...")
308
- repo_make.clone_and_bleach(GITEA_TEMPLATE_URL, target_dir, GITEA_TOKEN)
309
-
310
- typer.echo(" └── 🌐 Provisioning repository on Gitea...")
311
- remote_url = repo_make.create_gitea_repo(
312
- repo_name, GITEA_API_URL, GITEA_TOKEN
437
+ # Use default template or fallback to first available
438
+ template = settings.templates.get(
439
+ "default", next(iter(settings.templates.values()))
313
440
  )
441
+ repo_make.clone_and_bleach(
442
+ template.url, target_dir, settings.forge_token
443
+ )
444
+
445
+ typer.echo(" └── 🌐 Provisioning repository on Forge...")
446
+ from wraith_cli.providers import get_forge_provider
447
+
448
+ forge = get_forge_provider(settings)
449
+ remote_url = forge.create_repo(repo_name)
314
450
 
315
451
  typer.echo(f" └── 🚀 Initialising and pushing to {remote_url}...")
316
- repo_make.init_and_push(target_dir, remote_url, GITEA_TOKEN)
452
+ repo_make.init_and_push(target_dir, remote_url, settings.forge_token)
317
453
 
318
454
  typer.secho(
319
455
  f"\n✅ Success! Gitea Repo '{repo_name}' is alive.",
@@ -356,7 +492,7 @@ def logs(
356
492
  )
357
493
 
358
494
 
359
- @app.command()
495
+ @app.command(hidden=not SETTINGS.tailscale_enabled)
360
496
  def mesh():
361
497
  """
362
498
  Map the Tailscale Ghost Mesh network.