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.
- runebox_sync-0.2.0/LICENSE +21 -0
- runebox_sync-0.2.0/PKG-INFO +95 -0
- runebox_sync-0.2.0/README.md +84 -0
- runebox_sync-0.2.0/pyproject.toml +27 -0
- runebox_sync-0.2.0/scripts/runebox_sync.egg-info/PKG-INFO +95 -0
- runebox_sync-0.2.0/scripts/runebox_sync.egg-info/SOURCES.txt +9 -0
- runebox_sync-0.2.0/scripts/runebox_sync.egg-info/dependency_links.txt +1 -0
- runebox_sync-0.2.0/scripts/runebox_sync.egg-info/entry_points.txt +2 -0
- runebox_sync-0.2.0/scripts/runebox_sync.egg-info/top_level.txt +1 -0
- runebox_sync-0.2.0/scripts/sync.py +388 -0
- runebox_sync-0.2.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sync
|
|
@@ -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())
|