Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .ruler/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
},
"GitHub": {
"command": "sh",
"args": ["-c", "exec mcp-remote 'https://api.githubcopilot.com/mcp/' --header \"Authorization:Bearer $(gh auth token)\""]
"args": [
"-c",
"exec mcp-remote 'https://api.githubcopilot.com/mcp/' --header \"Authorization:Bearer $(gh auth token)\""
]
},
"MemPalace": {
"command": "python3-mempalace",
Expand Down
263 changes: 65 additions & 198 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ RULES_TARGET_DIR := $(dir $(lastword $(MAKEFILE_LIST))).ruler
SKILLS_SRC_DIR := $(dir $(lastword $(MAKEFILE_LIST)))skills
SKILLS_RULER_DIR := $(dir $(lastword $(MAKEFILE_LIST))).ruler/skills
SKILLS_TARGET_DIRS := $(HOME)/.claude/skills $(HOME)/.cursor/skills $(HOME)/.codex/skills $(HOME)/.roo/skills $(HOME)/.gemini/skills $(HOME)/.agents/skills $(HOME)/.vibe/skills $(HOME)/.config/opencode/skills
SKILLS_STATE_DIR := $(HOME)/.cache/dotagents/skills
SKILLS_MANIFEST_DIR := $(SKILLS_STATE_DIR)/manifests
SKILLS_SPEC_STATE_FILE := $(SKILLS_STATE_DIR)/skills.txt.normalized
SKILLS_FILE := $(dir $(lastword $(MAKEFILE_LIST)))SKILLS.txt
SKILLS_LOCK_FILE := $(dir $(lastword $(MAKEFILE_LIST)))skills-lock.json
SKILLS_EXTERNAL_SOURCE_DIR := $(HOME)/.agents/skills
SKILLS_GLOBAL_LOCK := $(HOME)/.agents/.skill-lock.json

MCP_SRC := $(dir $(lastword $(MAKEFILE_LIST))).ruler/mcp.json
MCP_TARGET_DIRS := $(HOME)/.cursor $(HOME)/.claude $(HOME)/.codex
Expand All @@ -27,8 +27,6 @@ MCP_SETTINGS_TARGETS := $(addsuffix /settings.local.json,$(MCP_TARGET_DIRS)) $(d
DOTDIRS := .agent .agents .amazonq .augment .claude .cursor .gemini .idx .junie .kilocode .kiro .opencode .openhands .pi .qwen .roo .skillz .trae .vibe .vscode .windsurf .zed
DOTDIRS_SRC_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))

SKILLS_FILE := $(dir $(lastword $(MAKEFILE_LIST)))SKILLS.txt

# ====================================================================================
# ROOT TARGETS
# ====================================================================================
Expand Down Expand Up @@ -82,208 +80,77 @@ ruler-rules-copy: ## Copy rules to .ruler directory.
# ====================================================================================
# SKILLS
# ====================================================================================

.PHONY: skills-clean
skills-clean: ## Remove all globally installed skills and cached install state.
@for target in $(SKILLS_TARGET_DIRS); do \
if [ -d "$$target" ]; then \
rm -rf "$$target"/*; \
echo "Cleaned $$target"; \
fi; \
done
@if [ -d "$(SKILLS_STATE_DIR)" ]; then \
rm -rf "$(SKILLS_STATE_DIR)"; \
echo "Cleared $(SKILLS_STATE_DIR)"; \
fi

.PHONY: skills-managed-clean
skills-managed-clean: ## Remove managed external skills recorded from SKILLS.txt.
@manifest_dir="$(SKILLS_MANIFEST_DIR)"; \
if [ -d "$$manifest_dir" ]; then \
for manifest in "$$manifest_dir"/*.skills; do \
if [ ! -f "$$manifest" ]; then \
continue; \
fi; \
while IFS= read -r skill || [ -n "$$skill" ]; do \
if [ -z "$$skill" ]; then \
continue; \
fi; \
for target in $(SKILLS_TARGET_DIRS); do \
if [ -e "$$target/$$skill" ] || [ -L "$$target/$$skill" ]; then \
rm -rf "$$target/$$skill"; \
echo "Removed $$target/$$skill"; \
fi; \
done; \
done < "$$manifest"; \
done; \
fi
@if [ -d "$(SKILLS_STATE_DIR)" ]; then \
rm -rf "$(SKILLS_STATE_DIR)"; \
echo "Cleared $(SKILLS_STATE_DIR)"; \
fi
# External skills are declared in SKILLS.txt and locked in skills-lock.json.

.PHONY: skills-install
skills-install: ## Ensure skills from SKILLS.txt are installed and reconcile managed removals.
@state_dir="$(SKILLS_STATE_DIR)"; \
manifest_dir="$(SKILLS_MANIFEST_DIR)"; \
spec_state="$(SKILLS_SPEC_STATE_FILE)"; \
external_source="$(SKILLS_EXTERNAL_SOURCE_DIR)"; \
tmp_spec=$$(mktemp); \
spec_changed=0; \
failed=0; \
mkdir -p "$$state_dir" "$$manifest_dir" "$$external_source"; \
list_external_skills() { \
if [ -d "$$external_source" ]; then \
find "$$external_source" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec basename {} \; | LC_ALL=C sort -u; \
fi; \
}; \
remove_repo_skills() { \
manifest_file="$$1"; \
if [ ! -f "$$manifest_file" ]; then \
return 0; \
fi; \
while IFS= read -r skill || [ -n "$$skill" ]; do \
if [ -z "$$skill" ]; then \
continue; \
fi; \
for target in $(SKILLS_TARGET_DIRS); do \
if [ -e "$$target/$$skill" ] || [ -L "$$target/$$skill" ]; then \
rm -rf "$$target/$$skill"; \
fi; \
done; \
done < "$$manifest_file"; \
}; \
list_repo_skills() { \
repo="$$1"; \
bun x skills add $$repo --global --yes --list </dev/null 2>&1 \
| sed 's/\x1b\[[0-9;]*m//g' \
| sed 's/\x1b\[?25[hl]//g' \
| sed 's/\x1b\[999D\x1b\[J//g' \
| grep -E '^│[[:space:]]{4}[a-z]' \
| sed 's/^│[[:space:]]*//'; \
}; \
install_repo() { \
repo="$$1"; \
normalized_skills="$$2"; \
manifest_file="$$3"; \
cleanup_old="$$4"; \
if [ "$$cleanup_old" = "1" ]; then \
remove_repo_skills "$$manifest_file"; \
fi; \
if [ -n "$$normalized_skills" ]; then \
skill_args=$$(printf '%s\n' "$$normalized_skills" | tr ',' '\n' | sed '/^$$/d' | while IFS= read -r s; do printf " --skill %s" "$$s"; done); \
echo "Installing selected skills from $$repo..."; \
if bun x skills add $$repo --global --yes $$skill_args </dev/null; then \
printf '%s\n' "$$normalized_skills" | tr ',' '\n' | sed '/^$$/d' | LC_ALL=C sort > "$$manifest_file"; \
echo "✓ Installed $$repo (selective)"; \
else \
echo "✗ Failed to install $$repo (continuing...)"; \
failed=1; \
fi; \
else \
echo "Installing all skills from $$repo..."; \
if bun x skills add $$repo --global --yes </dev/null; then \
list_repo_skills "$$repo" | LC_ALL=C sort > "$$manifest_file"; \
echo "✓ Installed $$repo (all)"; \
else \
echo "✗ Failed to install $$repo (continuing...)"; \
failed=1; \
fi; \
fi; \
}; \
while IFS= read -r raw_line || [ -n "$$raw_line" ]; do \
line=$$(printf '%s' "$$raw_line" | sed 's/^[[:space:]]*//; s/[[:space:]]*$$//'); \
case "$$line" in \
''|\#*) continue ;; \
esac; \
repo=$$(printf '%s\n' "$$line" | awk '{print $$1}'); \
skills_csv=$$(printf '%s\n' "$$line" | awk '{print $$2}'); \
normalized_skills=$$(printf '%s\n' "$$skills_csv" | tr ',' '\n' | sed '/^$$/d' | LC_ALL=C sort | paste -sd, -); \
printf '%s|%s\n' "$$repo" "$$normalized_skills" >> "$$tmp_spec"; \
done < "$(SKILLS_FILE)"; \
if [ "$${DOTAGENTS_FORCE_SKILLS_INSTALL:-0}" = "1" ]; then \
echo "Forcing managed external skill reinstall..."; \
$(MAKE) skills-managed-clean; \
mkdir -p "$$state_dir" "$$manifest_dir" "$$external_source"; \
while IFS='|' read -r repo normalized_skills || [ -n "$$repo$$normalized_skills" ]; do \
manifest_file="$$manifest_dir/$$(printf '%s' "$$repo" | sed 's#[^A-Za-z0-9_.-]#_#g').skills"; \
install_repo "$$repo" "$$normalized_skills" "$$manifest_file" 0; \
done < "$$tmp_spec"; \
else \
if [ -f "$$spec_state" ] && ! cmp -s "$$tmp_spec" "$$spec_state"; then \
echo "Detected SKILLS.txt changes; applying incremental update..."; \
while IFS='|' read -r old_repo old_skills || [ -n "$$old_repo$$old_skills" ]; do \
if ! grep -qF "$$old_repo|" "$$tmp_spec"; then \
echo "Removing $$old_repo (deleted from SKILLS.txt)..."; \
manifest_file="$$manifest_dir/$$(printf '%s' "$$old_repo" | sed 's#[^A-Za-z0-9_.-]#_#g').skills"; \
remove_repo_skills "$$manifest_file"; \
rm -f "$$manifest_file"; \
fi; \
done < "$$spec_state"; \
skills-install: ## Install external skills from skills-lock.json (skips already installed).
@lock="$(SKILLS_LOCK_FILE)"; \
skills_dir="$(SKILLS_EXTERNAL_SOURCE_DIR)"; \
force="$${DOTAGENTS_FORCE_SKILLS_INSTALL:-0}"; \
if [ ! -f "$$lock" ]; then \
echo "Error: $$lock not found"; \
exit 1; \
fi; \
tmp_missing=$$(mktemp); \
jq -r '.skills | to_entries[] | [(.value.sourceUrl // .value.source) + (if .value.ref then "#" + .value.ref else "" end), .key] | @tsv' "$$lock" \
| while read -r source name; do \
if [ "$$force" = "1" ] || { [ ! -e "$$skills_dir/$$name" ] && [ ! -L "$$skills_dir/$$name" ]; }; then \
printf '%s\t%s\n' "$$source" "$$name"; \
fi; \
while IFS='|' read -r repo normalized_skills || [ -n "$$repo$$normalized_skills" ]; do \
manifest_file="$$manifest_dir/$$(printf '%s' "$$repo" | sed 's#[^A-Za-z0-9_.-]#_#g').skills"; \
reinstall_repo=0; \
if [ -f "$$spec_state" ]; then \
old_line=$$(grep "^$$repo|" "$$spec_state" 2>/dev/null || true); \
new_line="$$repo|$$normalized_skills"; \
if [ -n "$$old_line" ] && [ "$$old_line" != "$$new_line" ]; then \
reinstall_repo=1; \
echo "Reinstalling $$repo (skills selection changed)"; \
fi; \
fi; \
if [ "$$reinstall_repo" = "0" ]; then \
if [ ! -f "$$manifest_file" ] || [ ! -s "$$manifest_file" ]; then \
reinstall_repo=1; \
echo "Reinstalling $$repo (missing or empty manifest)"; \
else \
while IFS= read -r skill || [ -n "$$skill" ]; do \
if [ -z "$$skill" ]; then \
continue; \
fi; \
if [ ! -e "$$external_source/$$skill" ] && [ ! -L "$$external_source/$$skill" ]; then \
reinstall_repo=1; \
echo "Reinstalling $$repo (missing $$external_source/$$skill)"; \
break; \
fi; \
done < "$$manifest_file"; \
fi; \
fi; \
if [ "$$reinstall_repo" = "1" ]; then \
install_repo "$$repo" "$$normalized_skills" "$$manifest_file" 1; \
else \
echo "Skipping $$repo (installed state matches SKILLS.txt)"; \
fi; \
done < "$$tmp_spec"; \
done | LC_ALL=C sort > "$$tmp_missing"; \
if [ ! -s "$$tmp_missing" ]; then \
rm -f "$$tmp_missing"; \
echo "All skills from skills-lock.json are installed."; \
exit 0; \
fi; \
cp "$$tmp_spec" "$$spec_state"; \
rm -f "$$tmp_spec"; \
if [ "$$failed" = "1" ]; then \
echo "Some skills failed to install (see above)."; \
else \
echo "Managed external skills are in sync."; \
fi
failed=0; \
for source in $$(cut -f1 "$$tmp_missing" | uniq); do \
names=$$(awk -F'\t' -v s="$$source" '$$1 == s {print $$2}' "$$tmp_missing"); \
skill_args=$$(printf '%s\n' "$$names" | while IFS= read -r n; do printf ' --skill %s' "$$n"; done); \
count=$$(printf '%s\n' "$$names" | wc -l | tr -d ' '); \
echo "Installing $$count skill(s) from $$source..."; \
bun x skills add "$$source" --global --yes $$skill_args </dev/null; \
status=$$?; \
still_missing=$$(printf '%s\n' "$$names" | while IFS= read -r n; do \
if [ ! -e "$$skills_dir/$$n" ] && [ ! -L "$$skills_dir/$$n" ]; then printf ' %s' "$$n"; fi; \
done); \
if [ -n "$$still_missing" ] || { [ "$$force" = "1" ] && [ "$$status" != "0" ]; }; then \
echo "Failed to install from $$source:$$still_missing"; \
failed=1; \
fi; \
done; \
rm -f "$$tmp_missing"; \
exit $$failed

.PHONY: skills-refresh
skills-refresh: ## Force a clean reinstall of external skills and re-sync local repo skills.
@$(MAKE) skills-managed-clean
skills-refresh: ## Force a reinstall of all external skills from skills-lock.json.
@DOTAGENTS_FORCE_SKILLS_INSTALL=1 $(MAKE) skills-install
@$(MAKE) skills-sync

.PHONY: skills-install-repo
skills-install-repo: ## Install a single skill repo. Usage: make skills-install-repo REPO=owner/repo [SKILLS=a,b,c]
@if [ -z "$(REPO)" ]; then \
echo "Error: REPO is required. Usage: make skills-install-repo REPO=owner/repo"; \
.PHONY: skills-lock
skills-lock: ## Regenerate skills-lock.json from SKILLS.txt.
@global_lock="$(SKILLS_GLOBAL_LOCK)"; \
skills_dir="$(SKILLS_EXTERNAL_SOURCE_DIR)"; \
if [ ! -f "$$global_lock" ]; then \
echo "Error: $$global_lock not found; install a skill first (bun x skills add ... --global) to initialize it."; \
exit 1; \
fi
@if [ -n "$(SKILLS)" ]; then \
echo "Installing selected skills from $(REPO) ($(SKILLS))..."; \
bun x skills add $(REPO) --global --yes $(shell echo "$(SKILLS)" | tr ',' '\n' | sed '/^$$/d' | while read -r s; do printf " --skill $$s"; done); \
else \
echo "Installing all skills from $(REPO)..."; \
bun x skills add $(REPO) --global --yes; \
fi
@echo "✓ Installed $(REPO)"
fi; \
if ! jq -e '(.version | type == "number") and (.skills | type == "object")' "$$global_lock" >/dev/null; then \
echo "Error: $$global_lock is missing version/skills fields"; \
exit 1; \
fi; \
ondisk=$$(find "$$skills_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec basename {} \; 2>/dev/null | jq -R . | jq -s .); \
spec=$$(awk '!/^[[:space:]]*(\#|$$)/ {print $$1 "\t" $$2}' "$(SKILLS_FILE)" | jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {repo: .[0], names: (if (.[1] // "") == "" then [] else (.[1] | split(",") | map(select(length > 0))) end)})'); \
jq --argjson ondisk "$$ondisk" --argjson spec "$$spec" '. as $$lock | ($$lock.skills | with_entries(select(.key as $$k | $$ondisk | index($$k))) | with_entries(.value |= ({source, sourceType, sourceUrl, ref, skillPath, skillFolderHash} | with_entries(select(.value != null))))) as $$inst | reduce $$spec[] as $$s ({}; if ($$s.names | length) == 0 then . + ($$inst | with_entries(select(.value.source | ascii_downcase == ($$s.repo | ascii_downcase)))) else reduce $$s.names[] as $$n (.; ($$inst[$$n] // null) as $$hit | .[$$n] = (if $$hit != null and (($$hit.source | ascii_downcase) == ($$s.repo | ascii_downcase)) then $$hit elif .[$$n] != null then .[$$n] elif ($$s.repo | test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$$")) then {source: $$s.repo, sourceType: "github", sourceUrl: "https://github.com/\($$s.repo).git"} else {source: $$s.repo} end)) end) | {version: $$lock.version, skills: (to_entries | sort_by(.key) | from_entries)}' "$$global_lock" > "$(SKILLS_LOCK_FILE).tmp" && mv "$(SKILLS_LOCK_FILE).tmp" "$(SKILLS_LOCK_FILE)"; \
for repo in $$(printf '%s' "$$spec" | jq -r '.[] | select(.names | length == 0) | .repo'); do \
if ! jq -e --arg repo "$$repo" '[.skills[] | select(.source | ascii_downcase == ($$repo | ascii_downcase))] | length > 0' "$(SKILLS_LOCK_FILE)" >/dev/null; then \
echo "warn: no installed skills for install-all repo $$repo; run: bun x skills add $$repo --global --yes --skill '*'"; \
fi; \
done; \
undeclared=$$(jq -r --argjson ondisk "$$ondisk" --slurpfile out "$(SKILLS_LOCK_FILE)" '.skills | keys[] | . as $$k | select(($$ondisk | index($$k)) and ($$out[0].skills | has($$k) | not))' "$$global_lock" | paste -sd, -); \
if [ -n "$$undeclared" ]; then \
echo "warn: installed but not declared in SKILLS.txt: $$undeclared"; \
fi; \
jq -r --argjson ondisk "$$ondisk" '.skills | "skills-lock.json: \(length) skills (\([keys[] | select(. as $$k | $$ondisk | index($$k) | not)] | length) not yet installed)"' "$(SKILLS_LOCK_FILE)"

.PHONY: ruler-skills-copy
ruler-skills-copy: ## Copy skills from root to .ruler/skills directory (overwrites, preserves other files).
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@ Rules for Agents
make sync
```

This runs the full pipeline: prepares `.ruler/`, generates agent instruction files (`~/.claude/CLAUDE.md`, etc.) via Ruler, reconciles managed external skills from `SKILLS.txt` against the actual installed skill directories, then syncs commands, local repo skills, MCP config, and dot directories to `$HOME`.
This runs the full pipeline: prepares `.ruler/`, generates agent instruction files (`~/.claude/CLAUDE.md`, etc.) via Ruler, installs external skills from `skills-lock.json`, then syncs commands, local repo skills, MCP config, and dot directories to `$HOME`.

To force a clean reinstall of external skills, run:
## External skills

External skills are declared in `SKILLS.txt` (canonical: repo plus comma-separated selection, no selection installs all) and locked in `skills-lock.json`, both committed. Installs go through the [skills CLI](https://github.com/vercel-labs/skills); global scope is scripted here because the CLI's own lock restore is project-only (see [vercel-labs/skills#549](https://github.com/vercel-labs/skills/issues/549)).

```bash
# Regenerate skills-lock.json from SKILLS.txt
make skills-lock

# Install everything in the lock that is missing (idempotent)
make skills-install

# Force a reinstall of everything in the lock
make skills-refresh
```

To add skills: edit `SKILLS.txt`, run `make skills-lock && make skills-install && make skills-lock` (the second lock captures resolved metadata), and commit both files. To remove: delete from `SKILLS.txt`, run `bunx skills remove <name> --global --yes`, then `make skills-lock`.

Installs track each source's default branch: the skills CLI does not record commit SHAs in its lock yet, so entries are not pinned to a commit.
Loading