Deliver oss's rules to Claude's user-level rule namespace, and its own permission rules to Claude's settings.
| Action | What happens |
|---|---|
rules/*.md → ~/.claude/rules/oss-<name>.md |
symlink |
| Stale link from an older oss version | refreshed silently |
| Link whose source left the plugin | removed |
| Real file or foreign link at a destination | preserved, reported as conflict |
Anything outside ~/.claude/rules/ |
never touched |
.claude-plugin/permissions-{allow,deny}.json → ~/.claude/settings.json |
merged additively; nothing removed |
Any other key of ~/.claude/settings.json |
never touched |
Why namespaced? Claude loads user rules from one flat directory. Four plugins ship a rules/quality-gates.md; installing source basenames would collide. Every rule installs as <plugin>-<source-name>.md, so quality-gates.md becomes oss-quality-gates.md. The prefix is inert — verified against Claude Code 2.1.220 that a filename prefix changes neither unconditional loading nor paths: frontmatter matching.
Why symlink, not copy? Rules load at session start. A symlink serves the installed version after every upgrade; a copy silently serves stale content forever.
Why does oss deliver only its own rules? Each plugin installs independently. A plugin that shipped a sibling's rules would break standalone installation and couple releases.
NOT for: statusLine, TEAM_PROTOCOL.md, or plugin-cache purging — those are /foundry:setup (requires foundry plugin). Of ~/.claude/settings.json only the permissions.allow and permissions.deny arrays are touched, and only additively. Writes nothing under ~/.codex/.
- No arguments — interactive; prompts before replacing a conflicting destination.
--approve— non-interactive; replaces conflicting destinations without asking. Used bymake sync-claude.
Step 0: Flags
Parse $ARGUMENTS for --approve (case-insensitive) → APPROVE_ALL=true, else false. Any other --<token> → print ! Unknown flag(s): `--<token>`. Supported: `--approve`. and stop.
Step 1: Python
PYTHON_CMD=""
for c in python python3; do
command -v "$c" >/dev/null 2>&1 && "$c" --version 2>/dev/null | grep -qE "Python 3\.(1[0-9]|[2-9][0-9])" && PYTHON_CMD="$c" && break
done
if [ -z "$PYTHON_CMD" ] && command -v py >/dev/null 2>&1 && py -3 --version 2>/dev/null | grep -qE "Python 3\.(1[0-9]|[2-9][0-9])"; then
PYTHON_CMD="py -3"
fi
[ -z "$PYTHON_CMD" ] && { printf "! Python 3.10+ not found — install it and re-run /oss:setup\n"; exit 1; }
printf " Python: %s\n" "$PYTHON_CMD"
Step 2: Dry run — see what would change
$CLAUDE_PLUGIN_ROOT is the installed plugin version. sync_rules.py re-validates it (manifest exists, parses, declares oss; rules/ is a real directory holding at least one non-empty regular *.md) and aborts before touching anything if any check fails.
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}"
python "$PLUGIN_ROOT/bin/sync_rules.py" --plugin-name oss --plugin-root "$PLUGIN_ROOT" --dry-run # timeout: 15000
Non-zero exit → print stderr verbatim and stop; nothing was modified.
Step 3: Apply
Run without --dry-run. Add --approve only when APPROVE_ALL=true:
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}"
python "$PLUGIN_ROOT/bin/sync_rules.py" --plugin-name oss --plugin-root "$PLUGIN_ROOT" # timeout: 15000
Output lines, one per destination: linked: · unchanged: · replaced (--approve): · removed obsolete: · conflict, kept as-is: · FAILED:.
Ownership is proved before any replace or remove: the existing link must resolve under the current plugin root, or under the same ~/.claude/plugins/cache/<marketplace>/oss/ lineage as the current install. A link into another marketplace, another plugin, a source checkout, or a dotfiles tree is never adopted — path substrings are not evidence of ownership.
Step 4: Conflicts
No conflict, kept as-is: lines → skip to Step 5.
APPROVE_ALL=true → conflicts were already replaced in Step 3; skip to Step 5.
Otherwise invoke AskUserQuestion, listing each conflicting destination and its current state:
- (a) Keep them — those rules stay undelivered
- (b) Replace all with plugin links ★ recommended — existing content is overwritten
- (c) Abort — leave everything as it is
On (b), re-run Step 3's command with --approve appended and report the resulting replaced (--approve): lines. On (a) or (c), report which rules remain undelivered.
Step 5: Merge permissions.allow and permissions.deny
This plugin ships its own permissions-allow.json and permissions-deny.json. Claude Code does not read them from the plugin manifest, so without this step they are inert files — the allow entries never suppress a prompt and the deny entries never block anything.
Merge is additive and idempotent: unique keeps entries already present from being duplicated, and no entry is ever removed. Each plugin merges only its own pair.
Create the file when this is a first install, and back it up before any write — a standalone install may reach this step with no ~/.claude/settings.json at all:
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
SETUP_BAK_TS=$(date -u +%Y-%m-%dT%H-%M-%SZ)
echo "$SETUP_BAK_TS" > "${TMPDIR:-/tmp}/oss-setup-bak-ts-${CSID}"
[ -f ~/.claude/settings.json ] || printf '{}\n' > ~/.claude/settings.json # created in-bash — no Write-tool prompt, headless-safe
cp ~/.claude/settings.json "$HOME/.claude/settings.json.bak-${SETUP_BAK_TS}" # timeout: 5000
Report: "Backed up ~/.claude/settings.json → ~/.claude/settings.json.bak-"
Writes merged permissions.allow array:
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}"
_jq_result=$(jq --slurpfile perms "$PLUGIN_ROOT/.claude-plugin/permissions-allow.json" \
'.permissions.allow = ((.permissions.allow // []) + $perms[0] | unique)' \
~/.claude/settings.json) # timeout: 5000
[ $? -eq 0 ] && [ -n "$_jq_result" ] && printf '%s\n' "$_jq_result" > "${TMPDIR:-/tmp}/oss_setup_tmp.json-${CSID}" && mv "${TMPDIR:-/tmp}/oss_setup_tmp.json-${CSID}" ~/.claude/settings.json || { printf "! jq failed merging permissions.allow — settings.json unchanged\n"; exit 1; }
Report: "Added N new permissions.allow entries (M already present)."
Writes merged permissions.deny array:
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}"
_jq_result=$(jq --slurpfile deny "$PLUGIN_ROOT/.claude-plugin/permissions-deny.json" \
'.permissions.deny = ((.permissions.deny // []) + $deny[0] | unique)' \
~/.claude/settings.json) # timeout: 5000
[ $? -eq 0 ] && [ -n "$_jq_result" ] && printf '%s\n' "$_jq_result" > "${TMPDIR:-/tmp}/oss_setup_tmp.json-${CSID}" && mv "${TMPDIR:-/tmp}/oss_setup_tmp.json-${CSID}" ~/.claude/settings.json || { printf "! jq failed merging permissions.deny — settings.json unchanged\n"; exit 1; }
Report: "Added N new permissions.deny entries (M already present)."
Deny wins over allow in Claude Code, so merging both in either order yields the same effective policy.
Validate what was written; settings.json that no longer parses is restored from the backup taken above:
jq empty ~/.claude/settings.json # timeout: 5000
Zero exit → continue to Step 6. Non-zero exit → restore, report the failure, and stop:
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r SETUP_BAK_TS < "${TMPDIR:-/tmp}/oss-setup-bak-ts-${CSID}" 2>/dev/null || SETUP_BAK_TS=$(ls -t "$HOME/.claude/settings.json.bak-"* 2>/dev/null | head -1 | sed 's/.*\.bak-//')
cp "$HOME/.claude/settings.json.bak-${SETUP_BAK_TS}" ~/.claude/settings.json # timeout: 5000
Step 6: Report
- Rules linked: N →
~/.claude/rules/oss-*.md - Unchanged: N · Obsolete removed: N · Conflicts kept: N (name each)
- Permissions: N allow entries added, N deny entries added
- Failures: N (name each; a failure means the platform refused the symlink — rules are never copied as a fallback)
Upgrade path: claude plugin install oss@borda-ai-rig then /oss:setup. Links from the previous version share the install-cache lineage, so they refresh without prompting; a rule dropped in the new version has its link removed. make sync-claude runs /oss:setup --approve headlessly for every installed managed plugin that ships a setup skill, so a normal sync needs no manual step.
Uninstall leaves state behind: Claude Code runs no cleanup hook on uninstall, and neither claude plugin uninstall nor make clear-all removes what setup created. After removing the plugin, delete ~/.claude/rules/oss-*.md by hand — they become dangling symlinks once the plugin cache version is gone.
Testing: setup is reachable only as /oss:setup after the plugin is installed. To exercise it locally, bump version in plugins/cc_oss/.claude-plugin/plugin.json, run claude plugin install oss@borda-ai-rig from the repo root to refresh the cache, then invoke the skill. bin/sync_rules.py is a byte-identical propagated copy of the canonical helper; its regression suite lives beside that canonical copy in the AI-Rig repository.
Follow-up gate omitted — setup is one-shot; Step 6 is terminal output.