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.
- agent_notes/VERSION +1 -0
- agent_notes/__init__.py +1 -0
- agent_notes/__main__.py +4 -0
- agent_notes/cli.py +348 -0
- agent_notes/commands/__init__.py +27 -0
- agent_notes/commands/_install_helpers.py +262 -0
- agent_notes/commands/build.py +170 -0
- agent_notes/commands/doctor.py +112 -0
- agent_notes/commands/info.py +95 -0
- agent_notes/commands/install.py +99 -0
- agent_notes/commands/list.py +169 -0
- agent_notes/commands/memory.py +430 -0
- agent_notes/commands/regenerate.py +152 -0
- agent_notes/commands/set_role.py +143 -0
- agent_notes/commands/uninstall.py +26 -0
- agent_notes/commands/update.py +169 -0
- agent_notes/commands/validate.py +199 -0
- agent_notes/commands/wizard.py +720 -0
- agent_notes/config.py +154 -0
- agent_notes/data/agents/agents.yaml +352 -0
- agent_notes/data/agents/analyst.md +45 -0
- agent_notes/data/agents/api-reviewer.md +47 -0
- agent_notes/data/agents/architect.md +46 -0
- agent_notes/data/agents/coder.md +28 -0
- agent_notes/data/agents/database-specialist.md +45 -0
- agent_notes/data/agents/debugger.md +47 -0
- agent_notes/data/agents/devil.md +47 -0
- agent_notes/data/agents/devops.md +38 -0
- agent_notes/data/agents/explorer.md +23 -0
- agent_notes/data/agents/integrations.md +44 -0
- agent_notes/data/agents/lead.md +216 -0
- agent_notes/data/agents/performance-profiler.md +44 -0
- agent_notes/data/agents/refactorer.md +48 -0
- agent_notes/data/agents/reviewer.md +44 -0
- agent_notes/data/agents/security-auditor.md +44 -0
- agent_notes/data/agents/system-auditor.md +38 -0
- agent_notes/data/agents/tech-writer.md +32 -0
- agent_notes/data/agents/test-runner.md +36 -0
- agent_notes/data/agents/test-writer.md +39 -0
- agent_notes/data/cli/claude.yaml +25 -0
- agent_notes/data/cli/copilot.yaml +18 -0
- agent_notes/data/cli/opencode.yaml +22 -0
- agent_notes/data/commands/brainstorm.md +8 -0
- agent_notes/data/commands/debug.md +9 -0
- agent_notes/data/commands/review.md +10 -0
- agent_notes/data/global-claude.md +290 -0
- agent_notes/data/global-copilot.md +27 -0
- agent_notes/data/global-opencode.md +40 -0
- agent_notes/data/hooks/session-context.md.tpl +19 -0
- agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
- agent_notes/data/models/claude-opus-4-1.yaml +16 -0
- agent_notes/data/models/claude-opus-4-5.yaml +16 -0
- agent_notes/data/models/claude-opus-4-6.yaml +16 -0
- agent_notes/data/models/claude-opus-4-7.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
- agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4.yaml +16 -0
- agent_notes/data/pricing.yaml +33 -0
- agent_notes/data/roles/orchestrator.yaml +5 -0
- agent_notes/data/roles/reasoner.yaml +5 -0
- agent_notes/data/roles/scout.yaml +5 -0
- agent_notes/data/roles/worker.yaml +5 -0
- agent_notes/data/rules/code-quality.md +9 -0
- agent_notes/data/rules/safety.md +10 -0
- agent_notes/data/scripts/cost-report +211 -0
- agent_notes/data/skills/brainstorming/SKILL.md +57 -0
- agent_notes/data/skills/code-review/SKILL.md +64 -0
- agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
- agent_notes/data/skills/docker-compose/SKILL.md +318 -0
- agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
- agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
- agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
- agent_notes/data/skills/git/SKILL.md +87 -0
- agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
- agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
- agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
- agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
- agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
- agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
- agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
- agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
- agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
- agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
- agent_notes/data/skills/rails-lib/SKILL.md +101 -0
- agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
- agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
- agent_notes/data/skills/rails-models/SKILL.md +459 -0
- agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
- agent_notes/data/skills/rails-routes/SKILL.md +804 -0
- agent_notes/data/skills/rails-style/SKILL.md +538 -0
- agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
- agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
- agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
- agent_notes/data/skills/rails-validations/SKILL.md +108 -0
- agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
- agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
- agent_notes/data/skills/rails-views/SKILL.md +413 -0
- agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
- agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
- agent_notes/data/skills/tdd/SKILL.md +57 -0
- agent_notes/data/templates/__init__.py +1 -0
- agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__init__.py +1 -0
- agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/claude.py +44 -0
- agent_notes/data/templates/frontmatter/opencode.py +104 -0
- agent_notes/doctor_checks.py +189 -0
- agent_notes/domain/__init__.py +17 -0
- agent_notes/domain/agent.py +34 -0
- agent_notes/domain/cli_backend.py +40 -0
- agent_notes/domain/diagnostics.py +29 -0
- agent_notes/domain/diff.py +44 -0
- agent_notes/domain/model.py +27 -0
- agent_notes/domain/role.py +13 -0
- agent_notes/domain/rule.py +13 -0
- agent_notes/domain/skill.py +15 -0
- agent_notes/domain/state.py +46 -0
- agent_notes/install_state.py +11 -0
- agent_notes/registries/__init__.py +16 -0
- agent_notes/registries/_base.py +46 -0
- agent_notes/registries/agent_registry.py +107 -0
- agent_notes/registries/cli_registry.py +89 -0
- agent_notes/registries/model_registry.py +85 -0
- agent_notes/registries/role_registry.py +64 -0
- agent_notes/registries/rule_registry.py +80 -0
- agent_notes/registries/skill_registry.py +141 -0
- agent_notes/services/__init__.py +8 -0
- agent_notes/services/diagnostics/__init__.py +47 -0
- agent_notes/services/diagnostics/_checks.py +272 -0
- agent_notes/services/diagnostics/_display.py +346 -0
- agent_notes/services/diagnostics/_fix.py +169 -0
- agent_notes/services/diff.py +349 -0
- agent_notes/services/fs.py +195 -0
- agent_notes/services/install_state_builder.py +210 -0
- agent_notes/services/installer.py +293 -0
- agent_notes/services/memory_backend.py +155 -0
- agent_notes/services/rendering.py +329 -0
- agent_notes/services/session_context.py +23 -0
- agent_notes/services/settings_writer.py +79 -0
- agent_notes/services/state_store.py +249 -0
- agent_notes/services/ui.py +419 -0
- agent_notes/services/user_config.py +62 -0
- agent_notes/services/validation.py +67 -0
- agent_notes/state.py +21 -0
- agent_notes-2.0.4.dist-info/METADATA +14 -0
- agent_notes-2.0.4.dist-info/RECORD +162 -0
- agent_notes-2.0.4.dist-info/WHEEL +5 -0
- agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
- agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
- agent_notes-2.0.4.dist-info/top_level.txt +2 -0
- tests/conftest.py +20 -0
- tests/functional/__init__.py +0 -0
- tests/functional/test_build_commands.py +88 -0
- tests/functional/test_registries.py +128 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_build_output.py +129 -0
- tests/plugins/__init__.py +0 -0
- tests/plugins/test_agents.py +93 -0
- 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
|