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,104 @@
1
+ """OpenCode frontmatter generator template."""
2
+
3
+ from typing import Dict, Any
4
+
5
+
6
+ _COLOR_TO_HEX = {
7
+ 'red': '#ef4444',
8
+ 'blue': '#3b82f6',
9
+ 'green': '#22c55e',
10
+ 'yellow': '#eab308',
11
+ 'purple': '#a855f7',
12
+ 'orange': '#f97316',
13
+ 'pink': '#ec4899',
14
+ 'cyan': '#06b6d4',
15
+ 'iris': '#6366f1',
16
+ 'violet': '#8b5cf6',
17
+ 'ruby': '#e11d48',
18
+ 'gold': '#d97706',
19
+ 'gray': '#6b7280',
20
+ 'jade': '#10b981',
21
+ 'lime': '#84cc16',
22
+ 'mint': '#14b8a6',
23
+ }
24
+
25
+
26
+ def render(ctx: dict) -> str:
27
+ """Render YAML frontmatter for an OpenCode agent.
28
+
29
+ ctx keys:
30
+ agent_name: str
31
+ agent_config: dict (from agents.yaml entry)
32
+ model_str: str (resolved model string)
33
+ backend_name: str
34
+ backend: CLIBackend object or None
35
+ """
36
+ agent_config = ctx['agent_config']
37
+ model_str = ctx['model_str']
38
+
39
+ frontmatter = ['---']
40
+ frontmatter.append(f'description: {agent_config["description"]}')
41
+ frontmatter.append(f'mode: {agent_config["mode"]}')
42
+ frontmatter.append(f'model: {model_str}')
43
+
44
+ # Handle permissions
45
+ opencode_config = agent_config.get('opencode', {})
46
+ permission = opencode_config.get('permission', {})
47
+
48
+ if permission:
49
+ frontmatter.append('permission:')
50
+
51
+ # Handle simple permissions
52
+ if 'edit' in permission:
53
+ frontmatter.append(f' edit: {permission["edit"]}')
54
+
55
+ # Handle bash permissions (can be string or dict)
56
+ if 'bash' in permission:
57
+ bash_perm = permission['bash']
58
+ if isinstance(bash_perm, str):
59
+ frontmatter.append(f' bash: {bash_perm}')
60
+ elif isinstance(bash_perm, dict):
61
+ frontmatter.append(' bash:')
62
+ for key, value in bash_perm.items():
63
+ # Properly quote keys with special characters
64
+ if '*' in key or ' ' in key:
65
+ frontmatter.append(f' "{key}": {value}')
66
+ else:
67
+ frontmatter.append(f' {key}: {value}')
68
+
69
+ if 'color' in agent_config:
70
+ color = _COLOR_TO_HEX.get(agent_config['color'], agent_config['color'])
71
+ frontmatter.append(f"color: '{color}'")
72
+
73
+ frontmatter.append('---')
74
+
75
+ return '\n'.join(frontmatter)
76
+
77
+
78
+ def post_process(prompt: str, ctx: dict) -> str:
79
+ """Strip ## Memory section from prompt for OpenCode (doesn't support agent memory)."""
80
+ return _strip_memory_section(prompt)
81
+
82
+
83
+ def _strip_memory_section(content: str) -> str:
84
+ """Strip ## Memory section from content for OpenCode format."""
85
+ lines = content.split('\n')
86
+ result_lines = []
87
+ in_memory_section = False
88
+
89
+ for line in lines:
90
+ if line.startswith('## Memory'):
91
+ in_memory_section = True
92
+ continue
93
+ elif line.startswith('## ') and in_memory_section:
94
+ # New section after Memory, include this line and continue
95
+ in_memory_section = False
96
+ result_lines.append(line)
97
+ elif not in_memory_section:
98
+ result_lines.append(line)
99
+
100
+ # Remove trailing empty lines
101
+ while result_lines and result_lines[-1].strip() == '':
102
+ result_lines.pop()
103
+
104
+ return '\n'.join(result_lines)
@@ -0,0 +1,189 @@
1
+ """Scoped health checks for agent-notes — only touches files we own."""
2
+
3
+ from __future__ import annotations
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .registries.cli_registry import CLIRegistry
8
+ from .domain.cli_backend import CLIBackend
9
+ from .services import installer
10
+ from .state import State, ScopeState, sha256_of
11
+ from .config import BIN_HOME, AGENTS_HOME, DIST_SCRIPTS_DIR, DIST_SKILLS_DIR
12
+
13
+
14
+ def expected_paths_for_install(
15
+ registry: CLIRegistry, scope: str
16
+ ) -> list[tuple[Path, Path, str, str]]:
17
+ """Return list of (source_file, target_file, backend_name, component) tuples
18
+ that SHOULD be installed given the current dist/ tree and registry.
19
+
20
+ Does NOT read state.json — this is "what would be installed if user ran install now".
21
+ """
22
+ expected: list[tuple[Path, Path, str, str]] = []
23
+
24
+ for backend in registry.all():
25
+ for component in installer.COMPONENT_TYPES:
26
+ src = installer.dist_source_for(backend, component)
27
+ dst = installer.target_dir_for(backend, component, scope)
28
+ if src is None or dst is None:
29
+ continue
30
+
31
+ if component == "config":
32
+ fn = installer.config_filename_for(backend)
33
+ if not fn:
34
+ continue
35
+ src_file = src / fn
36
+ if src_file.exists():
37
+ expected.append((src_file, dst / fn, backend.name, component))
38
+ elif component == "skills":
39
+ # Each top-level dir in src is a skill
40
+ for skill_dir in sorted(src.iterdir()):
41
+ if skill_dir.is_dir():
42
+ expected.append((skill_dir, dst / skill_dir.name, backend.name, component))
43
+ else:
44
+ # agents, rules, commands: flat *.md files
45
+ for f in sorted(src.glob("*.md")):
46
+ expected.append((f, dst / f.name, backend.name, component))
47
+
48
+ # Scripts (global only)
49
+ if scope == "global" and DIST_SCRIPTS_DIR.exists():
50
+ for script in sorted(DIST_SCRIPTS_DIR.iterdir()):
51
+ if script.is_file():
52
+ expected.append((script, BIN_HOME / script.name, "scripts", "scripts"))
53
+
54
+ # Universal skills mirror (global only)
55
+ if scope == "global" and DIST_SKILLS_DIR.exists():
56
+ any_backend_has_skills = any(b.supports("skills") for b in registry.all())
57
+ if any_backend_has_skills:
58
+ for skill_dir in sorted(DIST_SKILLS_DIR.iterdir()):
59
+ if skill_dir.is_dir():
60
+ expected.append((skill_dir, AGENTS_HOME / "skills" / skill_dir.name, "universal", "skills"))
61
+
62
+ return expected
63
+
64
+
65
+ def check_missing(scope, registry, issues, fix_actions, scope_state: Optional[ScopeState] = None):
66
+ """Files that exist in dist/ but are not installed.
67
+
68
+ If ``scope_state`` is provided, only flag missing files for backends the
69
+ user actually installed — backends absent from state were opted-out of
70
+ and legitimately have no files on disk. When ``scope_state`` is None,
71
+ fall back to expecting every registry backend (legacy behavior).
72
+ """
73
+ from .doctor import Issue, FixAction # reuse existing classes
74
+ installed_backends: Optional[set[str]] = None
75
+ if scope_state is not None:
76
+ installed_backends = set(scope_state.clis.keys())
77
+
78
+ for src, dst, backend_name, component in expected_paths_for_install(registry, scope):
79
+ # "scripts" / "universal" are shared (not per-CLI-backend); always expected.
80
+ if (installed_backends is not None
81
+ and backend_name not in ("scripts", "universal")
82
+ and backend_name not in installed_backends):
83
+ continue
84
+ if not dst.exists() and not dst.is_symlink():
85
+ issues.append(Issue("missing", str(dst), "Source exists but not installed"))
86
+ fix_actions.append(FixAction("INSTALL", str(dst), f"install {component}"))
87
+
88
+
89
+ def check_broken(scope, registry, issues, fix_actions, scope_state: Optional[ScopeState] = None):
90
+ """Expected files that are broken symlinks."""
91
+ from .doctor import Issue, FixAction
92
+ paths_to_check: set[tuple[Path, Path]] = set()
93
+
94
+ # Expected paths from current dist
95
+ for src, dst, _, _ in expected_paths_for_install(registry, scope):
96
+ paths_to_check.add((src, dst))
97
+
98
+ # Plus state.json paths (may differ from expected if dist changed)
99
+ if scope_state is not None:
100
+ for backend_name, bs in scope_state.clis.items():
101
+ for component_type, items in bs.installed.items():
102
+ for _name, item in items.items():
103
+ paths_to_check.add((Path("?"), Path(item.target)))
104
+
105
+ for _src, path in paths_to_check:
106
+ if path.is_symlink():
107
+ target = path.readlink()
108
+ if not target.is_absolute():
109
+ target = path.parent / target
110
+ if not target.exists():
111
+ issues.append(Issue("broken", str(path), "Symlink target does not exist"))
112
+ # Try to recover the source — if it's in our dist, relink; else delete
113
+ fix_actions.append(FixAction("RELINK", str(path), "reinstall"))
114
+
115
+
116
+ def check_drift(scope, registry, issues, fix_actions, scope_state: Optional[ScopeState] = None):
117
+ """Content drift: regular file (not symlink) whose content differs from source.
118
+
119
+ Only meaningful when install mode was 'copy'. Limit to state.json paths if available.
120
+ """
121
+ from .doctor import Issue, FixAction
122
+ if scope_state is None:
123
+ return # without state.json, we don't know if drift is expected (no manifest)
124
+
125
+ if scope_state.mode != "copy":
126
+ return # symlinks can't drift
127
+
128
+ for backend_name, bs in scope_state.clis.items():
129
+ for component_type, items in bs.installed.items():
130
+ for name, item in items.items():
131
+ p = Path(item.target)
132
+ if not p.exists() or p.is_symlink():
133
+ continue
134
+ # File exists as regular file; compare sha
135
+ try:
136
+ current_sha = sha256_of(p) if p.is_file() else None
137
+ # For directories (skills), we'd need deeper compare — skip for now
138
+ if current_sha and current_sha != item.sha:
139
+ issues.append(Issue("drift", str(p),
140
+ "Content differs from source. Local changes will be lost on update."))
141
+ except OSError:
142
+ pass
143
+
144
+
145
+ def check_stale(scope, scope_state, registry, issues, fix_actions):
146
+ """State-based check: files listed in state.json whose dist source is gone."""
147
+ from .doctor import Issue, FixAction
148
+ if scope_state is None:
149
+ return
150
+
151
+ # Build lookup of what currently exists in dist for each backend
152
+ for backend_name, bs in scope_state.clis.items():
153
+ try:
154
+ backend = registry.get(backend_name)
155
+ except KeyError:
156
+ # Backend was removed entirely — everything is stale
157
+ for component_type, items in bs.installed.items():
158
+ for name, item in items.items():
159
+ issues.append(Issue("stale", str(item.target),
160
+ f"Backend '{backend_name}' no longer exists in agent-notes"))
161
+ fix_actions.append(FixAction("DELETE", str(item.target), "stale"))
162
+ continue
163
+
164
+ for component_type, items in bs.installed.items():
165
+ src_dir = installer.dist_source_for(backend, component_type)
166
+ if src_dir is None:
167
+ continue
168
+ for name, item in items.items():
169
+ # Check if this specific item's source still exists
170
+ if component_type == "config":
171
+ config_fn = installer.config_filename_for(backend)
172
+ if config_fn and (src_dir / config_fn).exists():
173
+ continue # Still exists
174
+ issues.append(Issue("stale", str(item.target),
175
+ f"Config file no longer built for {backend_name}"))
176
+ fix_actions.append(FixAction("DELETE", str(item.target), "stale config"))
177
+ elif component_type == "skills":
178
+ if (src_dir / name).is_dir():
179
+ continue # Still exists
180
+ issues.append(Issue("stale", str(item.target),
181
+ f"Skill '{name}' no longer exists in agent-notes"))
182
+ fix_actions.append(FixAction("DELETE", str(item.target), "stale skill"))
183
+ else:
184
+ # agents, rules, commands: files
185
+ if (src_dir / name).exists():
186
+ continue # Still exists
187
+ issues.append(Issue("stale", str(item.target),
188
+ f"{component_type.title()} '{name}' no longer exists in agent-notes"))
189
+ fix_actions.append(FixAction("DELETE", str(item.target), f"stale {component_type}"))
@@ -0,0 +1,17 @@
1
+ """Domain model layer — pure data classes, no I/O, no agent_notes deps."""
2
+ from .cli_backend import CLIBackend
3
+ from .model import Model
4
+ from .role import Role
5
+ from .agent import AgentSpec
6
+ from .skill import Skill
7
+ from .rule import Rule
8
+ from .state import State, ScopeState, BackendState, InstalledItem
9
+ from .diagnostics import Issue, FixAction, ValidationError, ValidationWarning
10
+ from .diff import ComponentDiff, StateDiff
11
+
12
+ __all__ = [
13
+ "CLIBackend", "Model", "Role", "AgentSpec", "Skill", "Rule",
14
+ "State", "ScopeState", "BackendState", "InstalledItem",
15
+ "Issue", "FixAction", "ValidationError", "ValidationWarning",
16
+ "ComponentDiff", "StateDiff",
17
+ ]
@@ -0,0 +1,34 @@
1
+ """Agent domain type for agent metadata."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional, Dict, Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AgentSpec:
10
+ """Agent configuration from agents.yaml."""
11
+ name: str
12
+ description: str
13
+ role: str
14
+ mode: str # primary, subagent
15
+ color: Optional[str] = None
16
+ effort: Optional[str] = None # low, medium, high
17
+ backends: Dict[str, Dict[str, Any]] = field(default_factory=dict)
18
+ # backends is keyed by CLI backend name (e.g. "claude", "opencode", "copilot", ...).
19
+ # Each value is the per-backend override dict as declared in agents.yaml.
20
+ # Example: {"claude": {"exclude": True}, "opencode": {"mode": "subagent"}}
21
+
22
+ def backend_config(self, backend_name: str) -> Dict[str, Any]:
23
+ """Return per-backend config for backend_name, or {} if none declared."""
24
+ return self.backends.get(backend_name, {}) or {}
25
+
26
+ def excluded_from(self, backend_name: str) -> bool:
27
+ """Return True if the agent is excluded from this backend's build.
28
+
29
+ A backend is excluded when its config has exclude: true. For backward
30
+ compat, also treat the legacy top-level 'claude_exclude' key as meaning
31
+ excluded-from-claude — the loader maps this into backends["claude"]["exclude"].
32
+ """
33
+ cfg = self.backend_config(backend_name)
34
+ return bool(cfg.get("exclude", False))
@@ -0,0 +1,40 @@
1
+ """CLI backend dataclass — pure data model for CLI backend descriptors."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CLIBackend:
11
+ """Represents a single CLI backend loaded from YAML descriptor."""
12
+
13
+ name: str # "claude"
14
+ label: str # "Claude Code"
15
+ global_home: Path # expanded ~/.claude (Path object)
16
+ local_dir: str # ".claude"
17
+ layout: dict[str, str] # {"agents": "agents/", ...}
18
+ features: dict[str, object] # {"agents": True, "frontmatter": "claude", ...}
19
+ global_template: Optional[str] # "global-claude.md" or None
20
+ exclude_flag: Optional[str] = None # "claude_exclude" or None
21
+ strip_memory_section: bool = False
22
+ settings_template: Optional[str] = None
23
+ accepted_providers: tuple[str, ...] = () # new
24
+
25
+ def supports(self, feature: str) -> bool:
26
+ """Return True if the backend has that feature enabled."""
27
+ val = self.features.get(feature)
28
+ return bool(val)
29
+
30
+ def local_path(self) -> Path:
31
+ """Return Path(self.local_dir) relative to cwd — caller decides absolute."""
32
+ return Path(self.local_dir)
33
+
34
+ def first_alias_for(self, model_aliases: dict[str, str]) -> Optional[tuple[str, str]]:
35
+ """Given a model's aliases dict, return (provider, alias) for the first
36
+ of self.accepted_providers that has an alias. None if no compat."""
37
+ for provider in self.accepted_providers:
38
+ if provider in model_aliases:
39
+ return (provider, model_aliases[provider])
40
+ return None
@@ -0,0 +1,29 @@
1
+ """Diagnostics dataclasses — pure data models for issues and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Issue:
7
+ def __init__(self, issue_type: str, file: str, message: str):
8
+ self.type = issue_type
9
+ self.file = file
10
+ self.message = message
11
+
12
+
13
+ class FixAction:
14
+ def __init__(self, action: str, file: str, details: str):
15
+ self.action = action
16
+ self.file = file
17
+ self.details = details
18
+
19
+
20
+ class ValidationError:
21
+ def __init__(self, file_path: str, message: str):
22
+ self.file_path = file_path
23
+ self.message = message
24
+
25
+
26
+ class ValidationWarning:
27
+ def __init__(self, file_path: str, message: str):
28
+ self.file_path = file_path
29
+ self.message = message
@@ -0,0 +1,44 @@
1
+ """Diff dataclasses — pure data models for state comparison."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class ComponentDiff:
10
+ """Diff for one component type (agents, skills, rules, commands, config) within one backend."""
11
+ backend: str
12
+ component: str # "agents" | "skills" | "rules" | "commands" | "config" | "settings"
13
+ added: list[str] = field(default_factory=list) # names/keys present in new, absent in old
14
+ removed: list[str] = field(default_factory=list) # present in old, absent in new
15
+ modified: list[str] = field(default_factory=list) # present in both, sha differs
16
+ unchanged: list[str] = field(default_factory=list) # present in both, sha identical
17
+
18
+ def has_changes(self) -> bool:
19
+ return bool(self.added or self.removed or self.modified)
20
+
21
+ def change_count(self) -> int:
22
+ return len(self.added) + len(self.removed) + len(self.modified)
23
+
24
+
25
+ @dataclass
26
+ class StateDiff:
27
+ """Full diff between two State snapshots."""
28
+ old_version: Optional[str]
29
+ new_version: str
30
+ old_commit: Optional[str]
31
+ new_commit: str
32
+ added_backends: list[str] # present in new, absent in old
33
+ removed_backends: list[str] # present in old, absent in new
34
+ components: list[ComponentDiff] # one per (backend, component-type) that has at least one of {added, removed, modified, unchanged}
35
+
36
+ def has_changes(self) -> bool:
37
+ return (
38
+ bool(self.added_backends) or
39
+ bool(self.removed_backends) or
40
+ any(c.has_changes() for c in self.components)
41
+ )
42
+
43
+ def total_changes(self) -> int:
44
+ return sum(c.change_count() for c in self.components)
@@ -0,0 +1,27 @@
1
+ """Model dataclass — pure data model for model descriptors."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Model:
10
+ id: str # "claude-opus-4-7"
11
+ label: str # "Claude Opus 4.7"
12
+ family: str # "claude", "kimi", "gpt"
13
+ model_class: str # "opus" | "sonnet" | "haiku" | "flash" | "pro"
14
+ aliases: dict[str, str] # {"anthropic": "claude-opus-4-7", ...}
15
+ pricing: dict[str, float] = field(default_factory=dict)
16
+ capabilities: dict[str, bool] = field(default_factory=dict)
17
+
18
+ def has_alias_for(self, provider: str) -> bool:
19
+ return provider in self.aliases
20
+
21
+ def resolve_for_providers(self, providers: list[str]) -> Optional[tuple[str, str]]:
22
+ """Given a CLI's ordered accepted_providers list, return (provider, resolved_id)
23
+ for the first provider that has an alias for this model. None if no compat."""
24
+ for provider in providers:
25
+ if provider in self.aliases:
26
+ return (provider, self.aliases[provider])
27
+ return None
@@ -0,0 +1,13 @@
1
+ """Role dataclass — pure data model for role descriptors."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Role:
9
+ name: str
10
+ label: str
11
+ description: str
12
+ typical_class: str
13
+ color: str = ""
@@ -0,0 +1,13 @@
1
+ """Rule domain type."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Rule:
10
+ """Rule from data/rules/*.md."""
11
+ name: str # filename stem
12
+ path: Path # full path
13
+ title: str # first # heading in the file, or name
@@ -0,0 +1,15 @@
1
+ """Skill domain type."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Skill:
11
+ """Skill from data/skills/<name>/."""
12
+ name: str # directory name
13
+ path: Path # full path to skill dir
14
+ description: str # from SKILL.md frontmatter or first line
15
+ group: Optional[str] = None # from SKILL.md frontmatter (if present)
@@ -0,0 +1,46 @@
1
+ """State dataclasses — pure data models for installation state."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class MemoryConfig:
10
+ backend: str = "local" # "obsidian" | "local" | "none"
11
+ path: str = "" # vault root (obsidian) or memory dir (local). empty = use default
12
+
13
+
14
+ @dataclass
15
+ class InstalledItem:
16
+ sha: str
17
+ target: str
18
+ mode: str
19
+
20
+
21
+ @dataclass
22
+ class BackendState:
23
+ """Installation manifest for one CLI within one scope."""
24
+ role_models: dict[str, str] = field(default_factory=dict) # role_name -> model_id
25
+ installed: dict[str, dict[str, InstalledItem]] = field(default_factory=dict)
26
+ # installed is a dict of component_type -> {filename/key -> InstalledItem}
27
+ # e.g. installed["agents"]["lead.md"] = InstalledItem(...)
28
+
29
+
30
+ @dataclass
31
+ class ScopeState:
32
+ """One install scope: either the single global install or one local-project install."""
33
+ installed_at: str = ""
34
+ updated_at: str = ""
35
+ mode: str = "symlink"
36
+ clis: dict[str, BackendState] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass
40
+ class State:
41
+ """Full agent-notes state."""
42
+ source_path: str = ""
43
+ source_commit: str = ""
44
+ global_install: Optional[ScopeState] = None # JSON key is "global"
45
+ local_installs: dict[str, ScopeState] = field(default_factory=dict) # JSON key is "local"
46
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
@@ -0,0 +1,11 @@
1
+ """Build and read State objects during install/uninstall flows."""
2
+
3
+ # Re-export everything from services
4
+ from .services.install_state_builder import build_install_state, git_head_short, _get_target_path
5
+ from .services.state_store import record_install_state, load_current_state, remove_install_state, clear_state
6
+
7
+ # Backward compatibility
8
+ __all__ = [
9
+ 'build_install_state', 'git_head_short', 'record_install_state',
10
+ 'load_current_state', 'remove_install_state', 'clear_state'
11
+ ]
@@ -0,0 +1,16 @@
1
+ """Registries — load YAML/Markdown descriptors into typed in-memory indexes."""
2
+ from .cli_registry import CLIRegistry, load_registry, default_registry
3
+ from .model_registry import ModelRegistry, load_model_registry, default_model_registry
4
+ from .role_registry import RoleRegistry, load_role_registry, default_role_registry
5
+ from .agent_registry import AgentRegistry, load_agent_registry, default_agent_registry
6
+ from .skill_registry import SkillRegistry, load_skill_registry, default_skill_registry
7
+ from .rule_registry import RuleRegistry, load_rule_registry, default_rule_registry
8
+
9
+ __all__ = [
10
+ "CLIRegistry", "load_registry", "default_registry",
11
+ "ModelRegistry", "load_model_registry", "default_model_registry",
12
+ "RoleRegistry", "load_role_registry", "default_role_registry",
13
+ "AgentRegistry", "load_agent_registry", "default_agent_registry",
14
+ "SkillRegistry", "load_skill_registry", "default_skill_registry",
15
+ "RuleRegistry", "load_rule_registry", "default_rule_registry",
16
+ ]
@@ -0,0 +1,46 @@
1
+ """Shared helpers used by all registries."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ from typing import Any
5
+ import yaml
6
+
7
+
8
+ def load_yaml_file(path: Path) -> dict[str, Any]:
9
+ """Load a single YAML file, wrapping errors with the filename."""
10
+ try:
11
+ return yaml.safe_load(path.read_text()) or {}
12
+ except yaml.YAMLError as e:
13
+ raise ValueError(f"Invalid YAML in {path}: {e}")
14
+ except Exception as e:
15
+ raise ValueError(f"Failed to read {path}: {e}")
16
+
17
+
18
+ def require_fields(
19
+ data: dict,
20
+ required: list[str],
21
+ source: Path,
22
+ msg_template: str = "Missing required field '{field}' in {path}",
23
+ ) -> None:
24
+ """Validate that required top-level fields are present; raise ValueError on miss.
25
+
26
+ msg_template supports {field}, {filename} (basename), and {path} (full path).
27
+ """
28
+ for f in required:
29
+ if f not in data:
30
+ msg = msg_template.format(field=f, filename=source.name, path=source)
31
+ raise ValueError(msg)
32
+
33
+
34
+ def load_yaml_dir(dir_path: Path, required_fields: list[str] | None = None) -> list[tuple[Path, dict]]:
35
+ """Load every *.yaml in dir_path (sorted). Returns list of (path, data) pairs.
36
+ Raises ValueError if dir missing or any YAML invalid.
37
+ """
38
+ if not dir_path.exists():
39
+ raise ValueError(f"Registry directory not found: {dir_path}")
40
+ items = []
41
+ for yaml_file in sorted(dir_path.glob("*.yaml")):
42
+ data = load_yaml_file(yaml_file)
43
+ if required_fields:
44
+ require_fields(data, required_fields, yaml_file)
45
+ items.append((yaml_file, data))
46
+ return items