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,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)
|