agent-notes 2.0.4__py3-none-any.whl

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.
Files changed (162) hide show
  1. agent_notes/VERSION +1 -0
  2. agent_notes/__init__.py +1 -0
  3. agent_notes/__main__.py +4 -0
  4. agent_notes/cli.py +348 -0
  5. agent_notes/commands/__init__.py +27 -0
  6. agent_notes/commands/_install_helpers.py +262 -0
  7. agent_notes/commands/build.py +170 -0
  8. agent_notes/commands/doctor.py +112 -0
  9. agent_notes/commands/info.py +95 -0
  10. agent_notes/commands/install.py +99 -0
  11. agent_notes/commands/list.py +169 -0
  12. agent_notes/commands/memory.py +430 -0
  13. agent_notes/commands/regenerate.py +152 -0
  14. agent_notes/commands/set_role.py +143 -0
  15. agent_notes/commands/uninstall.py +26 -0
  16. agent_notes/commands/update.py +169 -0
  17. agent_notes/commands/validate.py +199 -0
  18. agent_notes/commands/wizard.py +720 -0
  19. agent_notes/config.py +154 -0
  20. agent_notes/data/agents/agents.yaml +352 -0
  21. agent_notes/data/agents/analyst.md +45 -0
  22. agent_notes/data/agents/api-reviewer.md +47 -0
  23. agent_notes/data/agents/architect.md +46 -0
  24. agent_notes/data/agents/coder.md +28 -0
  25. agent_notes/data/agents/database-specialist.md +45 -0
  26. agent_notes/data/agents/debugger.md +47 -0
  27. agent_notes/data/agents/devil.md +47 -0
  28. agent_notes/data/agents/devops.md +38 -0
  29. agent_notes/data/agents/explorer.md +23 -0
  30. agent_notes/data/agents/integrations.md +44 -0
  31. agent_notes/data/agents/lead.md +216 -0
  32. agent_notes/data/agents/performance-profiler.md +44 -0
  33. agent_notes/data/agents/refactorer.md +48 -0
  34. agent_notes/data/agents/reviewer.md +44 -0
  35. agent_notes/data/agents/security-auditor.md +44 -0
  36. agent_notes/data/agents/system-auditor.md +38 -0
  37. agent_notes/data/agents/tech-writer.md +32 -0
  38. agent_notes/data/agents/test-runner.md +36 -0
  39. agent_notes/data/agents/test-writer.md +39 -0
  40. agent_notes/data/cli/claude.yaml +25 -0
  41. agent_notes/data/cli/copilot.yaml +18 -0
  42. agent_notes/data/cli/opencode.yaml +22 -0
  43. agent_notes/data/commands/brainstorm.md +8 -0
  44. agent_notes/data/commands/debug.md +9 -0
  45. agent_notes/data/commands/review.md +10 -0
  46. agent_notes/data/global-claude.md +290 -0
  47. agent_notes/data/global-copilot.md +27 -0
  48. agent_notes/data/global-opencode.md +40 -0
  49. agent_notes/data/hooks/session-context.md.tpl +19 -0
  50. agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
  51. agent_notes/data/models/claude-opus-4-1.yaml +16 -0
  52. agent_notes/data/models/claude-opus-4-5.yaml +16 -0
  53. agent_notes/data/models/claude-opus-4-6.yaml +16 -0
  54. agent_notes/data/models/claude-opus-4-7.yaml +15 -0
  55. agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
  56. agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
  57. agent_notes/data/models/claude-sonnet-4.yaml +16 -0
  58. agent_notes/data/pricing.yaml +33 -0
  59. agent_notes/data/roles/orchestrator.yaml +5 -0
  60. agent_notes/data/roles/reasoner.yaml +5 -0
  61. agent_notes/data/roles/scout.yaml +5 -0
  62. agent_notes/data/roles/worker.yaml +5 -0
  63. agent_notes/data/rules/code-quality.md +9 -0
  64. agent_notes/data/rules/safety.md +10 -0
  65. agent_notes/data/scripts/cost-report +211 -0
  66. agent_notes/data/skills/brainstorming/SKILL.md +57 -0
  67. agent_notes/data/skills/code-review/SKILL.md +64 -0
  68. agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
  69. agent_notes/data/skills/docker-compose/SKILL.md +318 -0
  70. agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
  71. agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
  72. agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
  73. agent_notes/data/skills/git/SKILL.md +87 -0
  74. agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
  75. agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
  76. agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
  77. agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
  78. agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
  79. agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
  80. agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
  81. agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
  82. agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
  83. agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
  84. agent_notes/data/skills/rails-lib/SKILL.md +101 -0
  85. agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
  86. agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
  87. agent_notes/data/skills/rails-models/SKILL.md +459 -0
  88. agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
  89. agent_notes/data/skills/rails-routes/SKILL.md +804 -0
  90. agent_notes/data/skills/rails-style/SKILL.md +538 -0
  91. agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
  92. agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
  93. agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
  94. agent_notes/data/skills/rails-validations/SKILL.md +108 -0
  95. agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
  96. agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
  97. agent_notes/data/skills/rails-views/SKILL.md +413 -0
  98. agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
  99. agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
  100. agent_notes/data/skills/tdd/SKILL.md +57 -0
  101. agent_notes/data/templates/__init__.py +1 -0
  102. agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
  103. agent_notes/data/templates/frontmatter/__init__.py +1 -0
  104. agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
  105. agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
  106. agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
  107. agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
  108. agent_notes/data/templates/frontmatter/claude.py +44 -0
  109. agent_notes/data/templates/frontmatter/opencode.py +104 -0
  110. agent_notes/doctor_checks.py +189 -0
  111. agent_notes/domain/__init__.py +17 -0
  112. agent_notes/domain/agent.py +34 -0
  113. agent_notes/domain/cli_backend.py +40 -0
  114. agent_notes/domain/diagnostics.py +29 -0
  115. agent_notes/domain/diff.py +44 -0
  116. agent_notes/domain/model.py +27 -0
  117. agent_notes/domain/role.py +13 -0
  118. agent_notes/domain/rule.py +13 -0
  119. agent_notes/domain/skill.py +15 -0
  120. agent_notes/domain/state.py +46 -0
  121. agent_notes/install_state.py +11 -0
  122. agent_notes/registries/__init__.py +16 -0
  123. agent_notes/registries/_base.py +46 -0
  124. agent_notes/registries/agent_registry.py +107 -0
  125. agent_notes/registries/cli_registry.py +89 -0
  126. agent_notes/registries/model_registry.py +85 -0
  127. agent_notes/registries/role_registry.py +64 -0
  128. agent_notes/registries/rule_registry.py +80 -0
  129. agent_notes/registries/skill_registry.py +141 -0
  130. agent_notes/services/__init__.py +8 -0
  131. agent_notes/services/diagnostics/__init__.py +47 -0
  132. agent_notes/services/diagnostics/_checks.py +272 -0
  133. agent_notes/services/diagnostics/_display.py +346 -0
  134. agent_notes/services/diagnostics/_fix.py +169 -0
  135. agent_notes/services/diff.py +349 -0
  136. agent_notes/services/fs.py +195 -0
  137. agent_notes/services/install_state_builder.py +210 -0
  138. agent_notes/services/installer.py +293 -0
  139. agent_notes/services/memory_backend.py +155 -0
  140. agent_notes/services/rendering.py +329 -0
  141. agent_notes/services/session_context.py +23 -0
  142. agent_notes/services/settings_writer.py +79 -0
  143. agent_notes/services/state_store.py +249 -0
  144. agent_notes/services/ui.py +419 -0
  145. agent_notes/services/user_config.py +62 -0
  146. agent_notes/services/validation.py +67 -0
  147. agent_notes/state.py +21 -0
  148. agent_notes-2.0.4.dist-info/METADATA +14 -0
  149. agent_notes-2.0.4.dist-info/RECORD +162 -0
  150. agent_notes-2.0.4.dist-info/WHEEL +5 -0
  151. agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
  152. agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
  153. agent_notes-2.0.4.dist-info/top_level.txt +2 -0
  154. tests/conftest.py +20 -0
  155. tests/functional/__init__.py +0 -0
  156. tests/functional/test_build_commands.py +88 -0
  157. tests/functional/test_registries.py +128 -0
  158. tests/integration/__init__.py +0 -0
  159. tests/integration/test_build_output.py +129 -0
  160. tests/plugins/__init__.py +0 -0
  161. tests/plugins/test_agents.py +93 -0
  162. tests/plugins/test_skills.py +77 -0
@@ -0,0 +1,143 @@
1
+ """Set role to model mapping in state.json."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional, List
6
+
7
+ from ..config import Color
8
+
9
+
10
+ def set_role(role_name: str, model_id: str, cli: Optional[str] = None, scope: Optional[str] = None, local: bool = False) -> None:
11
+ """Update role→model assignment in state.json and regenerate affected files.
12
+
13
+ Args:
14
+ role_name: Role to update (orchestrator, reasoner, worker, scout)
15
+ model_id: New model ID (must be in model registry)
16
+ cli: Target CLI name (auto-detect if only one CLI has this role in scope)
17
+ scope: 'global' or 'local' (auto: global if exists, else local)
18
+ local: Shortcut for scope='local'
19
+ """
20
+ from .. import state as state_mod
21
+ from ..state import get_scope, set_scope
22
+ from ..registries.role_registry import load_role_registry
23
+ from ..registries.model_registry import load_model_registry
24
+ from ..registries.cli_registry import load_registry
25
+ from .. import install_state
26
+
27
+ # Load state.json
28
+ current_state = state_mod.load()
29
+ if current_state is None:
30
+ print("No installation found. Run `agent-notes install` first.")
31
+ sys.exit(1)
32
+
33
+ # Determine scope
34
+ if local:
35
+ scope = 'local'
36
+ elif scope is None:
37
+ # Auto-detect: prefer global if exists
38
+ if current_state.global_install is not None:
39
+ scope = 'global'
40
+ elif current_state.local_installs:
41
+ scope = 'local'
42
+ else:
43
+ print("No installation found.")
44
+ sys.exit(1)
45
+
46
+ project_path = Path.cwd() if scope == 'local' else None
47
+ scope_state = get_scope(current_state, scope, project_path)
48
+
49
+ if scope_state is None:
50
+ print(f"No {scope} installation found.")
51
+ sys.exit(1)
52
+
53
+ # Validate role exists
54
+ role_registry = load_role_registry()
55
+ try:
56
+ role = role_registry.get(role_name)
57
+ except KeyError:
58
+ print(f"Unknown role: {role_name}")
59
+ print(f"Available roles: {', '.join(role_registry.names())}")
60
+ sys.exit(1)
61
+
62
+ # Validate model exists and get it
63
+ model_registry = load_model_registry()
64
+ try:
65
+ model = model_registry.get(model_id)
66
+ except KeyError:
67
+ print(f"Unknown model: {model_id}")
68
+ print(f"Available models: {', '.join(model_registry.ids())}")
69
+ sys.exit(1)
70
+
71
+ # Determine target CLI(s)
72
+ registry = load_registry()
73
+
74
+ if cli == "all":
75
+ # Apply to all CLIs where model is compatible
76
+ target_clis = []
77
+ for cli_name in scope_state.clis.keys():
78
+ backend = registry.get(cli_name)
79
+ if backend.first_alias_for(model.aliases) is not None:
80
+ target_clis.append(cli_name)
81
+ else:
82
+ print(f"Warning: Skipping {backend.label} - model {model_id} not compatible")
83
+
84
+ if not target_clis:
85
+ print(f"Model {model_id} is not compatible with any installed CLI")
86
+ sys.exit(1)
87
+
88
+ elif cli is None:
89
+ # Auto-detect: error if ambiguous
90
+ candidates = [name for name in scope_state.clis.keys()
91
+ if role_name in scope_state.clis[name].role_models]
92
+ if len(candidates) == 0:
93
+ # No CLI has this role yet, check all CLIs
94
+ all_candidates = list(scope_state.clis.keys())
95
+ if len(all_candidates) == 1:
96
+ target_clis = all_candidates
97
+ else:
98
+ print(f"Multiple CLIs found: {', '.join(all_candidates)}")
99
+ print("Specify --cli <name> or --cli all")
100
+ sys.exit(1)
101
+ elif len(candidates) == 1:
102
+ target_clis = candidates
103
+ else:
104
+ print(f"Multiple CLIs found with role '{role_name}': {', '.join(candidates)}")
105
+ print("Specify --cli <name> or --cli all")
106
+ sys.exit(1)
107
+ else:
108
+ # Explicit CLI specified
109
+ if cli not in scope_state.clis:
110
+ print(f"CLI '{cli}' not found in {scope} installation")
111
+ print(f"Installed CLIs: {', '.join(scope_state.clis.keys())}")
112
+ sys.exit(1)
113
+
114
+ backend = registry.get(cli)
115
+ if backend.first_alias_for(model.aliases) is None:
116
+ print(f"Model {model_id} is not compatible with {backend.label}")
117
+ print(f"Compatible providers: {', '.join(backend.accepted_providers)}")
118
+ print(f"Model providers: {', '.join(model.aliases.keys())}")
119
+ sys.exit(1)
120
+
121
+ target_clis = [cli]
122
+
123
+ # Update state.json
124
+ for cli_name in target_clis:
125
+ backend_state = scope_state.clis[cli_name]
126
+ backend_state.role_models[role_name] = model_id
127
+ backend = registry.get(cli_name)
128
+ print(f"Updated {backend.label}: {role_name} → {model_id}")
129
+
130
+ # Write back
131
+ install_state.record_install_state(current_state)
132
+ print(f"Wrote {state_mod.state_file()}")
133
+
134
+ # Trigger regenerate
135
+ from ..regenerate import regenerate
136
+
137
+ for cli_name in target_clis:
138
+ backend = registry.get(cli_name)
139
+ print(f"\nRegenerating {backend.label}...")
140
+ regenerate(scope=scope, cli=cli_name, project_path=project_path)
141
+
142
+ print(f"\n{Color.GREEN}Done.{Color.NC} Restart your AI CLI to pick up changes.")
143
+ print(f"Tip: Run `agent-notes regenerate` if you hand-edit state.json in the future.")
@@ -0,0 +1,26 @@
1
+ """Uninstall command."""
2
+
3
+ from pathlib import Path
4
+
5
+ from ..config import Color
6
+ from .. import install_state
7
+
8
+
9
+ def uninstall(local: bool = False) -> None:
10
+ """Remove installed components."""
11
+ print(f"Uninstalling ({'local' if local else 'global'}) ...")
12
+ print("")
13
+
14
+ from ..services import installer
15
+ scope = "local" if local else "global"
16
+ installer.uninstall_all(scope)
17
+
18
+ print("")
19
+ print(f"{Color.GREEN}Done.{Color.NC}")
20
+
21
+ # Remove state for this scope only
22
+ try:
23
+ project_path = Path.cwd() if local else None
24
+ install_state.remove_install_state("local" if local else "global", project_path)
25
+ except Exception as e:
26
+ print(f"{Color.YELLOW}Warning: failed to update state.json: {e}{Color.NC}")
@@ -0,0 +1,169 @@
1
+ """Pull latest changes, rebuild, show diff, and reinstall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from ..config import ROOT, Color, get_version, PKG_DIR
10
+ from .. import install_state
11
+ from ..services import diff as update_diff
12
+
13
+
14
+ def _run_git(args, cwd) -> subprocess.CompletedProcess:
15
+ return subprocess.run(
16
+ ["git", *args], cwd=cwd,
17
+ capture_output=True, text=True, check=True,
18
+ )
19
+
20
+
21
+ def _git_head(repo) -> str:
22
+ try:
23
+ return _run_git(["rev-parse", "HEAD"], repo).stdout.strip()
24
+ except Exception:
25
+ return ""
26
+
27
+
28
+ def _git_pull(repo) -> None:
29
+ _run_git(["pull", "--ff-only"], repo)
30
+
31
+
32
+ def _show_commits(repo, before: str, after: str, limit: int = 5) -> None:
33
+ if not before or not after or before == after:
34
+ return
35
+ try:
36
+ out = _run_git(["log", "--oneline", f"{before}..{after}"], repo).stdout.strip()
37
+ except Exception:
38
+ return
39
+ if not out:
40
+ return
41
+ lines = out.split("\n")
42
+ print(f"{Color.GREEN}Updated{Color.NC} {len(lines)} commits.")
43
+ for line in lines[:limit]:
44
+ print(f" {line}")
45
+ if len(lines) > limit:
46
+ print(f" ... and {len(lines) - limit} more")
47
+ print("")
48
+
49
+
50
+ def update(
51
+ dry_run: bool = False,
52
+ yes: bool = False,
53
+ only: Optional[list[str]] = None,
54
+ since: Optional[str] = None,
55
+ skip_pull: bool = False,
56
+ ) -> None:
57
+ """Pull, rebuild, diff against state.json, prompt, reinstall.
58
+
59
+ - dry_run: show the diff, do NOT reinstall
60
+ - yes: don't prompt, just reinstall if there are changes
61
+ - only: list of component types to include in the diff (agents, skills, rules, commands, config, settings)
62
+ - since: if set, compare against this git sha rather than current state.json (advanced)
63
+ - skip_pull: skip the git pull (useful when user already pulled)
64
+ """
65
+ repo = ROOT
66
+ print("Updating agent-notes...")
67
+ print("")
68
+
69
+ # Step 1: git pull
70
+ if not skip_pull:
71
+ git_dir = repo / ".git"
72
+ if not git_dir.exists():
73
+ print(f"{Color.RED}Error:{Color.NC} Not a git repository. Update requires a git-based install.")
74
+ return
75
+ before = _git_head(repo)
76
+ try:
77
+ _git_pull(repo)
78
+ except subprocess.CalledProcessError:
79
+ print(f"{Color.RED}Error:{Color.NC} Could not fast-forward. Resolve manually: cd {repo} && git status")
80
+ return
81
+ after = _git_head(repo)
82
+ _show_commits(repo, before, after)
83
+ if before == after and before:
84
+ print(f"{Color.GREEN}Already up to date (no new commits).{Color.NC}")
85
+
86
+ # Step 2: rebuild dist/
87
+ print("Rebuilding...")
88
+ try:
89
+ from ..commands.build import build as run_build
90
+ run_build()
91
+ except Exception as e:
92
+ print(f"{Color.RED}Build failed: {e}{Color.NC}")
93
+ return
94
+
95
+ # Step 3: determine which scope to update and compute "new" state
96
+ old_state = install_state.load_current_state()
97
+
98
+ # Determine scope: if CWD has a local install, update that; otherwise update global
99
+ current_project = Path.cwd()
100
+ local_exists = old_state and str(current_project.resolve()) in old_state.local_installs if old_state else False
101
+ global_exists = old_state and old_state.global_install is not None if old_state else False
102
+
103
+ # Default to global unless local exists and global doesn't, or if only local exists
104
+ if local_exists and not global_exists:
105
+ scope = "local"
106
+ project_path = current_project
107
+ elif local_exists and global_exists:
108
+ # Both exist - default to global (could add --local flag in future)
109
+ scope = "global"
110
+ project_path = None
111
+ else:
112
+ # Default to global
113
+ scope = "global"
114
+ project_path = None
115
+
116
+ # Get the existing scope's mode, or default
117
+ if scope == "global" and old_state and old_state.global_install:
118
+ mode = old_state.global_install.mode
119
+ elif scope == "local" and old_state and str(current_project.resolve()) in old_state.local_installs:
120
+ mode = old_state.local_installs[str(current_project.resolve())].mode
121
+ else:
122
+ mode = "symlink" # default
123
+
124
+ new_state = install_state.build_install_state(
125
+ mode=mode,
126
+ scope=scope,
127
+ repo_root=PKG_DIR.parent,
128
+ project_path=project_path,
129
+ )
130
+
131
+ # If `since` is provided, we'd need to stash old state and rebuild from that commit.
132
+ # Keep it minimal for now: `since` only influences the commit label in the diff output.
133
+ if since:
134
+ if old_state is not None:
135
+ old_state.source_commit = since
136
+ else:
137
+ print(f"{Color.YELLOW}Warning: --since provided but no prior state.json; treating as initial install.{Color.NC}")
138
+
139
+ # Step 4: diff
140
+ diff = update_diff.diff_states(old_state, new_state)
141
+ if only:
142
+ diff = update_diff.filter_diff(diff, only=only)
143
+
144
+ # Step 5: render report
145
+ print("")
146
+ print(update_diff.render_diff_report(diff, use_color=Color.NC != ""))
147
+ print("")
148
+
149
+ # Step 6: decide
150
+ if not diff.has_changes():
151
+ print(f"{Color.GREEN}Nothing to apply.{Color.NC}")
152
+ return
153
+
154
+ if dry_run:
155
+ print(f"{Color.CYAN}Dry run — no changes applied.{Color.NC}")
156
+ return
157
+
158
+ if not yes:
159
+ resp = input("Apply these changes? [Y/n] ").strip().lower()
160
+ if resp not in ("", "y", "yes"):
161
+ print("Aborted.")
162
+ return
163
+
164
+ # Step 7: reinstall (use existing install flow — it also writes new state.json)
165
+ # Use the determined scope and mode from the analysis above
166
+ from ..commands.install import install
167
+ local = (scope == "local")
168
+ copy = (mode == "copy")
169
+ install(local=local, copy=copy)
@@ -0,0 +1,199 @@
1
+ """Validate command - lint all agent-notes configs."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Set
6
+
7
+ # Re-export for backward compatibility. New code should import from agent_notes.domain.
8
+ from ..domain.diagnostics import ValidationError, ValidationWarning # noqa: F401
9
+
10
+ from ..services.validation import (
11
+ has_field, get_field, line_count, has_frontmatter, check_unclosed_code_blocks
12
+ )
13
+
14
+
15
+ def validate() -> None:
16
+ """Lint all agent-notes configs."""
17
+ from ..config import (
18
+ Color, ROOT, DIST_CLAUDE_DIR, DIST_OPENCODE_DIR, DIST_GITHUB_DIR,
19
+ DIST_RULES_DIR, find_skill_dirs
20
+ )
21
+
22
+ errors: List[ValidationError] = []
23
+ warnings: List[ValidationWarning] = []
24
+ names: Set[str] = set()
25
+ skill_names: Set[str] = set()
26
+
27
+ # Validate Claude agents
28
+ print("Validating Claude Code agents (dist/claude/agents/*.md) ...")
29
+
30
+ claude_agents_dir = DIST_CLAUDE_DIR / "agents"
31
+ if claude_agents_dir.exists():
32
+ for f in claude_agents_dir.glob("*.md"):
33
+ local_name = f.stem
34
+ lines = line_count(f)
35
+ label = f"dist/claude/agents/{local_name}.md ({lines} lines)"
36
+
37
+ # Frontmatter exists
38
+ if not has_frontmatter(f):
39
+ errors.append(ValidationError(label, "missing frontmatter"))
40
+ continue
41
+
42
+ # Required fields
43
+ for field in ["name", "description", "model"]:
44
+ if not has_field(f, field):
45
+ errors.append(ValidationError(label, f"missing required field: {field}"))
46
+
47
+ # Name matches filename
48
+ fm_name = get_field(f, "name")
49
+ if fm_name and fm_name != local_name:
50
+ errors.append(ValidationError(label, f"name '{fm_name}' does not match filename '{local_name}'"))
51
+
52
+ # Line count
53
+ if lines > 250:
54
+ errors.append(ValidationError(label, "exceeds 250 line limit"))
55
+ elif lines > 80:
56
+ warnings.append(ValidationWarning(label, "over 80 lines (consider trimming)"))
57
+ else:
58
+ print(f" {Color.GREEN}OK{Color.NC} {label}")
59
+
60
+ if fm_name:
61
+ names.add(f"agent:{fm_name}")
62
+
63
+ # Validate OpenCode agents
64
+ print("")
65
+ print("Validating OpenCode agents (dist/opencode/agents/*.md) ...")
66
+
67
+ opencode_agents_dir = DIST_OPENCODE_DIR / "agents"
68
+ if opencode_agents_dir.exists():
69
+ for f in opencode_agents_dir.glob("*.md"):
70
+ local_name = f.stem
71
+ lines = line_count(f)
72
+ label = f"dist/opencode/agents/{local_name}.md ({lines} lines)"
73
+
74
+ if not has_frontmatter(f):
75
+ errors.append(ValidationError(label, "missing frontmatter"))
76
+ continue
77
+
78
+ for field in ["description", "mode", "model"]:
79
+ if not has_field(f, field):
80
+ errors.append(ValidationError(label, f"missing required field: {field}"))
81
+
82
+ if lines > 250:
83
+ errors.append(ValidationError(label, "exceeds 250 line limit"))
84
+ elif lines > 80:
85
+ warnings.append(ValidationWarning(label, "over 80 lines (consider trimming)"))
86
+ else:
87
+ print(f" {Color.GREEN}OK{Color.NC} {label}")
88
+
89
+ # Validate Skills
90
+ print("")
91
+ print("Validating skills (*/SKILL.md) ...")
92
+
93
+ skill_name_regex = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
94
+
95
+ for skill_path in find_skill_dirs():
96
+ skill_name = skill_path.name
97
+ f = skill_path / "SKILL.md"
98
+ if not f.exists():
99
+ continue
100
+
101
+ lines = line_count(f)
102
+ label = f"{skill_name}/SKILL.md ({lines} lines)"
103
+
104
+ if not has_frontmatter(f):
105
+ errors.append(ValidationError(label, "missing frontmatter"))
106
+ continue
107
+
108
+ for field in ["name", "description"]:
109
+ if not has_field(f, field):
110
+ errors.append(ValidationError(label, f"missing required field: {field}"))
111
+
112
+ # Name matches directory
113
+ fm_name = get_field(f, "name")
114
+ if fm_name and fm_name != skill_name:
115
+ errors.append(ValidationError(label, f"name '{fm_name}' does not match directory '{skill_name}'"))
116
+
117
+ # Name format (OpenCode requirement)
118
+ if fm_name and not skill_name_regex.match(fm_name):
119
+ errors.append(ValidationError(label, f"name '{fm_name}' does not match required pattern (lowercase alphanumeric + hyphens)"))
120
+
121
+ print(f" {Color.GREEN}OK{Color.NC} {label}")
122
+
123
+ if fm_name:
124
+ skill_names.add(f"skill:{fm_name}")
125
+
126
+ # Check for duplicate names
127
+ print("")
128
+ print("Checking for duplicates ...")
129
+
130
+ all_names = names | skill_names
131
+ seen = set()
132
+ for name in all_names:
133
+ if name in seen:
134
+ errors.append(ValidationError("Duplicate name", name))
135
+ seen.add(name)
136
+
137
+ if all_names and not any("Duplicate name" in err.file_path for err in errors):
138
+ print(f" {Color.GREEN}OK{Color.NC} No duplicate names ({len(all_names)} total)")
139
+
140
+ # Global config files
141
+ print("")
142
+ print("Checking global config files ...")
143
+
144
+ required_global = [
145
+ DIST_CLAUDE_DIR / "CLAUDE.md",
146
+ DIST_OPENCODE_DIR / "AGENTS.md",
147
+ DIST_GITHUB_DIR / "copilot-instructions.md",
148
+ DIST_RULES_DIR / "code-quality.md",
149
+ DIST_RULES_DIR / "safety.md"
150
+ ]
151
+
152
+ for file_path in required_global:
153
+ rel_path = file_path.relative_to(ROOT)
154
+ if file_path.exists():
155
+ print(f" {Color.GREEN}OK{Color.NC} {rel_path}")
156
+ else:
157
+ errors.append(ValidationError(str(rel_path), "file not found"))
158
+
159
+ # Unclosed code blocks
160
+ print("")
161
+ print("Checking for unclosed code blocks ...")
162
+
163
+ codeblock_ok = True
164
+ for md_file in ROOT.rglob("*.md"):
165
+ # Skip .git and node_modules
166
+ if ".git" in str(md_file) or "node_modules" in str(md_file):
167
+ continue
168
+
169
+ if not check_unclosed_code_blocks(md_file):
170
+ rel_path = md_file.relative_to(ROOT)
171
+ try:
172
+ fence_count = md_file.read_text().count('```')
173
+ errors.append(ValidationError(str(rel_path), f"unclosed code block ({fence_count} fence markers)"))
174
+ codeblock_ok = False
175
+ except (FileNotFoundError, OSError):
176
+ pass
177
+
178
+ if codeblock_ok:
179
+ print(f" {Color.GREEN}OK{Color.NC} Code blocks valid")
180
+
181
+ # Print all errors and warnings
182
+ for error in errors:
183
+ print(f" {Color.RED}FAIL{Color.NC} {error.file_path} — {error.message}")
184
+
185
+ for warning in warnings:
186
+ print(f" {Color.YELLOW}WARN{Color.NC} {warning.file_path} — {warning.message}")
187
+
188
+ # Summary
189
+ print("")
190
+ print("===============================")
191
+ if errors:
192
+ print(f"{Color.RED}{len(errors)} error(s){Color.NC}, {len(warnings)} warning(s)")
193
+ exit(1)
194
+ elif warnings:
195
+ print(f"{Color.GREEN}0 errors{Color.NC}, {Color.YELLOW}{len(warnings)} warning(s){Color.NC}")
196
+ exit(0)
197
+ else:
198
+ print(f"{Color.GREEN}All checks passed.{Color.NC}")
199
+ exit(0)