arc-kernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arc/cli.py ADDED
@@ -0,0 +1,841 @@
1
+ """
2
+ arc.cli
3
+ --------------
4
+ The `arc` command. Subcommands implemented here:
5
+
6
+ arc init [project_name] [--env dev|staging|prod]
7
+ arc build [-p/--plugin NAME] [--no-lock]
8
+ arc settings get <key> [--reveal]
9
+ arc settings set <key> <value> [--secret]
10
+ arc settings delete <key>
11
+ arc plugin enable <name>
12
+ arc plugin disable <name>
13
+ arc plugin list
14
+ arc doctor [--json]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import os
21
+ import re
22
+ import secrets as stdlib_secrets
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import warnings
27
+ from pathlib import Path
28
+ from urllib.parse import urlparse
29
+
30
+ import tomlkit
31
+ import typer
32
+ from rich.console import Console
33
+ from rich.table import Table
34
+
35
+ from . import registry
36
+ from .doctor import doctor as _doctor_command
37
+ from .healthcmd import health as _health_command
38
+ from .plugin_cli import mount_plugin_clis
39
+ from .settings import REDACTED, SettingsError, SettingsManager
40
+
41
+ app = typer.Typer(name="arc", help="ARC kernel CLI", no_args_is_help=True)
42
+ settings_app = typer.Typer(help="Get, set, or delete a setting.", no_args_is_help=True)
43
+ plugin_app = typer.Typer(help="Enable, disable, or list plugins.", no_args_is_help=True)
44
+ app.add_typer(settings_app, name="settings")
45
+ app.add_typer(plugin_app, name="plugin")
46
+ app.command(name="doctor")(_doctor_command)
47
+ app.command(name="health")(_health_command)
48
+ mount_plugin_clis(app)
49
+
50
+ console = Console()
51
+ err_console = Console(stderr=True, style="bold red")
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # Shared helpers
56
+ # --------------------------------------------------------------------------- #
57
+ def find_project_root(start: Path | None = None) -> Path:
58
+ """Walk upward from `start` (default: cwd) looking for a .arc/arc.toml."""
59
+ current = (start or Path.cwd()).resolve()
60
+ for candidate in [current, *current.parents]:
61
+ if (candidate / ".arc" / "arc.toml").exists():
62
+ return candidate
63
+ err_console.print(
64
+ "Not an ARC project (no .arc/arc.toml found in this directory or any parent). "
65
+ "Run `arc init` first."
66
+ )
67
+ raise typer.Exit(code=1)
68
+
69
+
70
+ def run(cmd: list[str], cwd: Path) -> None:
71
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
72
+ result = subprocess.run(cmd, cwd=cwd)
73
+ if result.returncode != 0:
74
+ err_console.print(f"Command failed: {' '.join(cmd)}")
75
+ raise typer.Exit(code=result.returncode)
76
+
77
+
78
+ # --------------------------------------------------------------------------- #
79
+ # arc init
80
+ # --------------------------------------------------------------------------- #
81
+ @app.command()
82
+ def init(
83
+ project_name: str = typer.Argument(
84
+ None, help="Directory to create. Defaults to the current directory."
85
+ ),
86
+ env: str = typer.Option("dev", "--env", help="Default ARC_ENV for this project."),
87
+ kernel_repo: str = typer.Option(
88
+ os.environ.get("ARC_KERNEL_REPO", ""),
89
+ "--kernel-repo",
90
+ help="Git URL (or local path) to clone the kernel from into arc/. "
91
+ "Can also be set via the ARC_KERNEL_REPO env var.",
92
+ ),
93
+ kernel_branch: str = typer.Option("main", "--kernel-branch", help="Branch/tag to clone."),
94
+ ) -> None:
95
+ """
96
+ Scaffold a new ARC instance: arc/ (kernel, git-cloned), plugins/, .arc/,
97
+ config/, logs/, backups/ — and wire it all up as a single uv workspace.
98
+ """
99
+ root = Path(project_name).resolve() if project_name else Path.cwd()
100
+ root.mkdir(parents=True, exist_ok=True)
101
+
102
+ arc_dir = root / ".arc"
103
+ (arc_dir / "runtime").mkdir(parents=True, exist_ok=True)
104
+
105
+ for d in ["plugins", "config", "logs", "backups/db", "backups/files"]:
106
+ (root / d).mkdir(parents=True, exist_ok=True)
107
+ for keep in ["logs", "backups/db", "backups/files"]:
108
+ (root / keep / ".gitkeep").touch(exist_ok=True)
109
+
110
+ # --- kernel source: clone arc/ if it doesn't already exist -------------
111
+ kernel_dir = root / "arc"
112
+ if kernel_dir.exists():
113
+ console.print(f"[yellow]{kernel_dir} already exists — leaving untouched.[/yellow]")
114
+ elif kernel_repo:
115
+ run(["git", "clone", "--branch", kernel_branch, kernel_repo, str(kernel_dir)], cwd=root)
116
+ else:
117
+ err_console.print(
118
+ "No --kernel-repo given and ARC_KERNEL_REPO is not set. "
119
+ "The kernel source has nothing to clone from.\n"
120
+ "Pass --kernel-repo <git-url-or-path>, or set ARC_KERNEL_REPO once "
121
+ "in your shell profile so every `arc init` picks it up automatically."
122
+ )
123
+ raise typer.Exit(code=1)
124
+
125
+ # --- master key ----------------------------------------------------
126
+ mkey_path = arc_dir / "arc.mkey"
127
+ if mkey_path.exists():
128
+ console.print(f"[yellow]{mkey_path} already exists — leaving untouched.[/yellow]")
129
+ else:
130
+ mkey_path.write_text(stdlib_secrets.token_hex(32))
131
+ mkey_path.chmod(0o600)
132
+ console.print(f"[green]Generated master key: {mkey_path}[/green]")
133
+
134
+ # --- empty secrets store --------------------------------------------
135
+ secrets_path = arc_dir / "arc.secrets"
136
+ if not secrets_path.exists():
137
+ secrets_path.touch()
138
+ secrets_path.chmod(0o600)
139
+ console.print(f"[green]Created empty secrets store: {secrets_path}[/green]")
140
+
141
+ # --- arc.toml --------------------------------------------------------
142
+ toml_path = arc_dir / "arc.toml"
143
+ if toml_path.exists():
144
+ console.print(f"[yellow]{toml_path} already exists — leaving untouched.[/yellow]")
145
+ else:
146
+ doc = tomlkit.document()
147
+
148
+ project_table = tomlkit.table()
149
+ project_table["name"] = root.name
150
+ project_table["env"] = env
151
+ doc["project"] = project_table
152
+
153
+ doc["settings"] = tomlkit.table()
154
+
155
+ secrets_section = tomlkit.table()
156
+ secrets_section["provider"] = "local_file"
157
+ secrets_section["declared"] = tomlkit.array()
158
+ doc["secrets"] = secrets_section
159
+
160
+ logging_table = tomlkit.table()
161
+ logging_table["level"] = "INFO"
162
+ logging_table["dir"] = "logs"
163
+ doc["logging"] = logging_table
164
+
165
+ toml_path.write_text(tomlkit.dumps(doc))
166
+ console.print(f"[green]Wrote default {toml_path}[/green]")
167
+
168
+ # --- config/*.toml overlays -------------------------------------------
169
+ for env_name in ["common", "dev", "staging", "prod"]:
170
+ p = root / "config" / f"{env_name}.toml"
171
+ if not p.exists():
172
+ p.write_text(f"# {env_name} environment overrides — merged on top of .arc/arc.toml\n")
173
+
174
+ # --- plugins.lock skeleton --------------------------------------------
175
+ lock_path = arc_dir / "plugins.lock"
176
+ if not lock_path.exists():
177
+ registry.save_lock(lock_path, registry.load_lock(lock_path))
178
+ console.print(f"[green]Created empty {lock_path}[/green]")
179
+
180
+ # --- .gitignore --------------------------------------------------------
181
+ # arc/ and plugins/*/ are each their OWN independent git repos. If this
182
+ # project root is also a git repo, git would otherwise treat them as
183
+ # "embedded repositories" (silently skipped or added as a dangling
184
+ # gitlink, not real tracked files) — so the outer repo ignores their
185
+ # contents entirely and each is managed via its own remote instead.
186
+ gitignore_entries = [
187
+ ".arc/arc.mkey", ".arc/arc.rkey", ".arc/arc.secrets", ".arc/runtime/",
188
+ "logs/*.log", "backups/db/*", "backups/files/*",
189
+ "!backups/db/.gitkeep", "!backups/files/.gitkeep",
190
+ ".venv/", "__pycache__/",
191
+ "/arc/", "/plugins/*/",
192
+ ]
193
+ gitignore_path = root / ".gitignore"
194
+ existing = gitignore_path.read_text().splitlines() if gitignore_path.exists() else []
195
+ with gitignore_path.open("a") as f:
196
+ for entry in gitignore_entries:
197
+ if entry not in existing:
198
+ f.write(entry + "\n")
199
+
200
+ # --- root pyproject.toml: a uv WORKSPACE, not a single package ---------
201
+ # arc/ and every plugins/* are independent packages with their own
202
+ # pyproject.toml; the root just aggregates them into one shared venv
203
+ # and one shared lock file. The root itself ships no code, hence
204
+ # tool.uv.package = false.
205
+ pyproject = root / "pyproject.toml"
206
+ if not pyproject.exists():
207
+ doc = tomlkit.document()
208
+ project_table = tomlkit.table()
209
+ project_table["name"] = root.name
210
+ project_table["version"] = "0.1.0"
211
+ project_table["requires-python"] = ">=3.12"
212
+ project_table["dependencies"] = tomlkit.array()
213
+ doc["project"] = project_table
214
+
215
+ uv_table = tomlkit.table()
216
+ uv_table["package"] = False
217
+ doc["tool"] = tomlkit.table()
218
+ doc["tool"]["uv"] = uv_table
219
+
220
+ workspace_table = tomlkit.table()
221
+ workspace_table["members"] = ["arc", "plugins/*"]
222
+ doc["tool"]["uv"]["workspace"] = workspace_table
223
+
224
+ pyproject.write_text(tomlkit.dumps(doc))
225
+ console.print(f"[green]Wrote workspace {pyproject}[/green]")
226
+
227
+ run(["uv", "sync", "--all-packages"], cwd=root)
228
+
229
+ console.print(f"\n[bold green]ARC instance scaffolded at: {root}[/bold green]")
230
+ console.print("[yellow]Using local-file secrets — fine for dev/self-hosted. "
231
+ "Switch [secrets].provider in .arc/arc.toml for cloud production.[/yellow]")
232
+
233
+
234
+ def _infer_plugin_name(github_url: str) -> str:
235
+ """github.com/org/foo-plugin(.git) -> foo-plugin"""
236
+ path = urlparse(github_url).path if "://" in github_url else github_url
237
+ stem = path.rstrip("/").rsplit("/", 1)[-1]
238
+ return stem[:-4] if stem.endswith(".git") else stem
239
+
240
+
241
+ @app.command()
242
+ def install(
243
+ github_url: str = typer.Argument(..., help="Git URL of the plugin repo to clone."),
244
+ branch: str = typer.Option(
245
+ None, "--branch", help="Branch/tag to clone. Defaults to the repo's default branch."
246
+ ),
247
+ name: str = typer.Option(
248
+ None, "--name", help="Directory name under plugins/. Inferred from the URL if omitted."
249
+ ),
250
+ ) -> None:
251
+ """
252
+ Clone a plugin's git repo into plugins/<name> and register it —
253
+ analogous to `bench get-app`. Installs the plugin's own declared
254
+ Python dependencies via the uv workspace; no network dependency
255
+ resolution beyond the clone itself and a single `uv sync`.
256
+ """
257
+ root = find_project_root()
258
+ plugin_name = name or _infer_plugin_name(github_url)
259
+ target = root / "plugins" / plugin_name
260
+
261
+ if target.exists():
262
+ err_console.print(
263
+ f"plugins/{plugin_name} already exists. "
264
+ f"Pass --name to install under a different directory, or remove it first."
265
+ )
266
+ raise typer.Exit(code=1)
267
+
268
+ clone_cmd = ["git", "clone"]
269
+ if branch:
270
+ clone_cmd += ["--branch", branch]
271
+ clone_cmd += [github_url, str(target)]
272
+ run(clone_cmd, cwd=root)
273
+
274
+ manifest_path = target / "plugin.toml"
275
+ if not manifest_path.exists():
276
+ err_console.print(
277
+ f"{github_url} was cloned into plugins/{plugin_name}, but it has no "
278
+ f"plugin.toml at its root — this doesn't look like an ARC plugin. "
279
+ f"Removing the clone."
280
+ )
281
+ shutil.rmtree(target, ignore_errors=True)
282
+ raise typer.Exit(code=1)
283
+
284
+ console.print("Installing dependencies via the workspace...")
285
+ run(["uv", "sync", "--all-packages"], cwd=root)
286
+
287
+ manifest = registry.read_manifest(manifest_path)
288
+ lock_path = root / ".arc" / "plugins.lock"
289
+ lock_doc = registry.load_lock(lock_path)
290
+ lock_doc = registry.merge_manifests_into_lock(lock_doc, [manifest])
291
+ registry.save_lock(lock_path, lock_doc)
292
+
293
+ console.print(
294
+ f"[bold green]Installed '{plugin_name}' "
295
+ f"(capability: {manifest.capability}) and enabled it.[/bold green]"
296
+ )
297
+
298
+ all_manifests = registry.discover_plugins(root / "plugins")
299
+ for w in registry.validate_requires(all_manifests):
300
+ console.print(f"[yellow]Warning: {w}[/yellow]")
301
+
302
+
303
+ _PLUGIN_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
304
+
305
+
306
+ def _plugin_template_files(name: str) -> dict[str, str]:
307
+ """Every file `arc new-plugin` writes, keyed by path relative to
308
+ plugins/<name>/. hooks/api/tasks get real .py files with the whole
309
+ example commented out line-by-line — loaded for real via
310
+ register_hooks/register_api/register_tasks the moment the plugin
311
+ boots, so an uncommented decorator would immediately do something
312
+ (register a live hook, a real HTTP-reachable endpoint, a real
313
+ background job) — not what a starter file should do by default.
314
+ schemas/patches get a README instead of a same-shape sample .json:
315
+ JSON has no comment syntax, so a real .json file there would be
316
+ loaded as an ACTUAL schema by psqldb.register_model() the moment
317
+ this plugin boots, silently creating a real table nobody asked for."""
318
+ return {
319
+ ".gitignore": "__pycache__/\n",
320
+ "plugin.toml": (
321
+ "[plugin]\n"
322
+ f'name = "{name}"\n'
323
+ 'version = "0.1.0"\n'
324
+ f'capability = "{name}"\n'
325
+ 'requires = ["psqldb", "relay"]\n'
326
+ 'optional_requires = ["gateway"]\n'
327
+ ),
328
+ "pyproject.toml": (
329
+ "[project]\n"
330
+ f'name = "{name}"\n'
331
+ 'version = "0.1.0"\n'
332
+ f'description = "ARC business plugin: {name}"\n'
333
+ 'requires-python = ">=3.12"\n'
334
+ "dependencies = []\n"
335
+ "\n"
336
+ '[project.entry-points."arc.plugins"]\n'
337
+ f"{name} = \"{name}:register\"\n"
338
+ "\n"
339
+ "[build-system]\n"
340
+ 'requires = ["hatchling"]\n'
341
+ 'build-backend = "hatchling.build"\n'
342
+ "\n"
343
+ "[tool.hatch.build.targets.wheel]\n"
344
+ f'packages = ["{name}"]\n'
345
+ ),
346
+ f"{name}/__init__.py": (
347
+ f'"""{name} — an ARC business plugin.\n'
348
+ "\n"
349
+ "Scaffolded by `arc new-plugin`. docs/arc.MD §3.9 (schemas/patches),\n"
350
+ "§3.11 (hooks/api/tasks), §3.7 (directory conventions) cover how each\n"
351
+ 'of the directories below gets loaded.\n"""\n'
352
+ "\n"
353
+ "from pathlib import Path\n"
354
+ "from typing import Any\n"
355
+ "\n"
356
+ f'CAPABILITY = "{name}"\n'
357
+ "\n"
358
+ "\n"
359
+ "def register(kernel: Any) -> None:\n"
360
+ ' psqldb = kernel.get("psqldb")\n'
361
+ ' psqldb.register_model(Path(__file__).parent / "schemas")\n'
362
+ ' psqldb.register_patches(Path(__file__).parent / "patches")\n'
363
+ "\n"
364
+ ' relay = kernel.get("relay")\n'
365
+ ' relay.register_hooks(Path(__file__).parent / "hooks")\n'
366
+ ' relay.register_api(Path(__file__).parent / "api")\n'
367
+ ' relay.register_tasks(Path(__file__).parent / "tasks")\n'
368
+ "\n"
369
+ " # Serve this plugin's own UI, once you've built one — see ui/README.md.\n"
370
+ ' # if kernel.has("gateway"):\n'
371
+ ' # ui_dist = Path(__file__).parent / "ui" / "dist"\n'
372
+ " # if ui_dist.is_dir():\n"
373
+ f' # kernel.get("gateway").mount_spa(ui_dist, prefix="{name}_desk")\n'
374
+ "\n"
375
+ " kernel.export(\n"
376
+ f' CAPABILITY, object(), requires=["psqldb", "relay"], optional_requires=["gateway"]\n'
377
+ " )\n"
378
+ ),
379
+ f"{name}/schemas/README.md": (
380
+ "# schemas/\n\n"
381
+ "One JSON file per table this plugin OWNS (creates). Loaded via "
382
+ "`psqldb.register_model(...)` in `__init__.py`. The filename (minus "
383
+ "`.json`) becomes the table's file **stem** — the only valid value for "
384
+ "a REFERENCE/TABLE field's `target` elsewhere, never the physical, "
385
+ "slugified table name (docs/arc.MD §3.9).\n\n"
386
+ 'Every normal table must declare at least one business `"unique": true` '
387
+ "field of its own (not just the auto-injected `id`).\n\n"
388
+ "Example — `Department.json`:\n\n"
389
+ "```json\n"
390
+ "{\n"
391
+ ' "system": false,\n'
392
+ ' "audit": false,\n'
393
+ ' "child": false,\n'
394
+ ' "fields": [\n'
395
+ ' {"id": "AA01", "name": "code", "type": "STRING", "unique": true, "required": true, "length": 8},\n'
396
+ ' {"id": "AA02", "name": "dept_name", "type": "STRING", "required": true, "length": 100}\n'
397
+ " ],\n"
398
+ ' "index": [{"key": "idx_dept_name", "fields": ["dept_name"]}]\n'
399
+ "}\n"
400
+ "```\n\n"
401
+ "After adding or changing a schema file:\n"
402
+ "1. `arc psqldb plan` — preview the diff, never touches the DB.\n"
403
+ "2. `arc psqldb migrate` — apply it (run this yourself).\n"
404
+ ),
405
+ f"{name}/patches/README.md": (
406
+ "# patches/\n\n"
407
+ "Add or modify fields YOU own on a table — your own, or another "
408
+ "installed plugin's. Same JSON shape as `schemas/`, minus "
409
+ "`system`/`audit`/`child`. Never create a table here — that's "
410
+ "`schemas/`'s job.\n\n"
411
+ 'A patch can\'t target a `"system": true` table (skipped with a '
412
+ "warning at plan/migrate time — docs/arc.MD §3.9).\n\n"
413
+ "Example — `Employee.json` (adding a field to a table some other "
414
+ "installed plugin owns):\n\n"
415
+ "```json\n"
416
+ "{\n"
417
+ ' "fields": [\n'
418
+ ' {"id": "AB01", "name": "emergency_contact", "type": "STRING", "length": 100}\n'
419
+ " ]\n"
420
+ "}\n"
421
+ "```\n"
422
+ ),
423
+ f"{name}/hooks/example.py": (
424
+ '"""hooks/<Table Name>.py — one file per table, named exactly after '
425
+ "its schema (docs/arc.MD §3.11). Loaded via relay.register_hooks(...).\n\n"
426
+ "Delete this file, or rename it to a real table and uncomment what "
427
+ 'you need. Nothing below runs until you do.\n"""\n'
428
+ "\n"
429
+ "# import arc\n"
430
+ "#\n"
431
+ "# @arc.relay.validate\n"
432
+ "# async def check_something(ctx) -> None:\n"
433
+ "# if ctx.doc.some_field is None:\n"
434
+ '# arc.relay.throw("some_field is required", code="missing_field")\n'
435
+ "#\n"
436
+ "# @arc.relay.after_save\n"
437
+ "# async def on_saved(ctx) -> None:\n"
438
+ "# if ctx.doc._is_new:\n"
439
+ '# arc.relay.log(f"created {ctx.new[\'id\']}")\n'
440
+ ),
441
+ f"{name}/api/example.py": (
442
+ '"""api/*.py — whitelisted functions, not table-named (docs/arc.MD '
443
+ "§3.11). Loaded via relay.register_api(...). Always callable directly "
444
+ "via arc.relay.call(...); additionally reachable over HTTP at "
445
+ "/api/method/<plugin>.<function_name> when gateway is installed.\n\n"
446
+ "Delete this file, or rename it and uncomment what you need.\n"
447
+ '"""\n'
448
+ "\n"
449
+ "# import arc\n"
450
+ "#\n"
451
+ '# @arc.relay.whitelist(methods=["GET"], roles=["Guest"])\n'
452
+ "# async def ping() -> dict:\n"
453
+ '# return {"ok": True}\n'
454
+ ),
455
+ f"{name}/tasks/example.py": (
456
+ '"""tasks/*.py — background/scheduled jobs (docs/arc.MD §3.11/'
457
+ "§3.15). Loaded via relay.register_tasks(...). Durable + schedulable "
458
+ "when the `lineup` plugin is installed; still runs in-process (just "
459
+ "not durably) if it isn't — never depend on `lineup` directly, "
460
+ "arc.relay.task/enqueue handle that automatically.\n\n"
461
+ "Delete this file, or rename it and uncomment what you need.\n"
462
+ '"""\n'
463
+ "\n"
464
+ "# import arc\n"
465
+ "#\n"
466
+ '# @arc.relay.task(queue="default")\n'
467
+ "# async def send_something(employee_code: str) -> None:\n"
468
+ "# ...\n"
469
+ "#\n"
470
+ '# @arc.relay.task(queue="low", cron="0 2 * * *")\n'
471
+ "# async def nightly_cleanup() -> None:\n"
472
+ "# ...\n"
473
+ ),
474
+ f"{name}/ui/README.md": (
475
+ "# ui/\n\n"
476
+ "Most plugins don't need their own UI — this is a placeholder, not "
477
+ "a requirement. If you want to serve one (following admin's own "
478
+ "`/admin-desk` pattern, docs/arc.MD §3.14/§6):\n\n"
479
+ "1. `npm create vite@latest . -- --template react-ts` in this directory.\n"
480
+ "2. Build it, then in `__init__.py`'s `register()`, uncomment the "
481
+ "`mount_spa` block and point it at your own build's `dist/`.\n"
482
+ "3. Pick a route prefix that isn't your plugin's own name if you "
483
+ f'don\'t want the two coupled — e.g. `"{name}_desk"` (already the '
484
+ "default in the commented-out sample above).\n\n"
485
+ "See `plugins/admin/admin/ui/` for a complete, working reference.\n"
486
+ ),
487
+ }
488
+
489
+
490
+ @app.command(name="new-plugin")
491
+ def new_plugin(
492
+ name: str = typer.Argument(..., help="Plugin name — becomes plugins/<name>/ and its capability name."),
493
+ ) -> None:
494
+ """Scaffolds a new plugin directory: plugin.toml, pyproject.toml, and
495
+ the standard schemas/patches/hooks/api/tasks/ui layout (docs/arc.MD
496
+ §3.7/§3.9/§3.11), each with a README or a fully commented-out example
497
+ showing the convention — nothing in it is live until you uncomment
498
+ and adapt it. Initializes its own git repo (main branch, matching
499
+ every other plugin — §3.6) but does not commit anything; runs `uv
500
+ sync --all-packages` and registers it in plugins.lock (enabled by
501
+ default), the same way `arc install` finishes a clone."""
502
+ if not _PLUGIN_NAME_RE.match(name):
503
+ err_console.print(
504
+ f"'{name}' isn't a valid plugin name — must start with a lowercase letter "
505
+ f"and contain only lowercase letters, digits, and underscores (no leading "
506
+ f"underscore either — uv's own packaging tooling rejects distribution names "
507
+ f"that start with one, docs/arc.MD §3.7)."
508
+ )
509
+ raise typer.Exit(code=1)
510
+
511
+ root = find_project_root()
512
+ target = root / "plugins" / name
513
+ if target.exists():
514
+ err_console.print(f"plugins/{name} already exists.")
515
+ raise typer.Exit(code=1)
516
+
517
+ for rel_path, content in _plugin_template_files(name).items():
518
+ full_path = target / rel_path
519
+ full_path.parent.mkdir(parents=True, exist_ok=True)
520
+ full_path.write_text(content)
521
+
522
+ run(["git", "init", "-b", "main"], cwd=target)
523
+ console.print(f"[dim]Initialized a new git repo at plugins/{name} — nothing committed yet.[/dim]")
524
+
525
+ console.print("Installing dependencies via the workspace...")
526
+ run(["uv", "sync", "--all-packages"], cwd=root)
527
+
528
+ manifest = registry.read_manifest(target / "plugin.toml")
529
+ lock_path = root / ".arc" / "plugins.lock"
530
+ lock_doc = registry.load_lock(lock_path)
531
+ lock_doc = registry.merge_manifests_into_lock(lock_doc, [manifest])
532
+ registry.save_lock(lock_path, lock_doc)
533
+
534
+ console.print(f"[bold green]Scaffolded '{name}' at plugins/{name} and enabled it.[/bold green]")
535
+ console.print(
536
+ f"[dim]Next: add a schema (see plugins/{name}/{name}/schemas/README.md), "
537
+ f"then `arc psqldb plan` / `arc psqldb migrate` when you're ready to create tables. "
538
+ f"Review and commit plugins/{name} yourself when you're happy with it.[/dim]"
539
+ )
540
+
541
+
542
+ @app.command()
543
+ def build(
544
+ plugin: str = typer.Option(
545
+ None, "-p", "--plugin", help="Refresh only this plugin's plugins.lock entry."
546
+ ),
547
+ no_lock: bool = typer.Option(
548
+ False, "--no-lock", help="Skip the `uv lock` step (just sync from the existing lock)."
549
+ ),
550
+ ) -> None:
551
+ """
552
+ Re-resolve and re-install everything currently on disk under arc/ and
553
+ plugins/*, and refresh .arc/plugins.lock to match.
554
+
555
+ This does NOT fetch anything from git — that's `arc install`'s job.
556
+ `arc build` is what you run after a fresh clone of the whole project
557
+ (CI, a new machine, restoring from backup), or after hand-editing a
558
+ plugin's own pyproject.toml dependencies.
559
+ """
560
+ root = find_project_root()
561
+ plugins_dir = root / "plugins"
562
+ lock_path = root / ".arc" / "plugins.lock"
563
+
564
+ all_manifests = registry.discover_plugins(plugins_dir)
565
+ if not all_manifests:
566
+ console.print("[yellow]No plugins found under plugins/ — nothing to build.[/yellow]")
567
+ else:
568
+ warnings = registry.validate_requires(all_manifests)
569
+ for w in warnings:
570
+ console.print(f"[yellow]Warning: {w}[/yellow]")
571
+
572
+ to_refresh = (
573
+ [m for m in all_manifests if m.name == plugin] if plugin else all_manifests
574
+ )
575
+ if plugin and not to_refresh:
576
+ available = ", ".join(m.name for m in all_manifests) or "none"
577
+ err_console.print(f"No plugin named '{plugin}' found. Available: {available}")
578
+ raise typer.Exit(code=1)
579
+
580
+ if not no_lock:
581
+ run(["uv", "lock"], cwd=root)
582
+ run(["uv", "sync", "--all-packages"], cwd=root)
583
+
584
+ if to_refresh:
585
+ lock_doc = registry.load_lock(lock_path)
586
+ lock_doc = registry.merge_manifests_into_lock(lock_doc, to_refresh)
587
+ registry.save_lock(lock_path, lock_doc)
588
+ console.print(
589
+ f"[bold green]Build complete. Refreshed: "
590
+ f"{', '.join(m.name for m in to_refresh)}[/bold green]"
591
+ )
592
+
593
+
594
+ # --------------------------------------------------------------------------- #
595
+ # arc clear-cache
596
+ # --------------------------------------------------------------------------- #
597
+ @app.command(name="clear-cache")
598
+ def clear_cache() -> None:
599
+ """Clears everything genuinely cache-like: relay's generic cache
600
+ (cache_get/cache_set/cache_delete) and authn's session/access-key
601
+ cache. Deliberately does NOT touch lineup's job queues or redix's
602
+ rate-limit counters, which live in the same Redis instance under
603
+ their own prefixes — clearing those would drop pending work or reset
604
+ brute-force protection, neither of which "clear the cache" should do.
605
+
606
+ A narrow, acknowledged exception to the kernel's own domain-blindness
607
+ (§3.1): this needs to know redix's/authn's own key-prefix conventions
608
+ by name (hardcoded below), which the kernel doesn't otherwise know or
609
+ care about. Kept here anyway because "clear the cache" is a
610
+ cross-cutting operational concern no single plugin owns — the same
611
+ reasoning `arc doctor`/`arc health` already lean on."""
612
+ import arc
613
+
614
+ with warnings.catch_warnings():
615
+ warnings.simplefilter("ignore", arc.ArcAdvisory)
616
+ try:
617
+ kernel = arc.boot()
618
+ except arc.BootError as exc:
619
+ err_console.print(str(exc))
620
+ raise typer.Exit(code=1)
621
+
622
+ if not kernel.has("redix"):
623
+ console.print("[dim]redix isn't installed — there's no cache to clear.[/dim]")
624
+ return
625
+
626
+ # relay's own generic cache: "cache:*". authn's session/access-key
627
+ # cache: "session:*" / "access_key:*" (docs/arc.MD §3.13). Never
628
+ # "lineup:*" (job queues, §3.15) or "ratelimit:*" (redix.rate_limit()).
629
+ _CACHE_KEY_PATTERNS = ("cache:*", "session:*", "access_key:*")
630
+
631
+ async def _run() -> int:
632
+ await arc.redix.open()
633
+ try:
634
+ total = 0
635
+ for pattern in _CACHE_KEY_PATTERNS:
636
+ total += await arc.redix.scan_delete(pattern)
637
+ return total
638
+ finally:
639
+ await arc.redix.close()
640
+
641
+ total = asyncio.run(_run())
642
+ console.print(
643
+ f"[bold green]Cleared {total} cache key(s)[/bold green] "
644
+ f"(relay's generic cache + authn's session/access-key cache)."
645
+ )
646
+
647
+
648
+ # --------------------------------------------------------------------------- #
649
+ # arc settings get / set / delete
650
+ # --------------------------------------------------------------------------- #
651
+ @settings_app.command("get")
652
+ def settings_get(
653
+ key: str,
654
+ reveal: bool = typer.Option(
655
+ False, "--reveal", help="Show the real value even if the key is a secret."
656
+ ),
657
+ ) -> None:
658
+ """Get a setting's value. Secret values print as ******** unless --reveal is passed."""
659
+ root = find_project_root()
660
+ mgr = SettingsManager(root / ".arc")
661
+ try:
662
+ value = mgr.get(key, reveal=reveal)
663
+ except SettingsError as exc:
664
+ err_console.print(str(exc))
665
+ raise typer.Exit(code=1)
666
+
667
+ if value is None:
668
+ err_console.print(f"'{key}' is not set.")
669
+ raise typer.Exit(code=1)
670
+ console.print(value)
671
+
672
+
673
+ @settings_app.command("set")
674
+ def settings_set(
675
+ key: str,
676
+ value: str,
677
+ secret: bool = typer.Option(
678
+ False, "--secret", help="Store this value encrypted and mark the key as secret."
679
+ ),
680
+ ) -> None:
681
+ """Set a setting. Plain settings go into .arc/arc.toml; --secret routes to arc.secrets."""
682
+ root = find_project_root()
683
+ mgr = SettingsManager(root / ".arc")
684
+ try:
685
+ mgr.set(key, value, secret=secret)
686
+ except SettingsError as exc:
687
+ err_console.print(str(exc))
688
+ raise typer.Exit(code=1)
689
+
690
+ shown = REDACTED if secret else value
691
+ console.print(f"[green]Set {key} = {shown}[/green]" + (" (secret)" if secret else ""))
692
+
693
+
694
+ @settings_app.command("delete")
695
+ def settings_delete(key: str) -> None:
696
+ """Delete a setting, whether plain or secret."""
697
+ root = find_project_root()
698
+ mgr = SettingsManager(root / ".arc")
699
+ existed = mgr.delete(key)
700
+ if existed:
701
+ console.print(f"[green]Deleted '{key}'.[/green]")
702
+ else:
703
+ err_console.print(f"'{key}' was not set.")
704
+ raise typer.Exit(code=1)
705
+
706
+
707
+ # --------------------------------------------------------------------------- #
708
+ # arc plugin enable / disable / list
709
+ # --------------------------------------------------------------------------- #
710
+ @plugin_app.command("enable")
711
+ def plugin_enable(name: str) -> None:
712
+ """Enable a plugin. arc.boot() will call its register() and attach its namespace."""
713
+ root = find_project_root()
714
+ lock_path = root / ".arc" / "plugins.lock"
715
+ lock_doc = registry.load_lock(lock_path)
716
+ try:
717
+ registry.set_enabled(lock_doc, name, True)
718
+ except registry.RegistryError as exc:
719
+ err_console.print(str(exc))
720
+ raise typer.Exit(code=1)
721
+ registry.save_lock(lock_path, lock_doc)
722
+ console.print(f"[green]Enabled plugin '{name}'.[/green]")
723
+
724
+
725
+ @plugin_app.command("disable")
726
+ def plugin_disable(
727
+ name: str,
728
+ wipe: bool = typer.Option(
729
+ False, "--wipe", help="Also DROP every table this plugin owns from the database. Irreversible."
730
+ ),
731
+ force: bool = typer.Option(
732
+ False,
733
+ "--force",
734
+ help="With --wipe: proceed even if another plugin has patched fields onto, or a live "
735
+ "REFERENCE into, one of these tables (destroys that too — see the command's own help).",
736
+ ),
737
+ yes: bool = typer.Option(False, "--yes", help="Skip the drop confirmation prompt (--wipe only)."),
738
+ ) -> None:
739
+ """Disable a plugin. It is fully unregistered — arc.boot() skips it entirely,
740
+ and its capability namespace (arc.<name>) will not exist at runtime.
741
+
742
+ --wipe additionally DROPs every table this plugin owns — a real DROP
743
+ TABLE, not the recoverable soft-delete `arc psqldb clear` uses (a
744
+ dropped table has no _trash entry, only a dropped ROW does). Two
745
+ real risks, handled two different ways: if another still-enabled
746
+ plugin has PATCHED extra fields onto one of these tables, that's
747
+ checked directly (Postgres has no concept of "which plugin owns this
748
+ column") and refused unless --force. If another plugin's table has a
749
+ live REFERENCE pointing at one of these, Postgres's own FK constraint
750
+ refuses the plain DROP TABLE on its own — --force retries with
751
+ CASCADE in that case. Either way, --force means real, additional data
752
+ loss beyond this plugin's own tables — read the preview before
753
+ confirming."""
754
+ root = find_project_root()
755
+ lock_path = root / ".arc" / "plugins.lock"
756
+ lock_doc = registry.load_lock(lock_path)
757
+
758
+ if name not in lock_doc.get("plugins", {}):
759
+ err_console.print(f"Plugin '{name}' is not in plugins.lock. Run `arc build` first.")
760
+ raise typer.Exit(code=1)
761
+
762
+ if wipe:
763
+ import arc
764
+
765
+ with warnings.catch_warnings():
766
+ warnings.simplefilter("ignore", arc.ArcAdvisory)
767
+ try:
768
+ arc.boot()
769
+ except arc.BootError as exc:
770
+ err_console.print(str(exc))
771
+ raise typer.Exit(code=1)
772
+
773
+ async def _run() -> bool:
774
+ await arc.psqldb.open()
775
+ try:
776
+ plan = await arc.psqldb.wipe_plugin_tables(name, force=force, dry_run=True)
777
+ all_tables = plan["tables"] + ([plan["audit_table"]] if plan["audit_table"] else [])
778
+ if not all_tables:
779
+ console.print(f"[dim]'{name}' owns no tables — nothing to wipe.[/dim]")
780
+ return True
781
+ console.print(f"[bold red]About to PERMANENTLY DROP {len(all_tables)} table(s):[/bold red]")
782
+ for t in all_tables:
783
+ console.print(f" {t} ({plan['row_counts'].get(t, 0)} row(s))")
784
+ if not yes and not typer.confirm("This cannot be undone. Proceed?", default=False):
785
+ console.print("[dim]Aborted — nothing dropped, plugin not disabled.[/dim]")
786
+ return False
787
+ await arc.psqldb.wipe_plugin_tables(name, force=force, dry_run=False)
788
+ console.print(f"[green]Dropped {len(all_tables)} table(s).[/green]")
789
+ return True
790
+ finally:
791
+ await arc.psqldb.close()
792
+
793
+ try:
794
+ proceed = asyncio.run(_run())
795
+ except Exception as exc:
796
+ err_console.print(str(exc))
797
+ raise typer.Exit(code=1)
798
+ if not proceed:
799
+ raise typer.Exit(code=1)
800
+
801
+ try:
802
+ registry.set_enabled(lock_doc, name, False)
803
+ except registry.RegistryError as exc:
804
+ err_console.print(str(exc))
805
+ raise typer.Exit(code=1)
806
+ registry.save_lock(lock_path, lock_doc)
807
+ console.print(f"[yellow]Disabled plugin '{name}'. It will not be loaded by arc.boot().[/yellow]")
808
+
809
+
810
+ @plugin_app.command("list")
811
+ def plugin_list() -> None:
812
+ """Show every plugin known to plugins.lock and its enabled/disabled state."""
813
+ root = find_project_root()
814
+ lock_path = root / ".arc" / "plugins.lock"
815
+ lock_doc = registry.load_lock(lock_path)
816
+ entries = registry.list_plugins(lock_doc)
817
+
818
+ if not entries:
819
+ console.print("[dim]No plugins in plugins.lock yet. Run `arc build` first.[/dim]")
820
+ return
821
+
822
+ table = Table()
823
+ table.add_column("Name")
824
+ table.add_column("Version")
825
+ table.add_column("Capability")
826
+ table.add_column("Enabled")
827
+ table.add_column("Requires")
828
+ for name, entry in entries:
829
+ enabled = entry.get("enabled", True)
830
+ table.add_row(
831
+ name,
832
+ str(entry.get("version", "")),
833
+ str(entry.get("capability", "")),
834
+ "[green]yes[/green]" if enabled else "[red]no[/red]",
835
+ ", ".join(entry.get("requires", [])) or "-",
836
+ )
837
+ console.print(table)
838
+
839
+
840
+ if __name__ == "__main__":
841
+ app()