@softspark/ai-toolkit 4.30.3 → 4.32.0
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.
- package/CHANGELOG.md +123 -0
- package/README.md +32 -25
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +14 -0
- package/app/agents/frontend-specialist.md +27 -24
- package/app/agents/infrastructure-validator.md +2 -2
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/personas/frontend-lead.md +20 -12
- package/app/rules/claude-toolkit-rules.md +4 -1
- package/app/rules/common/git-team.md +33 -0
- package/app/rules/common/git-workflow.md +6 -20
- package/app/rules/common/performance.md +25 -1
- package/app/rules/common/testing.md +7 -1
- package/app/rules/edit-discipline.md +37 -0
- package/app/skills/app-builder/SKILL.md +37 -6
- package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
- package/app/skills/design-engineering/SKILL.md +87 -102
- package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
- package/app/skills/design-engineering/reference/component-craft.md +28 -0
- package/app/skills/documentation-standards/SKILL.md +1 -1
- package/app/skills/edit-discipline/SKILL.md +50 -0
- package/app/skills/git-conventions/SKILL.md +18 -0
- package/app/skills/output-mode/SKILL.md +41 -0
- package/app/skills/quality-gates/SKILL.md +23 -0
- package/app/skills/review/SKILL.md +10 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
- package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
- package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
- package/kb/procedures/sop-pre-commit.md +96 -0
- package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/copilot-compatibility.md +1 -1
- package/kb/reference/global-install-model.md +16 -3
- package/kb/reference/hooks-catalog.md +5 -3
- package/kb/reference/language-rules.md +28 -10
- package/kb/reference/licensing.md +2 -2
- package/kb/reference/quick-wins-implementation-summary.md +1 -1
- package/kb/reference/supported-tools-registry.md +2 -2
- package/kb/reference/unique-features.md +2 -1
- package/kb/troubleshooting/plugin-double-load.md +1 -1
- package/llms-full.txt +1016 -871
- package/llms.txt +6 -5
- package/manifest.json +2 -2
- package/package.json +6 -2
- package/scripts/benchmark_ecosystem.py +0 -1
- package/scripts/check_split.py +11 -9
- package/scripts/claude_app.py +5 -7
- package/scripts/codex_skill_adapter.py +4 -12
- package/scripts/compile_slm.py +10 -26
- package/scripts/doctor.py +322 -0
- package/scripts/evaluate_skills.py +1 -1
- package/scripts/frontmatter.py +452 -29
- package/scripts/generate_augment_rules.py +4 -4
- package/scripts/generate_cursor_mdc.py +2 -3
- package/scripts/generate_language_rules_skills.py +8 -14
- package/scripts/generate_llms_txt.py +1 -15
- package/scripts/generate_opencode_agents.py +0 -1
- package/scripts/generate_opencode_skills.py +2 -20
- package/scripts/generate_toolkit_rules_skills.py +189 -0
- package/scripts/generate_windsurf_rules.py +0 -1
- package/scripts/generator_base.py +0 -1
- package/scripts/inject_hook_cli.py +15 -2
- package/scripts/inject_mcp_cli.py +1 -2
- package/scripts/install.py +32 -1
- package/scripts/install_git_hooks.py +0 -1
- package/scripts/install_steps/ai_tools.py +65 -25
- package/scripts/install_steps/markers.py +6 -6
- package/scripts/install_steps/skill_scope.py +188 -0
- package/scripts/instruction_core.py +5 -8
- package/scripts/merge-hooks.py +13 -3
- package/scripts/pack_codebase.py +1 -1
- package/scripts/surface_manifest.py +6 -7
- package/scripts/validate.py +181 -12
- /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
22
|
from codex_skill_adapter import build_opencode_skill_text
|
|
23
23
|
from emission import skills_dir
|
|
24
|
-
from frontmatter import frontmatter_field
|
|
24
|
+
from frontmatter import frontmatter_block, frontmatter_field, frontmatter_sections
|
|
25
25
|
from secure_fs import SecureDestination, SecureTransaction, nearest_existing_root
|
|
26
26
|
|
|
27
27
|
|
|
@@ -43,24 +43,6 @@ class PreparedSkill:
|
|
|
43
43
|
return {Path("SKILL.md"), Path(MANAGED_MANIFEST), *self.files}
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def _frontmatter_sections(skill_file: Path) -> dict[str, list[str]]:
|
|
47
|
-
text = skill_file.read_text(encoding="utf-8")
|
|
48
|
-
if not text.startswith("---\n"):
|
|
49
|
-
return {}
|
|
50
|
-
parts = text.split("---", 2)
|
|
51
|
-
if len(parts) != 3:
|
|
52
|
-
return {}
|
|
53
|
-
sections: dict[str, list[str]] = {}
|
|
54
|
-
current: str | None = None
|
|
55
|
-
for line in parts[1].strip("\n").splitlines():
|
|
56
|
-
if line and not line[0].isspace() and ":" in line:
|
|
57
|
-
current = line.split(":", 1)[0].strip()
|
|
58
|
-
sections[current] = [line]
|
|
59
|
-
elif current is not None:
|
|
60
|
-
sections[current].append(line)
|
|
61
|
-
return sections
|
|
62
|
-
|
|
63
|
-
|
|
64
46
|
def _portable_body(skill_file: Path) -> str:
|
|
65
47
|
rendered = build_opencode_skill_text(skill_file)
|
|
66
48
|
if not rendered.startswith("---\n"):
|
|
@@ -72,7 +54,7 @@ def _portable_body(skill_file: Path) -> str:
|
|
|
72
54
|
def _render_skill(skill_file: Path) -> str:
|
|
73
55
|
name = frontmatter_field(skill_file, "name")
|
|
74
56
|
description = frontmatter_field(skill_file, "description")
|
|
75
|
-
sections =
|
|
57
|
+
sections = frontmatter_sections(frontmatter_block(skill_file))
|
|
76
58
|
lines = [
|
|
77
59
|
"---",
|
|
78
60
|
f"name: {name}",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Generate knowledge skills from the top-level rule files in ``app/rules/``.
|
|
7
|
+
|
|
8
|
+
Each ``app/rules/<stem>.md`` becomes ``app/skills/<stem>/SKILL.md``. The
|
|
9
|
+
per-language directories are not touched here; ``generate_language_rules_skills``
|
|
10
|
+
owns those.
|
|
11
|
+
|
|
12
|
+
Why this exists
|
|
13
|
+
---------------
|
|
14
|
+
The rules were reachable only as Claude Code user-level files under
|
|
15
|
+
``~/.claude/rules/``, with the global ``CLAUDE.md`` carrying a pointer to them
|
|
16
|
+
rather than their content. That pointer is a Claude Code idiom. DeepSeek Harness
|
|
17
|
+
reads ``AGENTS.md``, ``AGENTS.local.md``, ``CLAUDE.md`` and ``CLAUDE.local.md``
|
|
18
|
+
and has no rules-directory support at all, so under DSH every one of those rules
|
|
19
|
+
was inert: named in an injected file, never loaded, never applied.
|
|
20
|
+
|
|
21
|
+
Shipping them as skills fixes that without changing the Claude Code surface. A
|
|
22
|
+
skill is discovered by every editor that reads the shared skill catalogue, and
|
|
23
|
+
the catalogue injects each skill's *description* into the session. So the
|
|
24
|
+
description here is written as the rule itself in one imperative line — it is in
|
|
25
|
+
context whether or not the model chooses to load the body.
|
|
26
|
+
|
|
27
|
+
Idempotent: rerunning overwrites the generated ``SKILL.md`` and leaves anything
|
|
28
|
+
else in the skill directory alone.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
python3 scripts/generate_toolkit_rules_skills.py # write all
|
|
32
|
+
python3 scripts/generate_toolkit_rules_skills.py --check # dry-run
|
|
33
|
+
python3 scripts/generate_toolkit_rules_skills.py --rules git-conventions
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from textwrap import dedent
|
|
41
|
+
|
|
42
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
43
|
+
RULES_DIR = ROOT / "app" / "rules"
|
|
44
|
+
SKILLS_DIR = ROOT / "app" / "skills"
|
|
45
|
+
|
|
46
|
+
# The description is the rule. It is injected into every session through the
|
|
47
|
+
# skill catalogue, so it has to state the obligation on its own — a reader who
|
|
48
|
+
# never opens the body should still know what is required. Keep it imperative,
|
|
49
|
+
# keep it one line, and put the reasoning in the file under app/rules/.
|
|
50
|
+
DESCRIPTIONS: dict[str, str] = {
|
|
51
|
+
"edit-discipline": (
|
|
52
|
+
"Change files with the edit and write tools, never by rewriting them "
|
|
53
|
+
"through bash (sed, awk, tee, heredoc, redirection), and show `git diff` "
|
|
54
|
+
"before reporting a file-changing task as done. "
|
|
55
|
+
"Triggers: edit, write, modify, refactor, patch, fix, diff, review changes."
|
|
56
|
+
),
|
|
57
|
+
"git-conventions": (
|
|
58
|
+
"Conventional Commits only (feat, fix, docs, refactor, test, chore); no "
|
|
59
|
+
"AI co-authorship trailer and no AI signature in a commit message. "
|
|
60
|
+
"Triggers: commit, git, message, changelog, release, pull request."
|
|
61
|
+
),
|
|
62
|
+
"output-mode": (
|
|
63
|
+
"Answer concisely: lead with the result, no preamble, tables over prose, "
|
|
64
|
+
"no trailing restatement of a diff the reader can already see. "
|
|
65
|
+
"Triggers: response style, verbosity, summary, explanation, report."
|
|
66
|
+
),
|
|
67
|
+
"quality-gates": (
|
|
68
|
+
"Plan before work over an hour, and hold the gates: ruff clean, mypy "
|
|
69
|
+
"--strict clean, pytest coverage above 70 percent, no secrets in code. "
|
|
70
|
+
"Triggers: quality, lint, mypy, pytest, coverage, gate, definition of done."
|
|
71
|
+
),
|
|
72
|
+
"claude-toolkit-rules": (
|
|
73
|
+
"SoftSpark working agreement: never guess a home directory path, give at "
|
|
74
|
+
"least three alternatives, and apply a devil's advocate critique to "
|
|
75
|
+
"decisions. Triggers: toolkit, conventions, workflow, alternatives, review."
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _strip_frontmatter(text: str) -> str:
|
|
81
|
+
"""Remove YAML frontmatter (--- ... ---) if present."""
|
|
82
|
+
if not text.startswith("---\n"):
|
|
83
|
+
return text
|
|
84
|
+
end = text.find("\n---\n", 4)
|
|
85
|
+
if end == -1:
|
|
86
|
+
return text
|
|
87
|
+
return text[end + len("\n---\n"):]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _title(stem: str) -> str:
|
|
91
|
+
return stem.replace("-", " ").title()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_skill_md(stem: str, rule_file: Path) -> str:
|
|
95
|
+
description = DESCRIPTIONS[stem]
|
|
96
|
+
body = _strip_frontmatter(rule_file.read_text(encoding="utf-8")).strip()
|
|
97
|
+
frontmatter = dedent(
|
|
98
|
+
f"""\
|
|
99
|
+
---
|
|
100
|
+
name: {stem}
|
|
101
|
+
description: "{description}"
|
|
102
|
+
effort: low
|
|
103
|
+
user-invocable: false
|
|
104
|
+
allowed-tools: Read
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
# {_title(stem)}
|
|
108
|
+
|
|
109
|
+
This rule comes from `app/rules/{stem}.md` in ai-toolkit. It applies to
|
|
110
|
+
every task in this workspace, not only when it is loaded.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
return frontmatter + body + "\n"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def discover_rules() -> list[str]:
|
|
118
|
+
"""List top-level rule files that have a description to ship them under.
|
|
119
|
+
|
|
120
|
+
A rule with no entry in DESCRIPTIONS is skipped loudly rather than shipped
|
|
121
|
+
with a generated summary: the description is the part that reaches every
|
|
122
|
+
session, so it is written by a person.
|
|
123
|
+
"""
|
|
124
|
+
if not RULES_DIR.is_dir():
|
|
125
|
+
return []
|
|
126
|
+
found: list[str] = []
|
|
127
|
+
for path in sorted(RULES_DIR.glob("*.md")):
|
|
128
|
+
if path.stem in DESCRIPTIONS:
|
|
129
|
+
found.append(path.stem)
|
|
130
|
+
else:
|
|
131
|
+
print(
|
|
132
|
+
f" SKIP: {path.name} has no entry in DESCRIPTIONS; add one to ship it",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
return found
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def generate(rules: list[str] | None = None, check: bool = False) -> int:
|
|
139
|
+
"""Generate skills. Returns the number written, or that would be written."""
|
|
140
|
+
selected = rules if rules else discover_rules()
|
|
141
|
+
written = 0
|
|
142
|
+
for stem in selected:
|
|
143
|
+
rule_file = RULES_DIR / f"{stem}.md"
|
|
144
|
+
if not rule_file.is_file():
|
|
145
|
+
print(f" SKIP: {stem} (app/rules/{stem}.md missing)", file=sys.stderr)
|
|
146
|
+
continue
|
|
147
|
+
if stem not in DESCRIPTIONS:
|
|
148
|
+
print(f" SKIP: {stem} (no description)", file=sys.stderr)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
skill_dir = SKILLS_DIR / stem
|
|
152
|
+
skill_md = skill_dir / "SKILL.md"
|
|
153
|
+
content = _build_skill_md(stem, rule_file)
|
|
154
|
+
|
|
155
|
+
if check:
|
|
156
|
+
existing = skill_md.read_text(encoding="utf-8") if skill_md.is_file() else ""
|
|
157
|
+
status = "OK" if existing == content else "DIFF"
|
|
158
|
+
print(f" [{status}] {skill_md.relative_to(ROOT)}")
|
|
159
|
+
if existing != content:
|
|
160
|
+
written += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
skill_md.write_text(content, encoding="utf-8")
|
|
165
|
+
print(f" wrote {skill_md.relative_to(ROOT)}")
|
|
166
|
+
written += 1
|
|
167
|
+
return written
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main() -> int:
|
|
171
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
172
|
+
parser.add_argument("--check", action="store_true", help="report drift, write nothing")
|
|
173
|
+
parser.add_argument("--rules", help="comma-separated rule stems")
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
rules = [r.strip() for r in args.rules.split(",")] if args.rules else None
|
|
177
|
+
written = generate(rules, check=args.check)
|
|
178
|
+
if args.check:
|
|
179
|
+
if written:
|
|
180
|
+
print(f"{written} generated skill(s) differ from app/rules/", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
print("toolkit rule skills are up to date")
|
|
183
|
+
return 0
|
|
184
|
+
print(f"{written} toolkit rule skill(s) written")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
raise SystemExit(main())
|
|
@@ -221,8 +221,17 @@ def _entry_source(entry: dict) -> str | None:
|
|
|
221
221
|
return None
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
# Handler fields that change *how* a command hook is scheduled, not *what* it
|
|
225
|
+
# runs. A legacy untagged entry that differs only in these is the same hook,
|
|
226
|
+
# otherwise adding `async` or `timeout` to app/hooks.json would leave every
|
|
227
|
+
# existing install running the old copy and the new copy side by side.
|
|
228
|
+
_SCHEDULING_FIELDS = frozenset({
|
|
229
|
+
"async", "asyncRewake", "timeout", "statusMessage", "shell", "once", "if",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
|
|
224
233
|
def _entry_signature(entry: dict) -> tuple:
|
|
225
|
-
"""Return behavior-defining hook fields without source tags."""
|
|
234
|
+
"""Return behavior-defining hook fields without source or scheduling tags."""
|
|
226
235
|
handlers = []
|
|
227
236
|
for hook in entry.get("hooks", []):
|
|
228
237
|
if not isinstance(hook, dict):
|
|
@@ -230,7 +239,11 @@ def _entry_signature(entry: dict) -> tuple:
|
|
|
230
239
|
continue
|
|
231
240
|
handlers.append(
|
|
232
241
|
tuple(
|
|
233
|
-
sorted(
|
|
242
|
+
sorted(
|
|
243
|
+
(key, value)
|
|
244
|
+
for key, value in hook.items()
|
|
245
|
+
if key != "_source" and key not in _SCHEDULING_FIELDS
|
|
246
|
+
)
|
|
234
247
|
)
|
|
235
248
|
)
|
|
236
249
|
return (entry.get("matcher", ""), tuple(handlers))
|
|
@@ -55,7 +55,6 @@ from __future__ import annotations
|
|
|
55
55
|
|
|
56
56
|
import copy
|
|
57
57
|
import json
|
|
58
|
-
import os
|
|
59
58
|
import re
|
|
60
59
|
import sys
|
|
61
60
|
import urllib.parse
|
|
@@ -163,7 +162,7 @@ def _check_collisions(
|
|
|
163
162
|
|
|
164
163
|
if not force:
|
|
165
164
|
print(
|
|
166
|
-
|
|
165
|
+
"Error: server name collision(s) in .mcp.json (re-run with --force):",
|
|
167
166
|
file=sys.stderr,
|
|
168
167
|
)
|
|
169
168
|
for name, other_source in collisions:
|
package/scripts/install.py
CHANGED
|
@@ -41,6 +41,9 @@ Options:
|
|
|
41
41
|
--persona <p> backend-lead|frontend-lead|devops-eng|junior-dev
|
|
42
42
|
--modules <list> Install specific modules (comma-separated)
|
|
43
43
|
--auto-detect Detect project languages and install matching rules
|
|
44
|
+
--language-skills <s> detected (default): turn off <lang>-rules/<lang>-patterns
|
|
45
|
+
skills for languages no registered project uses;
|
|
46
|
+
all: keep every language skill on (persisted)
|
|
44
47
|
--status Show installed modules and exit
|
|
45
48
|
"""
|
|
46
49
|
from __future__ import annotations
|
|
@@ -50,7 +53,7 @@ import sys
|
|
|
50
53
|
from pathlib import Path
|
|
51
54
|
|
|
52
55
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
53
|
-
from _common import toolkit_dir, app_dir, inject_rule
|
|
56
|
+
from _common import toolkit_dir, app_dir, inject_rule, should_install
|
|
54
57
|
from emission import agent_count as count_agents, skill_count as count_skills
|
|
55
58
|
|
|
56
59
|
# Step modules
|
|
@@ -223,6 +226,7 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
223
226
|
"refresh_base": False,
|
|
224
227
|
"skip_register": False,
|
|
225
228
|
"codex_skills": False,
|
|
229
|
+
"language_skills": "",
|
|
226
230
|
}
|
|
227
231
|
i = 0
|
|
228
232
|
while i < len(argv):
|
|
@@ -283,6 +287,11 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
283
287
|
cfg["skip_register"] = True
|
|
284
288
|
elif arg == "--codex-skills":
|
|
285
289
|
cfg["codex_skills"] = True
|
|
290
|
+
elif arg.startswith("--language-skills="):
|
|
291
|
+
cfg["language_skills"] = arg.split("=", 1)[1]
|
|
292
|
+
elif arg == "--language-skills":
|
|
293
|
+
i += 1
|
|
294
|
+
cfg["language_skills"] = argv[i] if i < len(argv) else ""
|
|
286
295
|
elif arg.startswith("-"):
|
|
287
296
|
print(f"Unknown option: {arg}")
|
|
288
297
|
sys.exit(1)
|
|
@@ -335,6 +344,15 @@ def validate_args(cfg: dict) -> None:
|
|
|
335
344
|
if c and c not in VALID_COMPONENTS:
|
|
336
345
|
errors.append(f"Unknown component in --skip: '{c}' (valid: {', '.join(sorted(VALID_COMPONENTS))})")
|
|
337
346
|
|
|
347
|
+
# Validate --language-skills
|
|
348
|
+
if cfg["language_skills"]:
|
|
349
|
+
from install_steps.skill_scope import VALID_SCOPES
|
|
350
|
+
if cfg["language_skills"] not in VALID_SCOPES:
|
|
351
|
+
errors.append(
|
|
352
|
+
f"Unknown --language-skills value: '{cfg['language_skills']}' "
|
|
353
|
+
f"(valid: {', '.join(VALID_SCOPES)})"
|
|
354
|
+
)
|
|
355
|
+
|
|
338
356
|
# Validate --editors
|
|
339
357
|
if cfg["editors"] and cfg["editors"] != "all":
|
|
340
358
|
for e in cfg["editors"].split(","):
|
|
@@ -848,6 +866,19 @@ def main() -> None:
|
|
|
848
866
|
if is_new:
|
|
849
867
|
print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")
|
|
850
868
|
|
|
869
|
+
# Language knowledge skills are symlinked for every language, but their
|
|
870
|
+
# descriptions load into every session. Scope them to the languages the
|
|
871
|
+
# registered projects actually use (after registration, so a new project's
|
|
872
|
+
# language re-enables its skills in the same run). Read-only in dry-run.
|
|
873
|
+
if should_install("skills", only, skip):
|
|
874
|
+
from install_steps.skill_scope import reconcile_language_skill_overrides, resolve_scope
|
|
875
|
+
reconcile_language_skill_overrides(
|
|
876
|
+
toolkit_dir,
|
|
877
|
+
target_dir / ".claude" / "settings.json",
|
|
878
|
+
scope=resolve_scope(cfg["language_skills"]),
|
|
879
|
+
dry_run=dry_run,
|
|
880
|
+
)
|
|
881
|
+
|
|
851
882
|
# A global install is authoritative for Claude Code, so an uploaded Claude
|
|
852
883
|
# app plugin must not feed it in parallel. Uploading the ZIP re-enables the
|
|
853
884
|
# plugin every time, so this is re-asserted on every install/update rather
|
|
@@ -12,6 +12,7 @@ import subprocess
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from _common import app_dir, inject_section, toolkit_dir
|
|
15
|
+
from frontmatter import FrontmatterError, parse_frontmatter, split_frontmatter
|
|
15
16
|
from codex_skill_adapter import (
|
|
16
17
|
cleanup_codex_skills,
|
|
17
18
|
managed_skill_surface_transaction,
|
|
@@ -965,7 +966,7 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
965
966
|
_apply_extends_config(cwd, merged_config)
|
|
966
967
|
|
|
967
968
|
# Inject language-specific rules into project CLAUDE.md
|
|
968
|
-
_inject_language_rules(cwd, language_modules)
|
|
969
|
+
_inject_language_rules(cwd, language_modules, profile=profile)
|
|
969
970
|
|
|
970
971
|
# Install editor configs only for resolved editors
|
|
971
972
|
_create_local_ai_tool_configs(cwd, rules_dir, resolved_editors,
|
|
@@ -1061,17 +1062,21 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
1061
1062
|
print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
|
|
1062
1063
|
|
|
1063
1064
|
|
|
1064
|
-
def _inject_language_rules(cwd: Path, language_modules: list[str] | None
|
|
1065
|
+
def _inject_language_rules(cwd: Path, language_modules: list[str] | None,
|
|
1066
|
+
profile: str = "standard") -> None:
|
|
1065
1067
|
"""Install Claude language-rule entrypoints for a project.
|
|
1066
1068
|
|
|
1067
1069
|
Per-language rules (``app/rules/<lang>/``) are NOT injected here -- they
|
|
1068
1070
|
ship as ``<lang>-rules`` knowledge skills under ``app/skills/`` and load
|
|
1069
1071
|
contextually via the Agent Skills progressive-disclosure mechanism.
|
|
1070
1072
|
|
|
1071
|
-
Common rules are written as Claude Code
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1073
|
+
Common rules are written as Claude Code rules under ``.claude/rules/``.
|
|
1074
|
+
Each source rule's ``paths`` frontmatter decides whether it is always-on
|
|
1075
|
+
(``**/*``: coding-style, git-workflow, security) or path-scoped (testing,
|
|
1076
|
+
performance load only for matching files). Current Claude Code guidance
|
|
1077
|
+
targets under 200 lines per ``CLAUDE.md`` file; keeping rule bodies out
|
|
1078
|
+
of it and scoping the ones that are file-type specific keeps startup
|
|
1079
|
+
context smaller.
|
|
1075
1080
|
"""
|
|
1076
1081
|
if not language_modules:
|
|
1077
1082
|
return
|
|
@@ -1081,7 +1086,7 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1081
1086
|
if not common_dir.is_dir():
|
|
1082
1087
|
return
|
|
1083
1088
|
|
|
1084
|
-
rule_files = _sync_claude_common_rules(cwd, common_dir)
|
|
1089
|
+
rule_files = _sync_claude_common_rules(cwd, common_dir, profile)
|
|
1085
1090
|
|
|
1086
1091
|
# Detect requested per-language modules so we can name the linked skills
|
|
1087
1092
|
# in the marker block. The modules themselves are not inlined.
|
|
@@ -1095,12 +1100,18 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1095
1100
|
lines: list[str] = ["# Language Rules", ""]
|
|
1096
1101
|
lines.append(
|
|
1097
1102
|
"Common ai-toolkit rules live in `.claude/rules/ai-toolkit-*.md` "
|
|
1098
|
-
"with Claude Code `paths` frontmatter
|
|
1099
|
-
"
|
|
1103
|
+
"with Claude Code `paths` frontmatter instead of expanding this "
|
|
1104
|
+
"CLAUDE.md. Always-on rules load in every session; path-scoped rules "
|
|
1105
|
+
"load only when a matching file is touched."
|
|
1100
1106
|
)
|
|
1101
|
-
if
|
|
1107
|
+
always_on = [p for p, on in rule_files if on]
|
|
1108
|
+
scoped = [p for p, on in rule_files if not on]
|
|
1109
|
+
if always_on:
|
|
1102
1110
|
lines.append("")
|
|
1103
|
-
lines.append("
|
|
1111
|
+
lines.append("Always-on: " + ", ".join(f"`{p}`" for p in always_on) + ".")
|
|
1112
|
+
if scoped:
|
|
1113
|
+
lines.append("")
|
|
1114
|
+
lines.append("Path-scoped: " + ", ".join(f"`{p}`" for p in scoped) + ".")
|
|
1104
1115
|
lines.append("")
|
|
1105
1116
|
lines.append(
|
|
1106
1117
|
"Language-specific rules live in `<lang>-rules` knowledge skills "
|
|
@@ -1132,39 +1143,68 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1132
1143
|
tmp_path.unlink(missing_ok=True)
|
|
1133
1144
|
|
|
1134
1145
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
end = text.find("\n---", 3)
|
|
1138
|
-
if end != -1:
|
|
1139
|
-
return text[end + 4:].lstrip("\n")
|
|
1140
|
-
return text
|
|
1146
|
+
ALWAYS_ON_RULE_PATHS = ["**/*"]
|
|
1147
|
+
|
|
1141
1148
|
|
|
1149
|
+
def _rule_source(src: Path) -> tuple[str, list[str], list[str]]:
|
|
1150
|
+
"""Return ``(body, paths, profiles)`` for one ``app/rules/common`` file.
|
|
1142
1151
|
|
|
1143
|
-
|
|
1152
|
+
``paths`` defaults to always-on (``**/*``); an empty ``profiles`` means
|
|
1153
|
+
the rule ships in every profile. Shipped rules pass ``validate.py`` before
|
|
1154
|
+
release, so a parse failure here is a broken install, not a soft case.
|
|
1155
|
+
"""
|
|
1156
|
+
text = src.read_text(encoding="utf-8")
|
|
1157
|
+
try:
|
|
1158
|
+
_, body = split_frontmatter(text)
|
|
1159
|
+
meta = parse_frontmatter(text)
|
|
1160
|
+
except FrontmatterError as error:
|
|
1161
|
+
raise RuntimeError(f"invalid frontmatter in {src}: {error}") from error
|
|
1162
|
+
paths = meta.get("paths")
|
|
1163
|
+
profiles = meta.get("profiles")
|
|
1164
|
+
return (
|
|
1165
|
+
body,
|
|
1166
|
+
[str(p) for p in paths] if isinstance(paths, list) and paths else list(ALWAYS_ON_RULE_PATHS),
|
|
1167
|
+
[str(p) for p in profiles] if isinstance(profiles, list) else [],
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _sync_claude_common_rules(cwd: Path, common_dir: Path,
|
|
1172
|
+
profile: str = "standard") -> list[tuple[str, bool]]:
|
|
1144
1173
|
"""Write common ai-toolkit rules as Claude Code path-scoped rules.
|
|
1145
1174
|
|
|
1175
|
+
Each source rule's ``paths`` frontmatter decides its scope; rules
|
|
1176
|
+
without one are always-on. A ``profiles`` frontmatter restricts the rule
|
|
1177
|
+
to those install profiles (``git-team`` ships with ``strict`` only); a
|
|
1178
|
+
managed file whose rule no longer applies is removed, so switching profile
|
|
1179
|
+
on a rerun converges. Returns ``(relative path, always_on)`` pairs.
|
|
1180
|
+
|
|
1146
1181
|
Only ``ai-toolkit-*.md`` files are managed. User-authored files in
|
|
1147
1182
|
``.claude/rules/`` are preserved.
|
|
1148
1183
|
"""
|
|
1149
1184
|
rules_dir = cwd / ".claude" / "rules"
|
|
1150
1185
|
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
1151
1186
|
|
|
1152
|
-
source_files =
|
|
1153
|
-
|
|
1187
|
+
source_files: list[tuple[Path, str, list[str]]] = []
|
|
1188
|
+
for src in sorted(common_dir.glob("*.md")):
|
|
1189
|
+
body, paths, gate = _rule_source(src)
|
|
1190
|
+
if gate and profile not in gate:
|
|
1191
|
+
continue
|
|
1192
|
+
source_files.append((src, body, paths))
|
|
1193
|
+
expected = {f"ai-toolkit-{src.stem}.md" for src, _, _ in source_files}
|
|
1154
1194
|
for stale in sorted(rules_dir.glob("ai-toolkit-*.md")):
|
|
1155
1195
|
if stale.name not in expected:
|
|
1156
1196
|
stale.unlink()
|
|
1157
1197
|
|
|
1158
|
-
written: list[str] = []
|
|
1159
|
-
for src in source_files:
|
|
1160
|
-
body =
|
|
1198
|
+
written: list[tuple[str, bool]] = []
|
|
1199
|
+
for src, body, paths in source_files:
|
|
1200
|
+
body = body.lstrip("\n").rstrip()
|
|
1161
1201
|
rel = Path(".claude") / "rules" / f"ai-toolkit-{src.stem}.md"
|
|
1162
1202
|
target = cwd / rel
|
|
1163
1203
|
target.write_text(
|
|
1164
1204
|
"\n".join([
|
|
1165
1205
|
"---",
|
|
1166
1206
|
"paths:",
|
|
1167
|
-
' - "
|
|
1207
|
+
*[f' - "{p}"' for p in paths],
|
|
1168
1208
|
"---",
|
|
1169
1209
|
"",
|
|
1170
1210
|
body,
|
|
@@ -1172,7 +1212,7 @@ def _sync_claude_common_rules(cwd: Path, common_dir: Path) -> list[str]:
|
|
|
1172
1212
|
]),
|
|
1173
1213
|
encoding="utf-8",
|
|
1174
1214
|
)
|
|
1175
|
-
written.append(rel.as_posix())
|
|
1215
|
+
written.append((rel.as_posix(), paths == ALWAYS_ON_RULE_PATHS))
|
|
1176
1216
|
|
|
1177
1217
|
return written
|
|
1178
1218
|
|
|
@@ -214,10 +214,10 @@ def _refresh_url_rules(rules_dir: Path) -> None:
|
|
|
214
214
|
except Exception as exc:
|
|
215
215
|
if rule_file.is_file():
|
|
216
216
|
print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
|
|
217
|
-
print(
|
|
217
|
+
print(" Using cached version.")
|
|
218
218
|
else:
|
|
219
219
|
print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
|
|
220
|
-
print(
|
|
220
|
+
print(" No cached version — rule will be skipped.")
|
|
221
221
|
|
|
222
222
|
|
|
223
223
|
def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
@@ -250,10 +250,10 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
|
250
250
|
except Exception as exc:
|
|
251
251
|
if cached_file.is_file():
|
|
252
252
|
print(f" Warning: could not refresh '{hook_name}' from {url}: {exc}")
|
|
253
|
-
print(
|
|
253
|
+
print(" Using cached version.")
|
|
254
254
|
else:
|
|
255
255
|
print(f" Warning: could not fetch '{hook_name}' from {url}: {exc}")
|
|
256
|
-
print(
|
|
256
|
+
print(" No cached version — hook will be skipped.")
|
|
257
257
|
continue
|
|
258
258
|
|
|
259
259
|
# Re-inject from cached file
|
|
@@ -291,10 +291,10 @@ def refresh_url_mcp(target_dir: str | None = None) -> None:
|
|
|
291
291
|
except Exception as exc:
|
|
292
292
|
if cached_file.is_file():
|
|
293
293
|
print(f" Warning: could not refresh '{template_name}' from {url}: {exc}")
|
|
294
|
-
print(
|
|
294
|
+
print(" Using cached version.")
|
|
295
295
|
else:
|
|
296
296
|
print(f" Warning: could not fetch '{template_name}' from {url}: {exc}")
|
|
297
|
-
print(
|
|
297
|
+
print(" No cached version — template will be skipped.")
|
|
298
298
|
continue
|
|
299
299
|
|
|
300
300
|
if cached_file.is_file():
|