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,169 @@
|
|
|
1
|
+
"""Fix actions for agent-notes diagnostics."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ...domain.diagnostics import Issue, FixAction
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def do_fix(issues: List[Issue], fix_actions: List[FixAction]) -> bool:
|
|
10
|
+
"""Apply fixes with user confirmation and safety guards."""
|
|
11
|
+
from ... import install_state
|
|
12
|
+
from ...config import Color
|
|
13
|
+
|
|
14
|
+
non_build = [i for i in issues if i.type != "build_stale"]
|
|
15
|
+
if non_build and all(i.type == "missing_group" for i in non_build):
|
|
16
|
+
print(f"Not installed. Run '{Color.CYAN}agent-notes install{Color.NC}' to set up.")
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
if not fix_actions:
|
|
20
|
+
print(f"{Color.GREEN}No fixes needed.{Color.NC}")
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
print("The following changes will be made:")
|
|
24
|
+
print("")
|
|
25
|
+
|
|
26
|
+
# Safety check: verify DELETE actions are safe
|
|
27
|
+
state = install_state.load_current_state()
|
|
28
|
+
safe_delete_paths = set()
|
|
29
|
+
if state is not None:
|
|
30
|
+
# All paths in state.json are safe to delete
|
|
31
|
+
# Check global install
|
|
32
|
+
if state.global_install:
|
|
33
|
+
for backend_name, bs in state.global_install.clis.items():
|
|
34
|
+
for component_type, items in bs.installed.items():
|
|
35
|
+
for name, item in items.items():
|
|
36
|
+
safe_delete_paths.add(str(Path(item.target)))
|
|
37
|
+
|
|
38
|
+
# Check local installs
|
|
39
|
+
for project_path, scope_state in state.local_installs.items():
|
|
40
|
+
for backend_name, bs in scope_state.clis.items():
|
|
41
|
+
for component_type, items in bs.installed.items():
|
|
42
|
+
for name, item in items.items():
|
|
43
|
+
safe_delete_paths.add(str(Path(item.target)))
|
|
44
|
+
|
|
45
|
+
for action in fix_actions:
|
|
46
|
+
if action.action == "DELETE":
|
|
47
|
+
file_path = Path(action.file)
|
|
48
|
+
# Safety check: only allow DELETE if path is in state.json or is a symlink to our dist/
|
|
49
|
+
if str(file_path) not in safe_delete_paths:
|
|
50
|
+
if file_path.is_symlink():
|
|
51
|
+
target = file_path.readlink()
|
|
52
|
+
if not target.is_absolute():
|
|
53
|
+
target = file_path.parent / target
|
|
54
|
+
# Check if symlink target is within our dist/ directory
|
|
55
|
+
try:
|
|
56
|
+
from ...config import DIST_DIR
|
|
57
|
+
target_resolved = target.resolve()
|
|
58
|
+
dist_resolved = DIST_DIR.resolve()
|
|
59
|
+
if not str(target_resolved).startswith(str(dist_resolved)):
|
|
60
|
+
print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
|
|
61
|
+
print(f" Symlink target {target} is not in agent-notes dist/")
|
|
62
|
+
print(f" This appears to be a third-party file. Skipping for safety.")
|
|
63
|
+
continue
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
|
|
66
|
+
print(f" Cannot verify symlink target safety. Skipping.")
|
|
67
|
+
continue
|
|
68
|
+
else:
|
|
69
|
+
print(f" {Color.RED}UNSAFE DELETE BLOCKED:{Color.NC} {action.file}")
|
|
70
|
+
print(f" File not in state.json and not a symlink to our dist/")
|
|
71
|
+
print(f" This may be a user file. Skipping for safety.")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
print(f" {Color.RED}DELETE{Color.NC} {action.file} ({action.details})")
|
|
75
|
+
elif action.action == "RELINK":
|
|
76
|
+
print(f" {Color.CYAN}RELINK{Color.NC} {action.file} ({action.details})")
|
|
77
|
+
elif action.action == "INSTALL":
|
|
78
|
+
print(f" {Color.GREEN}INSTALL{Color.NC} {action.file} ({action.details})")
|
|
79
|
+
elif action.action == "BUILD":
|
|
80
|
+
print(f" {Color.CYAN}BUILD{Color.NC} {action.file} ({action.details})")
|
|
81
|
+
|
|
82
|
+
print("")
|
|
83
|
+
response = input("Proceed? [y/N] ")
|
|
84
|
+
|
|
85
|
+
if response.lower() != 'y':
|
|
86
|
+
print("Aborted.")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
print("")
|
|
90
|
+
print("Applying fixes...")
|
|
91
|
+
|
|
92
|
+
needs_install = False
|
|
93
|
+
needs_build = False
|
|
94
|
+
|
|
95
|
+
for action in fix_actions:
|
|
96
|
+
if action.action == "DELETE":
|
|
97
|
+
file_path = Path(action.file)
|
|
98
|
+
# Recheck safety (same logic as above)
|
|
99
|
+
if str(file_path) not in safe_delete_paths:
|
|
100
|
+
if file_path.is_symlink():
|
|
101
|
+
target = file_path.readlink()
|
|
102
|
+
if not target.is_absolute():
|
|
103
|
+
target = file_path.parent / target
|
|
104
|
+
try:
|
|
105
|
+
from ...config import DIST_DIR
|
|
106
|
+
target_resolved = target.resolve()
|
|
107
|
+
dist_resolved = DIST_DIR.resolve()
|
|
108
|
+
if not str(target_resolved).startswith(str(dist_resolved)):
|
|
109
|
+
print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
|
|
110
|
+
continue
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
|
|
113
|
+
continue
|
|
114
|
+
else:
|
|
115
|
+
print(f" {Color.RED}SKIPPED{Color.NC} {action.file} (unsafe)")
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if file_path.exists() or file_path.is_symlink():
|
|
119
|
+
if file_path.is_symlink():
|
|
120
|
+
file_path.unlink()
|
|
121
|
+
elif file_path.is_dir():
|
|
122
|
+
import shutil
|
|
123
|
+
shutil.rmtree(file_path)
|
|
124
|
+
else:
|
|
125
|
+
file_path.unlink()
|
|
126
|
+
print(f" {Color.RED}DELETED{Color.NC} {action.file}")
|
|
127
|
+
|
|
128
|
+
elif action.action == "RELINK":
|
|
129
|
+
# Extract source from details
|
|
130
|
+
if "symlink to " in action.details:
|
|
131
|
+
source_file_str = action.details.split("symlink to ")[1]
|
|
132
|
+
source_file = Path(source_file_str)
|
|
133
|
+
|
|
134
|
+
if source_file.exists():
|
|
135
|
+
file_path = Path(action.file)
|
|
136
|
+
# Backup original
|
|
137
|
+
if file_path.exists() and not file_path.is_symlink():
|
|
138
|
+
backup_path = Path(str(file_path) + ".bak")
|
|
139
|
+
file_path.rename(backup_path)
|
|
140
|
+
|
|
141
|
+
if file_path.exists():
|
|
142
|
+
file_path.unlink()
|
|
143
|
+
|
|
144
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
file_path.symlink_to(source_file.resolve())
|
|
146
|
+
print(f" {Color.CYAN}RELINKED{Color.NC} {action.file}")
|
|
147
|
+
else:
|
|
148
|
+
print(f" {Color.RED}FAILED{Color.NC} {action.file} (source not found: {source_file})")
|
|
149
|
+
|
|
150
|
+
elif action.action == "INSTALL":
|
|
151
|
+
needs_install = True
|
|
152
|
+
|
|
153
|
+
elif action.action == "BUILD":
|
|
154
|
+
needs_build = True
|
|
155
|
+
|
|
156
|
+
# Handle bulk operations
|
|
157
|
+
if needs_install:
|
|
158
|
+
print(f" {Color.GREEN}RUNNING{Color.NC} install to install missing components...")
|
|
159
|
+
# Invocation is deferred to the caller (commands layer) — services must
|
|
160
|
+
# not reach into the commands/top-level namespace. The caller checks
|
|
161
|
+
# for action.action == "INSTALL" in fix_actions and dispatches install().
|
|
162
|
+
# We flag it here by attaching a marker on the actions list.
|
|
163
|
+
fix_actions.append(FixAction("_TRIGGER_INSTALL", "-", "run install"))
|
|
164
|
+
|
|
165
|
+
if needs_build:
|
|
166
|
+
print(f" {Color.CYAN}NOTICE{Color.NC} Build stale issues detected.")
|
|
167
|
+
print(" Run the build process to regenerate files from source.")
|
|
168
|
+
|
|
169
|
+
return True
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Diff installed state vs. newly built state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from ..domain.state import State, ScopeState, BackendState, InstalledItem
|
|
6
|
+
from ..domain.diff import ComponentDiff, StateDiff
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_color_and_version():
|
|
10
|
+
"""Get Color and version avoiding circular import."""
|
|
11
|
+
from ..config import Color, get_version
|
|
12
|
+
return Color, get_version
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
COMPONENT_TYPES = ("agents", "skills", "rules", "commands", "config", "settings")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def diff_scope_states(old_scope: Optional[ScopeState], new_scope: ScopeState) -> StateDiff:
|
|
19
|
+
"""Compare a specific ScopeState's clis against another ScopeState's clis."""
|
|
20
|
+
if old_scope is None:
|
|
21
|
+
old_scope = ScopeState() # Empty scope for comparison
|
|
22
|
+
|
|
23
|
+
# Determine backend changes
|
|
24
|
+
old_backends = set(old_scope.clis.keys())
|
|
25
|
+
new_backends = set(new_scope.clis.keys())
|
|
26
|
+
|
|
27
|
+
added_backends = list(new_backends - old_backends)
|
|
28
|
+
removed_backends = list(old_backends - new_backends)
|
|
29
|
+
|
|
30
|
+
components = []
|
|
31
|
+
|
|
32
|
+
# Process all backends that appear in either old or new
|
|
33
|
+
all_backends = old_backends | new_backends
|
|
34
|
+
|
|
35
|
+
for backend_name in sorted(all_backends):
|
|
36
|
+
old_backend = old_scope.clis.get(backend_name, BackendState())
|
|
37
|
+
new_backend = new_scope.clis.get(backend_name, BackendState())
|
|
38
|
+
|
|
39
|
+
for component in COMPONENT_TYPES:
|
|
40
|
+
old_items = old_backend.installed.get(component, {})
|
|
41
|
+
new_items = new_backend.installed.get(component, {})
|
|
42
|
+
|
|
43
|
+
old_keys = set(old_items.keys())
|
|
44
|
+
new_keys = set(new_items.keys())
|
|
45
|
+
|
|
46
|
+
added = list(new_keys - old_keys)
|
|
47
|
+
removed = list(old_keys - new_keys)
|
|
48
|
+
|
|
49
|
+
# Check for modifications (keys in both with different sha)
|
|
50
|
+
modified = []
|
|
51
|
+
unchanged = []
|
|
52
|
+
for key in old_keys & new_keys:
|
|
53
|
+
old_sha = old_items[key].sha
|
|
54
|
+
new_sha = new_items[key].sha
|
|
55
|
+
if old_sha != new_sha:
|
|
56
|
+
modified.append(key)
|
|
57
|
+
else:
|
|
58
|
+
unchanged.append(key)
|
|
59
|
+
|
|
60
|
+
# Only include ComponentDiff if there's any content
|
|
61
|
+
if added or removed or modified or unchanged:
|
|
62
|
+
components.append(ComponentDiff(
|
|
63
|
+
backend=backend_name,
|
|
64
|
+
component=component,
|
|
65
|
+
added=sorted(added),
|
|
66
|
+
removed=sorted(removed),
|
|
67
|
+
modified=sorted(modified),
|
|
68
|
+
unchanged=sorted(unchanged)
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
# For version/commit, take from the overall State (not scope-specific)
|
|
72
|
+
old_version = getattr(old_scope, 'version', None) if hasattr(old_scope, 'version') else None
|
|
73
|
+
new_version = getattr(new_scope, 'version', '') if hasattr(new_scope, 'version') else ''
|
|
74
|
+
old_commit = getattr(old_scope, 'source_commit', None) if hasattr(old_scope, 'source_commit') else None
|
|
75
|
+
new_commit = getattr(new_scope, 'source_commit', '') if hasattr(new_scope, 'source_commit') else ''
|
|
76
|
+
|
|
77
|
+
return StateDiff(
|
|
78
|
+
old_version=old_version,
|
|
79
|
+
new_version=new_version,
|
|
80
|
+
old_commit=old_commit,
|
|
81
|
+
new_commit=new_commit,
|
|
82
|
+
added_backends=sorted(added_backends),
|
|
83
|
+
removed_backends=sorted(removed_backends),
|
|
84
|
+
components=components
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def diff_states(old: Optional[State], new: State) -> StateDiff:
|
|
89
|
+
"""Compute StateDiff between two full State objects.
|
|
90
|
+
|
|
91
|
+
This is a compatibility shim that extracts the appropriate scopes and calls diff_scope_states.
|
|
92
|
+
For now, it compares global scope if present in new, otherwise tries to find a matching local scope.
|
|
93
|
+
"""
|
|
94
|
+
current_version = _get_color_and_version()[1]() # Get version from VERSION file
|
|
95
|
+
|
|
96
|
+
if old is None:
|
|
97
|
+
# If there's a global install being created, compare that
|
|
98
|
+
if new.global_install:
|
|
99
|
+
temp_scope = ScopeState(
|
|
100
|
+
installed_at=new.global_install.installed_at,
|
|
101
|
+
updated_at=new.global_install.updated_at,
|
|
102
|
+
mode=new.global_install.mode,
|
|
103
|
+
clis=new.global_install.clis.copy()
|
|
104
|
+
)
|
|
105
|
+
# Copy state-level metadata to the scope for diff purposes
|
|
106
|
+
setattr(temp_scope, 'version', current_version)
|
|
107
|
+
setattr(temp_scope, 'source_commit', new.source_commit)
|
|
108
|
+
return diff_scope_states(None, temp_scope)
|
|
109
|
+
# If it's local-only, compare the first local install
|
|
110
|
+
elif new.local_installs:
|
|
111
|
+
first_local = next(iter(new.local_installs.values()))
|
|
112
|
+
temp_scope = ScopeState(
|
|
113
|
+
installed_at=first_local.installed_at,
|
|
114
|
+
updated_at=first_local.updated_at,
|
|
115
|
+
mode=first_local.mode,
|
|
116
|
+
clis=first_local.clis.copy()
|
|
117
|
+
)
|
|
118
|
+
# Copy state-level metadata to the scope for diff purposes
|
|
119
|
+
setattr(temp_scope, 'version', current_version)
|
|
120
|
+
setattr(temp_scope, 'source_commit', new.source_commit)
|
|
121
|
+
return diff_scope_states(None, temp_scope)
|
|
122
|
+
else:
|
|
123
|
+
# Empty new state
|
|
124
|
+
temp_scope = ScopeState()
|
|
125
|
+
setattr(temp_scope, 'version', current_version)
|
|
126
|
+
setattr(temp_scope, 'source_commit', new.source_commit)
|
|
127
|
+
return diff_scope_states(None, temp_scope)
|
|
128
|
+
|
|
129
|
+
# For old state, try to get version info if it was set previously, otherwise use current version
|
|
130
|
+
old_version = getattr(old, 'version', current_version)
|
|
131
|
+
|
|
132
|
+
# Determine which scope to compare based on what exists in new
|
|
133
|
+
if new.global_install:
|
|
134
|
+
old_scope = old.global_install
|
|
135
|
+
new_scope_raw = new.global_install
|
|
136
|
+
# Create temporary scopes with metadata
|
|
137
|
+
new_scope = ScopeState(
|
|
138
|
+
installed_at=new_scope_raw.installed_at,
|
|
139
|
+
updated_at=new_scope_raw.updated_at,
|
|
140
|
+
mode=new_scope_raw.mode,
|
|
141
|
+
clis=new_scope_raw.clis.copy()
|
|
142
|
+
)
|
|
143
|
+
setattr(new_scope, 'version', current_version)
|
|
144
|
+
setattr(new_scope, 'source_commit', new.source_commit)
|
|
145
|
+
|
|
146
|
+
if old_scope:
|
|
147
|
+
old_scope_temp = ScopeState(
|
|
148
|
+
installed_at=old_scope.installed_at,
|
|
149
|
+
updated_at=old_scope.updated_at,
|
|
150
|
+
mode=old_scope.mode,
|
|
151
|
+
clis=old_scope.clis.copy()
|
|
152
|
+
)
|
|
153
|
+
setattr(old_scope_temp, 'version', old_version)
|
|
154
|
+
setattr(old_scope_temp, 'source_commit', old.source_commit)
|
|
155
|
+
else:
|
|
156
|
+
old_scope_temp = None
|
|
157
|
+
|
|
158
|
+
return diff_scope_states(old_scope_temp, new_scope)
|
|
159
|
+
elif new.local_installs:
|
|
160
|
+
# Find a matching local scope in old, or use None
|
|
161
|
+
first_new_path = next(iter(new.local_installs.keys()))
|
|
162
|
+
old_scope_raw = old.local_installs.get(first_new_path) if old.local_installs else None
|
|
163
|
+
new_scope_raw = new.local_installs[first_new_path]
|
|
164
|
+
|
|
165
|
+
new_scope = ScopeState(
|
|
166
|
+
installed_at=new_scope_raw.installed_at,
|
|
167
|
+
updated_at=new_scope_raw.updated_at,
|
|
168
|
+
mode=new_scope_raw.mode,
|
|
169
|
+
clis=new_scope_raw.clis.copy()
|
|
170
|
+
)
|
|
171
|
+
setattr(new_scope, 'version', current_version)
|
|
172
|
+
setattr(new_scope, 'source_commit', new.source_commit)
|
|
173
|
+
|
|
174
|
+
if old_scope_raw:
|
|
175
|
+
old_scope = ScopeState(
|
|
176
|
+
installed_at=old_scope_raw.installed_at,
|
|
177
|
+
updated_at=old_scope_raw.updated_at,
|
|
178
|
+
mode=old_scope_raw.mode,
|
|
179
|
+
clis=old_scope_raw.clis.copy()
|
|
180
|
+
)
|
|
181
|
+
setattr(old_scope, 'version', old_version)
|
|
182
|
+
setattr(old_scope, 'source_commit', old.source_commit)
|
|
183
|
+
else:
|
|
184
|
+
old_scope = None
|
|
185
|
+
|
|
186
|
+
return diff_scope_states(old_scope, new_scope)
|
|
187
|
+
else:
|
|
188
|
+
# Empty new state - should not happen in practice
|
|
189
|
+
temp_scope = ScopeState()
|
|
190
|
+
setattr(temp_scope, 'version', current_version)
|
|
191
|
+
setattr(temp_scope, 'source_commit', new.source_commit)
|
|
192
|
+
return diff_scope_states(None, temp_scope)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def render_diff_report(diff: StateDiff, use_color: bool = True) -> str:
|
|
196
|
+
"""Return a human-readable report as a multi-line string.
|
|
197
|
+
|
|
198
|
+
Format:
|
|
199
|
+
agent-notes update: 3d447ca -> abc1234 (5 commits)
|
|
200
|
+
|
|
201
|
+
+ 2 new agents in claude:
|
|
202
|
+
+ analyst.md
|
|
203
|
+
+ devil.md
|
|
204
|
+
~ 1 agent updated in claude:
|
|
205
|
+
~ coder.md (content changed)
|
|
206
|
+
- 1 skill removed in opencode:
|
|
207
|
+
- rails-legacy
|
|
208
|
+
|
|
209
|
+
Summary: +2 added, ~1 modified, -1 removed across 2 backends.
|
|
210
|
+
"""
|
|
211
|
+
Color, _ = _get_color_and_version()
|
|
212
|
+
|
|
213
|
+
if not use_color:
|
|
214
|
+
# Create a color-disabled version
|
|
215
|
+
class NoColor:
|
|
216
|
+
GREEN = ""
|
|
217
|
+
YELLOW = ""
|
|
218
|
+
RED = ""
|
|
219
|
+
CYAN = ""
|
|
220
|
+
NC = ""
|
|
221
|
+
color = NoColor()
|
|
222
|
+
else:
|
|
223
|
+
color = Color()
|
|
224
|
+
|
|
225
|
+
if not diff.has_changes():
|
|
226
|
+
return "No changes."
|
|
227
|
+
|
|
228
|
+
lines = []
|
|
229
|
+
|
|
230
|
+
# Header line
|
|
231
|
+
if diff.old_commit and diff.new_commit:
|
|
232
|
+
if diff.old_commit == diff.new_commit:
|
|
233
|
+
header = f"agent-notes update: {diff.new_commit} (no new commits)"
|
|
234
|
+
else:
|
|
235
|
+
header = f"agent-notes update: {diff.old_commit} -> {diff.new_commit}"
|
|
236
|
+
elif diff.new_commit:
|
|
237
|
+
header = f"agent-notes update: initial install at {diff.new_commit}"
|
|
238
|
+
else:
|
|
239
|
+
header = "agent-notes update"
|
|
240
|
+
|
|
241
|
+
lines.append(header)
|
|
242
|
+
lines.append("")
|
|
243
|
+
|
|
244
|
+
# Group by backend, then by component type
|
|
245
|
+
backends_with_changes = {}
|
|
246
|
+
for comp in diff.components:
|
|
247
|
+
if comp.has_changes():
|
|
248
|
+
if comp.backend not in backends_with_changes:
|
|
249
|
+
backends_with_changes[comp.backend] = []
|
|
250
|
+
backends_with_changes[comp.backend].append(comp)
|
|
251
|
+
|
|
252
|
+
# Handle added/removed backends
|
|
253
|
+
for backend in diff.added_backends:
|
|
254
|
+
lines.append(f"{color.GREEN}+ New backend: {backend}{color.NC}")
|
|
255
|
+
|
|
256
|
+
for backend in diff.removed_backends:
|
|
257
|
+
lines.append(f"{color.RED}- Backend removed: {backend}{color.NC}")
|
|
258
|
+
|
|
259
|
+
# Process component changes
|
|
260
|
+
for backend_name in sorted(backends_with_changes.keys()):
|
|
261
|
+
backend_components = backends_with_changes[backend_name]
|
|
262
|
+
|
|
263
|
+
for comp in backend_components:
|
|
264
|
+
if comp.added:
|
|
265
|
+
count = len(comp.added)
|
|
266
|
+
plural = "s" if count != 1 else ""
|
|
267
|
+
lines.append(f"{color.GREEN}+ {count} new {comp.component}{plural} in {backend_name}:{color.NC}")
|
|
268
|
+
|
|
269
|
+
items_to_show = comp.added[:20] # Show max 20
|
|
270
|
+
for item in items_to_show:
|
|
271
|
+
lines.append(f" {color.GREEN}+ {item}{color.NC}")
|
|
272
|
+
|
|
273
|
+
if len(comp.added) > 20:
|
|
274
|
+
remaining = len(comp.added) - 20
|
|
275
|
+
lines.append(f" {color.GREEN}... and {remaining} more{color.NC}")
|
|
276
|
+
|
|
277
|
+
if comp.modified:
|
|
278
|
+
count = len(comp.modified)
|
|
279
|
+
plural = "s" if count != 1 else ""
|
|
280
|
+
lines.append(f"{color.YELLOW}~ {count} {comp.component}{plural} updated in {backend_name}:{color.NC}")
|
|
281
|
+
|
|
282
|
+
items_to_show = comp.modified[:20]
|
|
283
|
+
for item in items_to_show:
|
|
284
|
+
lines.append(f" {color.YELLOW}~ {item} (content changed){color.NC}")
|
|
285
|
+
|
|
286
|
+
if len(comp.modified) > 20:
|
|
287
|
+
remaining = len(comp.modified) - 20
|
|
288
|
+
lines.append(f" {color.YELLOW}... and {remaining} more{color.NC}")
|
|
289
|
+
|
|
290
|
+
if comp.removed:
|
|
291
|
+
count = len(comp.removed)
|
|
292
|
+
plural = "s" if count != 1 else ""
|
|
293
|
+
lines.append(f"{color.RED}- {count} {comp.component}{plural} removed from {backend_name}:{color.NC}")
|
|
294
|
+
|
|
295
|
+
items_to_show = comp.removed[:20]
|
|
296
|
+
for item in items_to_show:
|
|
297
|
+
lines.append(f" {color.RED}- {item}{color.NC}")
|
|
298
|
+
|
|
299
|
+
if len(comp.removed) > 20:
|
|
300
|
+
remaining = len(comp.removed) - 20
|
|
301
|
+
lines.append(f" {color.RED}... and {remaining} more{color.NC}")
|
|
302
|
+
|
|
303
|
+
# Summary line
|
|
304
|
+
if lines and lines[-1] != "":
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
total_added = sum(len(c.added) for c in diff.components)
|
|
308
|
+
total_modified = sum(len(c.modified) for c in diff.components)
|
|
309
|
+
total_removed = sum(len(c.removed) for c in diff.components)
|
|
310
|
+
backend_count = len(set(c.backend for c in diff.components if c.has_changes()))
|
|
311
|
+
|
|
312
|
+
summary_parts = []
|
|
313
|
+
if total_added:
|
|
314
|
+
summary_parts.append(f"{color.GREEN}+{total_added} added{color.NC}")
|
|
315
|
+
if total_modified:
|
|
316
|
+
summary_parts.append(f"{color.YELLOW}~{total_modified} modified{color.NC}")
|
|
317
|
+
if total_removed:
|
|
318
|
+
summary_parts.append(f"{color.RED}-{total_removed} removed{color.NC}")
|
|
319
|
+
|
|
320
|
+
if summary_parts:
|
|
321
|
+
summary = f"Summary: {', '.join(summary_parts)} across {backend_count} backend{'s' if backend_count != 1 else ''}."
|
|
322
|
+
lines.append(summary)
|
|
323
|
+
|
|
324
|
+
return "\n".join(lines)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def filter_diff(diff: StateDiff, only: Optional[list[str]] = None) -> StateDiff:
|
|
328
|
+
"""Return a new StateDiff keeping only the listed component types.
|
|
329
|
+
|
|
330
|
+
`only` can contain "agents", "skills", "rules", "commands", "config", "settings".
|
|
331
|
+
If None or empty, return the diff unchanged.
|
|
332
|
+
"""
|
|
333
|
+
if not only:
|
|
334
|
+
return diff
|
|
335
|
+
|
|
336
|
+
filtered_components = []
|
|
337
|
+
for comp in diff.components:
|
|
338
|
+
if comp.component in only:
|
|
339
|
+
filtered_components.append(comp)
|
|
340
|
+
|
|
341
|
+
return StateDiff(
|
|
342
|
+
old_version=diff.old_version,
|
|
343
|
+
new_version=diff.new_version,
|
|
344
|
+
old_commit=diff.old_commit,
|
|
345
|
+
new_commit=diff.new_commit,
|
|
346
|
+
added_backends=diff.added_backends,
|
|
347
|
+
removed_backends=diff.removed_backends,
|
|
348
|
+
components=filtered_components
|
|
349
|
+
)
|