runebox-sync 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Suttles
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: runebox-sync
3
+ Version: 0.2.0
4
+ Summary: Standalone sync client for the Runebox registry — no Claude Code required. Keeps your team's approved skills, agents, and commands installed and current on Claude Code, Codex CLI, Cursor, and Gemini CLI.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://runebox.ai
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # Runebox sync — Claude Code plugin + standalone CLI
13
+
14
+ Your team's [Runebox](https://runebox.ai) registry, kept in sync on every engineer's
15
+ machine: log in once with an API key and your org's approved skills, agents, and
16
+ commands install and stay current — on **Claude Code, Codex CLI, Cursor, or Gemini
17
+ CLI**. One sync core (`scripts/sync.py`, stdlib-only) ships two ways:
18
+
19
+ - **On Claude Code** — the plugin below, with a `SessionStart` hook.
20
+ - **Everywhere else** — the same script as a standalone `runebox-sync` CLI via
21
+ `pipx`/`uvx`. No Claude Code required.
22
+
23
+ ## Install — Claude Code plugin
24
+
25
+ ```
26
+ claude plugin marketplace add runebox-ai/runebox-plugin
27
+ claude plugin install runebox
28
+ /runebox:login
29
+ ```
30
+
31
+ `/runebox:login` asks for your org's API key (starts with `rbx_`; your admin
32
+ creates it on the org's **Get set up** page at [runebox.ai](https://runebox.ai)),
33
+ verifies it, auto-detects which harnesses you have installed, and syncs the whole
34
+ approved catalog in one shot.
35
+
36
+ ## Install — standalone CLI (Codex, Cursor, Gemini CLI)
37
+
38
+ ```
39
+ pipx install runebox-sync # or: uvx runebox-sync login
40
+ printf '%s' "rbx_yourkey" | runebox-sync login
41
+ ```
42
+
43
+ Same key as the plugin path — an engineer on Codex/Cursor/Gemini CLI never touches
44
+ Claude Code at all. Add `runebox-sync autosync` to your shell profile so it keeps
45
+ syncing on every new shell (there's no `SessionStart` hook outside Claude Code):
46
+
47
+ ```
48
+ runebox-sync autosync --apply
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Plugin (`/runebox:…`) | Standalone CLI (`runebox-sync …`) | What it does |
54
+ |---|---|---|
55
+ | `/runebox:login` | `login` | Connect and install the approved catalog for every detected harness |
56
+ | `/runebox:sync` | `sync [--quiet]` | Sync now (the plugin also runs this automatically at session start) |
57
+ | `/runebox:status` | `status` | Show connected orgs and per-harness inventory |
58
+ | — | `harness enable\|disable <name>` | Turn a harness (`claude`/`codex`/`cursor`/`gemini`) on or off; disabling uninstalls just that harness's files |
59
+ | — | `autosync [--apply]` | Print (or append) a shell-profile snippet that keeps non-Claude harnesses synced |
60
+ | `/runebox:logout` | `logout [--remove]` | Disconnect (optionally remove everything it installed) |
61
+
62
+ ## How sync works
63
+
64
+ - Per org, per enabled harness: one conditional GET against the org catalog.
65
+ Nothing changed → a cheap `304` and zero output. Something changed → it
66
+ installs/updates/removes and prints one line. A skill installs identically to
67
+ every harness (the open SKILL.md format); a command's filename/location differs
68
+ per harness; agents/output styles/`claude_md` stay Claude Code-only for now.
69
+ - Every file it installs is tracked in a local manifest, per (org, harness). It
70
+ only ever touches files it installed itself — never your own skills or settings.
71
+ - When an admin **yanks** a bad version, the next sync removes it from your
72
+ machine. When your key is revoked, sync stops with a single notice.
73
+ - Adding a *harness* the server can target requires a client release (a new
74
+ allowlisted install root) — a compromised server can't write anywhere new.
75
+
76
+ ## Requirements
77
+
78
+ - `python3` on PATH — the sync script is stdlib-only, no dependencies, works
79
+ standalone or as a Claude Code plugin.
80
+
81
+ ## Security notes
82
+
83
+ - API keys are read from stdin at login (never passed as a CLI argument) and
84
+ stored in `~/.runebox/credentials.json` with `0600` permissions.
85
+ - Install paths from the server are validated client-side against a per-harness
86
+ roots allowlist — a hostile path can't escape those directories, and an
87
+ unrecognized root name is rejected outright.
88
+ - Sync failures never block your session; the hook (and `--quiet` sync) exits quietly.
89
+
90
+ ---
91
+
92
+ Runebox is a private, governed artifact registry for teams using AI coding
93
+ agents — publish once, scan + review every version, and every engineer's tools
94
+ stay in sync, whichever harness they run. Free for teams up to 5 seats at
95
+ [runebox.ai](https://runebox.ai).
@@ -0,0 +1,84 @@
1
+ # Runebox sync — Claude Code plugin + standalone CLI
2
+
3
+ Your team's [Runebox](https://runebox.ai) registry, kept in sync on every engineer's
4
+ machine: log in once with an API key and your org's approved skills, agents, and
5
+ commands install and stay current — on **Claude Code, Codex CLI, Cursor, or Gemini
6
+ CLI**. One sync core (`scripts/sync.py`, stdlib-only) ships two ways:
7
+
8
+ - **On Claude Code** — the plugin below, with a `SessionStart` hook.
9
+ - **Everywhere else** — the same script as a standalone `runebox-sync` CLI via
10
+ `pipx`/`uvx`. No Claude Code required.
11
+
12
+ ## Install — Claude Code plugin
13
+
14
+ ```
15
+ claude plugin marketplace add runebox-ai/runebox-plugin
16
+ claude plugin install runebox
17
+ /runebox:login
18
+ ```
19
+
20
+ `/runebox:login` asks for your org's API key (starts with `rbx_`; your admin
21
+ creates it on the org's **Get set up** page at [runebox.ai](https://runebox.ai)),
22
+ verifies it, auto-detects which harnesses you have installed, and syncs the whole
23
+ approved catalog in one shot.
24
+
25
+ ## Install — standalone CLI (Codex, Cursor, Gemini CLI)
26
+
27
+ ```
28
+ pipx install runebox-sync # or: uvx runebox-sync login
29
+ printf '%s' "rbx_yourkey" | runebox-sync login
30
+ ```
31
+
32
+ Same key as the plugin path — an engineer on Codex/Cursor/Gemini CLI never touches
33
+ Claude Code at all. Add `runebox-sync autosync` to your shell profile so it keeps
34
+ syncing on every new shell (there's no `SessionStart` hook outside Claude Code):
35
+
36
+ ```
37
+ runebox-sync autosync --apply
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ | Plugin (`/runebox:…`) | Standalone CLI (`runebox-sync …`) | What it does |
43
+ |---|---|---|
44
+ | `/runebox:login` | `login` | Connect and install the approved catalog for every detected harness |
45
+ | `/runebox:sync` | `sync [--quiet]` | Sync now (the plugin also runs this automatically at session start) |
46
+ | `/runebox:status` | `status` | Show connected orgs and per-harness inventory |
47
+ | — | `harness enable\|disable <name>` | Turn a harness (`claude`/`codex`/`cursor`/`gemini`) on or off; disabling uninstalls just that harness's files |
48
+ | — | `autosync [--apply]` | Print (or append) a shell-profile snippet that keeps non-Claude harnesses synced |
49
+ | `/runebox:logout` | `logout [--remove]` | Disconnect (optionally remove everything it installed) |
50
+
51
+ ## How sync works
52
+
53
+ - Per org, per enabled harness: one conditional GET against the org catalog.
54
+ Nothing changed → a cheap `304` and zero output. Something changed → it
55
+ installs/updates/removes and prints one line. A skill installs identically to
56
+ every harness (the open SKILL.md format); a command's filename/location differs
57
+ per harness; agents/output styles/`claude_md` stay Claude Code-only for now.
58
+ - Every file it installs is tracked in a local manifest, per (org, harness). It
59
+ only ever touches files it installed itself — never your own skills or settings.
60
+ - When an admin **yanks** a bad version, the next sync removes it from your
61
+ machine. When your key is revoked, sync stops with a single notice.
62
+ - Adding a *harness* the server can target requires a client release (a new
63
+ allowlisted install root) — a compromised server can't write anywhere new.
64
+
65
+ ## Requirements
66
+
67
+ - `python3` on PATH — the sync script is stdlib-only, no dependencies, works
68
+ standalone or as a Claude Code plugin.
69
+
70
+ ## Security notes
71
+
72
+ - API keys are read from stdin at login (never passed as a CLI argument) and
73
+ stored in `~/.runebox/credentials.json` with `0600` permissions.
74
+ - Install paths from the server are validated client-side against a per-harness
75
+ roots allowlist — a hostile path can't escape those directories, and an
76
+ unrecognized root name is rejected outright.
77
+ - Sync failures never block your session; the hook (and `--quiet` sync) exits quietly.
78
+
79
+ ---
80
+
81
+ Runebox is a private, governed artifact registry for teams using AI coding
82
+ agents — publish once, scan + review every version, and every engineer's tools
83
+ stay in sync, whichever harness they run. Free for teams up to 5 seats at
84
+ [runebox.ai](https://runebox.ai).
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "runebox-sync"
7
+ version = "0.2.0"
8
+ description = "Standalone sync client for the Runebox registry — no Claude Code required. Keeps your team's approved skills, agents, and commands installed and current on Claude Code, Codex CLI, Cursor, and Gemini CLI."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Homepage = "https://runebox.ai"
17
+
18
+ # `pipx install runebox-sync` / `uvx runebox-sync login` both resolve to this — the whole
19
+ # package is the single stdlib-only scripts/sync.py file (docs/10-multi-harness/design.md).
20
+ [project.scripts]
21
+ runebox-sync = "sync:main"
22
+
23
+ [tool.setuptools]
24
+ py-modules = ["sync"]
25
+
26
+ [tool.setuptools.package-dir]
27
+ "" = "scripts"
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: runebox-sync
3
+ Version: 0.2.0
4
+ Summary: Standalone sync client for the Runebox registry — no Claude Code required. Keeps your team's approved skills, agents, and commands installed and current on Claude Code, Codex CLI, Cursor, and Gemini CLI.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://runebox.ai
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # Runebox sync — Claude Code plugin + standalone CLI
13
+
14
+ Your team's [Runebox](https://runebox.ai) registry, kept in sync on every engineer's
15
+ machine: log in once with an API key and your org's approved skills, agents, and
16
+ commands install and stay current — on **Claude Code, Codex CLI, Cursor, or Gemini
17
+ CLI**. One sync core (`scripts/sync.py`, stdlib-only) ships two ways:
18
+
19
+ - **On Claude Code** — the plugin below, with a `SessionStart` hook.
20
+ - **Everywhere else** — the same script as a standalone `runebox-sync` CLI via
21
+ `pipx`/`uvx`. No Claude Code required.
22
+
23
+ ## Install — Claude Code plugin
24
+
25
+ ```
26
+ claude plugin marketplace add runebox-ai/runebox-plugin
27
+ claude plugin install runebox
28
+ /runebox:login
29
+ ```
30
+
31
+ `/runebox:login` asks for your org's API key (starts with `rbx_`; your admin
32
+ creates it on the org's **Get set up** page at [runebox.ai](https://runebox.ai)),
33
+ verifies it, auto-detects which harnesses you have installed, and syncs the whole
34
+ approved catalog in one shot.
35
+
36
+ ## Install — standalone CLI (Codex, Cursor, Gemini CLI)
37
+
38
+ ```
39
+ pipx install runebox-sync # or: uvx runebox-sync login
40
+ printf '%s' "rbx_yourkey" | runebox-sync login
41
+ ```
42
+
43
+ Same key as the plugin path — an engineer on Codex/Cursor/Gemini CLI never touches
44
+ Claude Code at all. Add `runebox-sync autosync` to your shell profile so it keeps
45
+ syncing on every new shell (there's no `SessionStart` hook outside Claude Code):
46
+
47
+ ```
48
+ runebox-sync autosync --apply
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Plugin (`/runebox:…`) | Standalone CLI (`runebox-sync …`) | What it does |
54
+ |---|---|---|
55
+ | `/runebox:login` | `login` | Connect and install the approved catalog for every detected harness |
56
+ | `/runebox:sync` | `sync [--quiet]` | Sync now (the plugin also runs this automatically at session start) |
57
+ | `/runebox:status` | `status` | Show connected orgs and per-harness inventory |
58
+ | — | `harness enable\|disable <name>` | Turn a harness (`claude`/`codex`/`cursor`/`gemini`) on or off; disabling uninstalls just that harness's files |
59
+ | — | `autosync [--apply]` | Print (or append) a shell-profile snippet that keeps non-Claude harnesses synced |
60
+ | `/runebox:logout` | `logout [--remove]` | Disconnect (optionally remove everything it installed) |
61
+
62
+ ## How sync works
63
+
64
+ - Per org, per enabled harness: one conditional GET against the org catalog.
65
+ Nothing changed → a cheap `304` and zero output. Something changed → it
66
+ installs/updates/removes and prints one line. A skill installs identically to
67
+ every harness (the open SKILL.md format); a command's filename/location differs
68
+ per harness; agents/output styles/`claude_md` stay Claude Code-only for now.
69
+ - Every file it installs is tracked in a local manifest, per (org, harness). It
70
+ only ever touches files it installed itself — never your own skills or settings.
71
+ - When an admin **yanks** a bad version, the next sync removes it from your
72
+ machine. When your key is revoked, sync stops with a single notice.
73
+ - Adding a *harness* the server can target requires a client release (a new
74
+ allowlisted install root) — a compromised server can't write anywhere new.
75
+
76
+ ## Requirements
77
+
78
+ - `python3` on PATH — the sync script is stdlib-only, no dependencies, works
79
+ standalone or as a Claude Code plugin.
80
+
81
+ ## Security notes
82
+
83
+ - API keys are read from stdin at login (never passed as a CLI argument) and
84
+ stored in `~/.runebox/credentials.json` with `0600` permissions.
85
+ - Install paths from the server are validated client-side against a per-harness
86
+ roots allowlist — a hostile path can't escape those directories, and an
87
+ unrecognized root name is rejected outright.
88
+ - Sync failures never block your session; the hook (and `--quiet` sync) exits quietly.
89
+
90
+ ---
91
+
92
+ Runebox is a private, governed artifact registry for teams using AI coding
93
+ agents — publish once, scan + review every version, and every engineer's tools
94
+ stay in sync, whichever harness they run. Free for teams up to 5 seats at
95
+ [runebox.ai](https://runebox.ai).
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ scripts/sync.py
5
+ scripts/runebox_sync.egg-info/PKG-INFO
6
+ scripts/runebox_sync.egg-info/SOURCES.txt
7
+ scripts/runebox_sync.egg-info/dependency_links.txt
8
+ scripts/runebox_sync.egg-info/entry_points.txt
9
+ scripts/runebox_sync.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ runebox-sync = sync:main
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ """Runebox plugin sync — stdlib only, no dependencies.
3
+
4
+ Subcommands:
5
+ login [--api URL] read an rbx_ API key from stdin, verify it, save it, detect
6
+ installed harnesses, run first sync
7
+ sync [--quiet] sync every logged-in org x enabled harness (the SessionStart
8
+ hook runs this)
9
+ status show orgs, key prefix, per-harness inventory
10
+ harness enable|disable N turn a harness on/off for every logged-in org (N = claude/
11
+ codex/cursor/gemini); disable uninstalls that harness's files
12
+ autosync [--apply] print (or append) a shell-profile snippet that keeps non-Claude
13
+ harnesses synced on every new shell (no SessionStart hook there)
14
+ logout [--remove] forget credentials (--remove also deletes what we installed)
15
+
16
+ Contract with the server (see docs/10-multi-harness/design.md):
17
+ GET /api/registry/whoami -> {org, user, key_name}
18
+ GET /api/registry/catalog?harness=H -> ETag header + {artifacts: [{id, slug, kind,
19
+ version, install: {root, path}}]}
20
+ honors If-None-Match -> 304. harness omitted -> "claude".
21
+ GET /api/registry/artifacts/{id} -> {slug, kind, version, skill_md,
22
+ files: [{path, content}]}
23
+
24
+ Local state:
25
+ $RUNEBOX_DIR (default ~/.runebox)/credentials.json (0600)
26
+ {org: {token, api, disabled?, harnesses: ["claude", "codex", ...]}}
27
+ (harnesses absent on a legacy entry means ["claude"] — v1 behavior)
28
+ $RUNEBOX_DIR/manifest.json
29
+ {org: {harness: {etag, artifacts: {id: {version, slug, kind, paths}}}}}
30
+ (a v1 manifest was {org: {etag, artifacts}} — migrated under "claude" on first load)
31
+
32
+ Invariants: we only ever delete paths recorded in our own manifest (and only under our
33
+ roots); install paths from the server are re-checked for traversal on our side too; any
34
+ failure in --quiet mode exits 0 silently — the hook must never block a session. Adding a
35
+ harness the server can target requires a client release (a new allowlisted root) — by design.
36
+ """
37
+ import argparse
38
+ import json
39
+ import os
40
+ import shutil
41
+ import sys
42
+ import urllib.error
43
+ import urllib.request
44
+ from pathlib import Path
45
+
46
+ RUNEBOX_DIR = Path(os.environ.get("RUNEBOX_DIR", str(Path.home() / ".runebox")))
47
+ DEFAULT_API = os.environ.get("RUNEBOX_API", "https://runebox.ai")
48
+ CREDS = RUNEBOX_DIR / "credentials.json"
49
+ MANIFEST = RUNEBOX_DIR / "manifest.json"
50
+ TIMEOUT = 6 # seconds; the hook path must stay snappy
51
+
52
+ # Install roots the server may target: one per harness, plus "staging" for claude_md (we
53
+ # never auto-overwrite a user's own CLAUDE.md — it's staged and pointed at instead). Each is
54
+ # env-overridable so tests can point every root at a scratch dir. A server "root" outside
55
+ # this allowlist is rejected client-side — adding a harness requires a client release.
56
+ ROOTS = {
57
+ "claude": Path(os.environ.get("CLAUDE_DIR", str(Path.home() / ".claude"))),
58
+ "codex": Path(os.environ.get("CODEX_DIR", str(Path.home() / ".codex"))),
59
+ "cursor": Path(os.environ.get("CURSOR_DIR", str(Path.home() / ".cursor"))),
60
+ "gemini": Path(os.environ.get("GEMINI_DIR", str(Path.home() / ".gemini"))),
61
+ "staging": RUNEBOX_DIR,
62
+ }
63
+ HARNESSES = [h for h in ROOTS if h != "staging"] # detectable / enable-able targets
64
+
65
+
66
+ def _load(path):
67
+ try:
68
+ return json.loads(path.read_text())
69
+ except (FileNotFoundError, json.JSONDecodeError):
70
+ return {}
71
+
72
+
73
+ def _save(path, data, mode=None):
74
+ RUNEBOX_DIR.mkdir(parents=True, exist_ok=True)
75
+ path.write_text(json.dumps(data, indent=2))
76
+ if mode is not None:
77
+ os.chmod(path, mode)
78
+
79
+
80
+ def _migrate_manifest(manifest):
81
+ """v1 manifest shape was {org: {etag, artifacts}} — implicitly Claude-only. v2 nests per
82
+ harness: {org: {harness: {etag, artifacts}}}. Detect the old shape (an "artifacts" key
83
+ sitting directly on the org) and nest it under "claude". Silent, one-time, idempotent."""
84
+ for slug, org_m in manifest.items():
85
+ if "artifacts" in org_m:
86
+ manifest[slug] = {"claude": org_m}
87
+ return manifest
88
+
89
+
90
+ def _harnesses(cred):
91
+ return cred.get("harnesses") or ["claude"]
92
+
93
+
94
+ def _fetch_json(api, token, route, etag=None):
95
+ """Return (status, parsed_json_or_None, etag). 304 -> (304, None, etag)."""
96
+ req = urllib.request.Request(
97
+ api.rstrip("/") + route, headers={"Authorization": "Bearer " + token}
98
+ )
99
+ if etag:
100
+ req.add_header("If-None-Match", etag)
101
+ try:
102
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
103
+ return resp.status, json.loads(resp.read().decode()), resp.headers.get("ETag")
104
+ except urllib.error.HTTPError as e:
105
+ if e.code == 304:
106
+ return 304, None, etag
107
+ raise
108
+
109
+
110
+ def _target(install):
111
+ root_name = install["root"]
112
+ if root_name not in ROOTS:
113
+ raise ValueError("unsafe install root: " + root_name)
114
+ root = ROOTS[root_name].resolve()
115
+ path = (root / install["path"]).resolve()
116
+ if not path.is_relative_to(root):
117
+ raise ValueError("unsafe install path: " + install["path"])
118
+ return path
119
+
120
+
121
+ def _install(item, content):
122
+ """Write an artifact to disk. Returns the list of top-level paths we now own."""
123
+ target = _target(item["install"])
124
+ if item["kind"] == "skill": # a folder: SKILL.md + helper files
125
+ if target.exists():
126
+ shutil.rmtree(target)
127
+ target.mkdir(parents=True)
128
+ (target / "SKILL.md").write_text(content["skill_md"])
129
+ for f in content.get("files") or []:
130
+ p = (target / f["path"]).resolve()
131
+ if not p.is_relative_to(target):
132
+ raise ValueError("unsafe file path in skill: " + f["path"])
133
+ p.parent.mkdir(parents=True, exist_ok=True)
134
+ p.write_text(f["content"])
135
+ return [str(target)]
136
+ target.parent.mkdir(parents=True, exist_ok=True) # every other kind: one .md file
137
+ target.write_text(content["skill_md"])
138
+ return [str(target)]
139
+
140
+
141
+ def _remove(paths):
142
+ for p in paths:
143
+ p = Path(p)
144
+ # never delete outside our roots, even if the manifest was tampered with
145
+ if not any(p.is_relative_to(r.resolve()) for r in ROOTS.values()):
146
+ continue
147
+ if p.is_dir():
148
+ shutil.rmtree(p, ignore_errors=True)
149
+ elif p.exists():
150
+ p.unlink()
151
+
152
+
153
+ def _sync_org_harness(cred, harness, org_h):
154
+ """Sync one (org, harness) pair against org_h = {etag, artifacts}. Returns a summary
155
+ string, or None if nothing changed."""
156
+ status, catalog, etag = _fetch_json(
157
+ cred["api"], cred["token"], f"/api/registry/catalog?harness={harness}", org_h.get("etag")
158
+ )
159
+ if status == 304:
160
+ return None
161
+ wanted = {it["id"]: it for it in catalog["artifacts"]}
162
+ installed = org_h["artifacts"]
163
+ added, updated, removed = 0, 0, 0
164
+ for aid in [a for a in installed if a not in wanted]: # yanked / unpublished / unsupported now
165
+ _remove(installed.pop(aid)["paths"])
166
+ removed += 1
167
+ for aid, item in wanted.items():
168
+ cur = installed.get(aid)
169
+ if cur and cur["version"] == item["version"]:
170
+ continue
171
+ _, content, _ = _fetch_json(cred["api"], cred["token"], "/api/registry/artifacts/" + aid)
172
+ paths = _install(item, content)
173
+ installed[aid] = {
174
+ "version": item["version"], "slug": item["slug"],
175
+ "kind": item["kind"], "paths": paths,
176
+ }
177
+ updated, added = (updated + 1, added) if cur else (updated, added + 1)
178
+ org_h["etag"] = etag
179
+ if not (added or updated or removed):
180
+ return None
181
+ parts = [f"{n} {label}" for n, label in
182
+ ((added, "installed"), (updated, "updated"), (removed, "removed")) if n]
183
+ return ", ".join(parts)
184
+
185
+
186
+ def _sync_org(slug, cred, manifest):
187
+ """Sync one org across every harness it has enabled. Returns a list of summary lines."""
188
+ if cred.get("disabled"):
189
+ return []
190
+ org_m = manifest.setdefault(slug, {})
191
+ lines = []
192
+ for harness in _harnesses(cred):
193
+ org_h = org_m.setdefault(harness, {"etag": None, "artifacts": {}})
194
+ try:
195
+ line = _sync_org_harness(cred, harness, org_h)
196
+ except urllib.error.HTTPError as e:
197
+ if e.code == 401:
198
+ cred["disabled"] = True # print once, then stay quiet until re-login
199
+ return [f"runebox: your access to {slug} was revoked — /runebox:login to reconnect"]
200
+ raise
201
+ if line:
202
+ lines.append(f"runebox [{slug}/{harness}]: {line}")
203
+ return lines
204
+
205
+
206
+ def cmd_sync(quiet):
207
+ creds = _load(CREDS)
208
+ if not creds:
209
+ if not quiet:
210
+ print("runebox: not logged in — run `runebox-sync login`")
211
+ return 0
212
+ manifest = _migrate_manifest(_load(MANIFEST))
213
+ dirty_creds = False
214
+ for slug, cred in creds.items():
215
+ was_disabled = cred.get("disabled")
216
+ try:
217
+ for line in _sync_org(slug, cred, manifest):
218
+ print(line)
219
+ except Exception as e:
220
+ if not quiet:
221
+ print(f"runebox [{slug}]: sync failed: {e}")
222
+ if cred.get("disabled") and not was_disabled:
223
+ dirty_creds = True
224
+ _save(MANIFEST, manifest)
225
+ if dirty_creds:
226
+ _save(CREDS, creds, 0o600)
227
+ return 0
228
+
229
+
230
+ def cmd_login(api):
231
+ key = sys.stdin.read().strip()
232
+ if not key.startswith("rbx_"):
233
+ print("runebox: that doesn't look like an API key (expected rbx_…)")
234
+ return 1
235
+ try:
236
+ _, who, _ = _fetch_json(api, key, "/api/registry/whoami")
237
+ except Exception as e:
238
+ code = getattr(e, "code", None)
239
+ print(f"runebox: login failed ({code or e}) — check the key and try again")
240
+ return 1
241
+ slug = who["org"]
242
+ detected = [h for h in HARNESSES if ROOTS[h].exists()] or ["claude"]
243
+ creds = _load(CREDS)
244
+ creds[slug] = {"token": key, "api": api, "harnesses": detected} # re-login replaces any disabled entry
245
+ _save(CREDS, creds, 0o600)
246
+ print(f"✓ Logged in to {slug} as {who.get('user', '?')} — detected: {', '.join(detected)}")
247
+ return cmd_sync(quiet=False)
248
+
249
+
250
+ def cmd_status():
251
+ creds = _load(CREDS)
252
+ if not creds:
253
+ print("runebox: not logged in — run `runebox-sync login`")
254
+ return 0
255
+ manifest = _migrate_manifest(_load(MANIFEST))
256
+ for slug, cred in creds.items():
257
+ harnesses = _harnesses(cred)
258
+ state = "REVOKED" if cred.get("disabled") else "ok"
259
+ print(f"{slug} [{state}] key {cred['token'][:8]}…")
260
+ org_m = manifest.get(slug, {})
261
+ for harness in harnesses:
262
+ arts = org_m.get(harness, {}).get("artifacts", {})
263
+ kinds = {}
264
+ for a in arts.values():
265
+ kinds[a["kind"]] = kinds.get(a["kind"], 0) + 1
266
+ inventory = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items())) or "nothing installed"
267
+ print(f" {harness}: {inventory}")
268
+ for h in HARNESSES:
269
+ if h not in harnesses and ROOTS[h].exists():
270
+ print(f" hint: {h} detected on disk but not enabled — `runebox-sync harness enable {h}`")
271
+ return 0
272
+
273
+
274
+ def cmd_harness(action, name):
275
+ if name not in HARNESSES:
276
+ print(f"runebox: unknown harness '{name}' — choose from {', '.join(HARNESSES)}")
277
+ return 1
278
+ creds = _load(CREDS)
279
+ if not creds:
280
+ print("runebox: not logged in — run `runebox-sync login` first")
281
+ return 1
282
+ manifest = _migrate_manifest(_load(MANIFEST))
283
+ for slug, cred in creds.items():
284
+ harnesses = cred.setdefault("harnesses", ["claude"])
285
+ if action == "enable":
286
+ if name not in harnesses:
287
+ harnesses.append(name)
288
+ else:
289
+ if name in harnesses:
290
+ harnesses.remove(name)
291
+ org_h = manifest.get(slug, {}).pop(name, None) # uninstall that harness's files now
292
+ if org_h:
293
+ for a in org_h.get("artifacts", {}).values():
294
+ _remove(a["paths"])
295
+ _save(CREDS, creds, 0o600)
296
+ _save(MANIFEST, manifest)
297
+ hint = " — run `runebox-sync sync` to install" if action == "enable" else ""
298
+ print(f"runebox: {action}d {name} for {len(creds)} org(s){hint}")
299
+ return 0
300
+
301
+
302
+ # Non-Claude harnesses have no SessionStart hook to auto-sync from, so v1 (docs/10-multi-
303
+ # harness/design.md) offers a printed shell-profile snippet instead of a per-harness daemon;
304
+ # the 304 fast path makes running it on every shell start cheap.
305
+ AUTOSYNC_SNIPPET = "command -v runebox-sync >/dev/null && (runebox-sync sync --quiet &) 2>/dev/null"
306
+
307
+
308
+ def _shell_profile():
309
+ shell = os.environ.get("SHELL", "")
310
+ if "zsh" in shell:
311
+ return Path.home() / ".zshrc"
312
+ if "bash" in shell:
313
+ return Path.home() / ".bashrc"
314
+ return None
315
+
316
+
317
+ def cmd_autosync(apply):
318
+ print("Add this line to your shell profile (~/.zshrc, ~/.bashrc, …) so Codex/Cursor/Gemini")
319
+ print("CLI stay synced on every new shell, with no Claude Code session-start hook needed:")
320
+ print()
321
+ print(" " + AUTOSYNC_SNIPPET)
322
+ print()
323
+ if not apply:
324
+ print("Run `runebox-sync autosync --apply` to append it for you.")
325
+ return 0
326
+ profile = _shell_profile()
327
+ if profile is None:
328
+ print("runebox: couldn't detect your shell (checked $SHELL) — add the line above by hand.")
329
+ return 1
330
+ existing = profile.read_text() if profile.exists() else ""
331
+ if AUTOSYNC_SNIPPET in existing:
332
+ print(f"runebox: already present in {profile}")
333
+ return 0
334
+ with profile.open("a") as f:
335
+ f.write("\n# Runebox: keep non-Claude harnesses synced\n" + AUTOSYNC_SNIPPET + "\n")
336
+ print(f"runebox: appended to {profile} — restart your shell (or `source {profile}`) to pick it up")
337
+ return 0
338
+
339
+
340
+ def cmd_logout(remove):
341
+ manifest = _migrate_manifest(_load(MANIFEST))
342
+ if remove:
343
+ for org_m in manifest.values():
344
+ for harness_m in org_m.values():
345
+ for a in harness_m.get("artifacts", {}).values():
346
+ _remove(a["paths"])
347
+ MANIFEST.unlink(missing_ok=True)
348
+ CREDS.unlink(missing_ok=True)
349
+ print("runebox: logged out" + (" and removed installed artifacts" if remove else ""))
350
+ return 0
351
+
352
+
353
+ def main():
354
+ p = argparse.ArgumentParser(prog="runebox-sync")
355
+ sub = p.add_subparsers(dest="cmd", required=True)
356
+ lp = sub.add_parser("login")
357
+ lp.add_argument("--api", default=DEFAULT_API)
358
+ sp = sub.add_parser("sync")
359
+ sp.add_argument("--quiet", action="store_true")
360
+ sub.add_parser("status")
361
+ hp = sub.add_parser("harness")
362
+ hsub = hp.add_subparsers(dest="action", required=True)
363
+ for name in ("enable", "disable"):
364
+ hh = hsub.add_parser(name)
365
+ hh.add_argument("name")
366
+ ap = sub.add_parser("autosync")
367
+ ap.add_argument("--apply", action="store_true")
368
+ op = sub.add_parser("logout")
369
+ op.add_argument("--remove", action="store_true")
370
+ a = p.parse_args()
371
+ if a.cmd == "login":
372
+ return cmd_login(a.api)
373
+ if a.cmd == "autosync":
374
+ return cmd_autosync(a.apply)
375
+ if a.cmd == "sync":
376
+ try:
377
+ return cmd_sync(a.quiet)
378
+ except Exception:
379
+ return 0 if a.quiet else 1 # the hook must never block a session
380
+ if a.cmd == "status":
381
+ return cmd_status()
382
+ if a.cmd == "harness":
383
+ return cmd_harness(a.action, a.name)
384
+ return cmd_logout(a.remove)
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+