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,249 @@
|
|
|
1
|
+
"""State storage I/O operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from ..domain.state import State, ScopeState, BackendState, InstalledItem, MemoryConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def state_dir() -> Path:
|
|
17
|
+
"""~/.config/agent-notes/ respecting $XDG_CONFIG_HOME."""
|
|
18
|
+
config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
19
|
+
base = Path(config_home) if config_home else (Path.home() / ".config")
|
|
20
|
+
return base / "agent-notes"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def state_file() -> Path:
|
|
24
|
+
return state_dir() / "state.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_state() -> Optional[State]:
|
|
28
|
+
"""Load state from disk. Return None if file absent or on any error."""
|
|
29
|
+
file_path = state_file()
|
|
30
|
+
if not file_path.exists():
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(file_path.read_text())
|
|
35
|
+
state = _state_from_dict(data)
|
|
36
|
+
return state
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_state(state: State) -> None:
|
|
42
|
+
"""Atomic write. Updates updated_at on any ScopeState that has changed — but
|
|
43
|
+
caller is responsible for setting installed_at on first creation. `save_state` itself
|
|
44
|
+
refreshes `updated_at` on ALL present scopes (simple semantics).
|
|
45
|
+
|
|
46
|
+
Serialize as JSON with keys:
|
|
47
|
+
source_path, source_commit, global, local
|
|
48
|
+
|
|
49
|
+
- `global_install` serializes to key "global" (None → omit or null)
|
|
50
|
+
- `local_installs` serializes to key "local"
|
|
51
|
+
- `BackendState.role_models` and `BackendState.installed` serialize naturally
|
|
52
|
+
- `installed[component_type]` is a dict of name → InstalledItem as dict
|
|
53
|
+
"""
|
|
54
|
+
# Update timestamps on all scopes
|
|
55
|
+
now = now_iso()
|
|
56
|
+
if state.global_install:
|
|
57
|
+
state.global_install.updated_at = now
|
|
58
|
+
for scope_state in state.local_installs.values():
|
|
59
|
+
scope_state.updated_at = now
|
|
60
|
+
|
|
61
|
+
# Ensure directory exists
|
|
62
|
+
file_path = state_file()
|
|
63
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
# Convert to JSON-serializable dict
|
|
66
|
+
data = _state_to_dict(state)
|
|
67
|
+
|
|
68
|
+
# Atomic write
|
|
69
|
+
tmp_path = file_path.with_suffix(".json.tmp")
|
|
70
|
+
try:
|
|
71
|
+
tmp_path.write_text(json.dumps(data, indent=2))
|
|
72
|
+
os.replace(tmp_path, file_path)
|
|
73
|
+
except Exception:
|
|
74
|
+
# Cleanup on failure
|
|
75
|
+
if tmp_path.exists():
|
|
76
|
+
tmp_path.unlink()
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clear_state() -> None:
|
|
81
|
+
"""Delete state file. No error if absent."""
|
|
82
|
+
file_path = state_file()
|
|
83
|
+
if file_path.exists():
|
|
84
|
+
file_path.unlink()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_scope(state: State, scope: str, project_path: Optional[Path] = None) -> Optional[ScopeState]:
|
|
88
|
+
"""Fetch the ScopeState for a scope. scope is 'global' or 'local'.
|
|
89
|
+
For 'local', project_path MUST be provided (absolute path).
|
|
90
|
+
Returns None if the scope hasn't been installed to yet."""
|
|
91
|
+
if scope == "global":
|
|
92
|
+
return state.global_install
|
|
93
|
+
if scope == "local":
|
|
94
|
+
if project_path is None:
|
|
95
|
+
raise ValueError("project_path required for local scope")
|
|
96
|
+
return state.local_installs.get(str(Path(project_path).resolve()))
|
|
97
|
+
raise ValueError(f"Unknown scope: {scope}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_scope(state: State, scope: str, scope_state: ScopeState, project_path: Optional[Path] = None) -> None:
|
|
101
|
+
"""Set/replace the scope state."""
|
|
102
|
+
if scope == "global":
|
|
103
|
+
state.global_install = scope_state
|
|
104
|
+
elif scope == "local":
|
|
105
|
+
if project_path is None:
|
|
106
|
+
raise ValueError("project_path required for local scope")
|
|
107
|
+
state.local_installs[str(Path(project_path).resolve())] = scope_state
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(f"Unknown scope: {scope}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def remove_scope(state: State, scope: str, project_path: Optional[Path] = None) -> None:
|
|
113
|
+
"""Remove scope state (for uninstall)."""
|
|
114
|
+
if scope == "global":
|
|
115
|
+
state.global_install = None
|
|
116
|
+
elif scope == "local":
|
|
117
|
+
if project_path is None:
|
|
118
|
+
raise ValueError("project_path required for local scope")
|
|
119
|
+
state.local_installs.pop(str(Path(project_path).resolve()), None)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def default_state() -> State:
|
|
123
|
+
"""Create an empty state."""
|
|
124
|
+
return State(
|
|
125
|
+
source_path="",
|
|
126
|
+
source_commit="",
|
|
127
|
+
global_install=None,
|
|
128
|
+
local_installs={}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sha256_of(path: Path) -> str:
|
|
133
|
+
"""Return sha256 hex digest of a file's bytes."""
|
|
134
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def now_iso() -> str:
|
|
138
|
+
"""Current UTC ISO 8601 timestamp, seconds precision, trailing Z."""
|
|
139
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _state_to_dict(s: State) -> dict:
|
|
143
|
+
return {
|
|
144
|
+
"source_path": s.source_path,
|
|
145
|
+
"source_commit": s.source_commit,
|
|
146
|
+
"global": _scope_to_dict(s.global_install) if s.global_install else None,
|
|
147
|
+
"local": {path: _scope_to_dict(ss) for path, ss in s.local_installs.items()},
|
|
148
|
+
"memory": {"backend": s.memory.backend, "path": s.memory.path},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _scope_to_dict(s: ScopeState) -> dict:
|
|
153
|
+
return {
|
|
154
|
+
"installed_at": s.installed_at,
|
|
155
|
+
"updated_at": s.updated_at,
|
|
156
|
+
"mode": s.mode,
|
|
157
|
+
"clis": {name: _backend_to_dict(bs) for name, bs in s.clis.items()},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _backend_to_dict(b: BackendState) -> dict:
|
|
162
|
+
return {
|
|
163
|
+
"role_models": dict(b.role_models),
|
|
164
|
+
"installed": {
|
|
165
|
+
component: {name: asdict(item) for name, item in items.items()}
|
|
166
|
+
for component, items in b.installed.items()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _state_from_dict(data: dict) -> State:
|
|
172
|
+
"""Load State from JSON dict, handling missing fields defensively."""
|
|
173
|
+
global_data = data.get("global")
|
|
174
|
+
global_install = _scope_from_dict(global_data) if global_data else None
|
|
175
|
+
|
|
176
|
+
local_data = data.get("local", {})
|
|
177
|
+
local_installs = {path: _scope_from_dict(scope_data) for path, scope_data in local_data.items()}
|
|
178
|
+
|
|
179
|
+
memory_data = data.get("memory", {})
|
|
180
|
+
memory = MemoryConfig(
|
|
181
|
+
backend=memory_data.get("backend", "local"),
|
|
182
|
+
path=memory_data.get("path", ""),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return State(
|
|
186
|
+
source_path=data.get("source_path", ""),
|
|
187
|
+
source_commit=data.get("source_commit", ""),
|
|
188
|
+
global_install=global_install,
|
|
189
|
+
local_installs=local_installs,
|
|
190
|
+
memory=memory,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _scope_from_dict(data: dict) -> ScopeState:
|
|
195
|
+
"""Load ScopeState from JSON dict."""
|
|
196
|
+
clis_data = data.get("clis", {})
|
|
197
|
+
clis = {name: _backend_from_dict(backend_data) for name, backend_data in clis_data.items()}
|
|
198
|
+
|
|
199
|
+
return ScopeState(
|
|
200
|
+
installed_at=data.get("installed_at", ""),
|
|
201
|
+
updated_at=data.get("updated_at", ""),
|
|
202
|
+
mode=data.get("mode", "symlink"),
|
|
203
|
+
clis=clis,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _backend_from_dict(data: dict) -> BackendState:
|
|
208
|
+
"""Load BackendState from JSON dict."""
|
|
209
|
+
role_models = data.get("role_models", {})
|
|
210
|
+
|
|
211
|
+
installed_data = data.get("installed", {})
|
|
212
|
+
installed = {}
|
|
213
|
+
for component, items_data in installed_data.items():
|
|
214
|
+
items = {name: InstalledItem(**item_data) for name, item_data in items_data.items()}
|
|
215
|
+
installed[component] = items
|
|
216
|
+
|
|
217
|
+
return BackendState(
|
|
218
|
+
role_models=role_models,
|
|
219
|
+
installed=installed,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# I/O functions moved from install_state.py
|
|
224
|
+
def record_install_state(state: State) -> None:
|
|
225
|
+
"""Persist state via save_state()."""
|
|
226
|
+
save_state(state)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_current_state() -> Optional[State]:
|
|
230
|
+
return load_state()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def remove_install_state(scope: str, project_path: Optional[Path] = None) -> None:
|
|
234
|
+
"""Remove install state for the given scope without affecting other scopes.
|
|
235
|
+
|
|
236
|
+
For uninstall operations - this should only remove the target scope,
|
|
237
|
+
not clear the entire state file.
|
|
238
|
+
"""
|
|
239
|
+
current_state = load_state()
|
|
240
|
+
if current_state is None:
|
|
241
|
+
return # Nothing to remove
|
|
242
|
+
|
|
243
|
+
remove_scope(current_state, scope, project_path)
|
|
244
|
+
|
|
245
|
+
# If state is now completely empty, clear the file
|
|
246
|
+
if current_state.global_install is None and not current_state.local_installs:
|
|
247
|
+
clear_state()
|
|
248
|
+
else:
|
|
249
|
+
save_state(current_state)
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Terminal UI primitives."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Tuple, Set
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tty
|
|
10
|
+
import termios
|
|
11
|
+
_HAS_TTY = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
_HAS_TTY = False
|
|
14
|
+
|
|
15
|
+
# Export for backward compatibility
|
|
16
|
+
__all__ = ['Color', 'ok', 'warn', 'fail', 'error', 'info', 'issue', 'linked', 'removed', 'skipped',
|
|
17
|
+
'_safe_input', '_can_interactive', '_read_key', '_checkbox_select', '_radio_select',
|
|
18
|
+
'_checkbox_select_fallback', '_radio_select_fallback', '_HAS_TTY',
|
|
19
|
+
'_clear_screen', '_render_step_header', '_render_nav_footer', '_terminal_width']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- Colors ---
|
|
23
|
+
class Color:
|
|
24
|
+
RED = "\033[0;31m"
|
|
25
|
+
GREEN = "\033[0;32m"
|
|
26
|
+
YELLOW = "\033[0;33m"
|
|
27
|
+
BLUE = "\033[0;34m"
|
|
28
|
+
MAGENTA = "\033[0;35m"
|
|
29
|
+
CYAN = "\033[0;36m"
|
|
30
|
+
WHITE = "\033[0;37m"
|
|
31
|
+
BOLD = "\033[1m"
|
|
32
|
+
DIM = "\033[2m"
|
|
33
|
+
NC = "\033[0m" # No color
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def disable():
|
|
37
|
+
"""Disable colors (for non-TTY output)."""
|
|
38
|
+
for attr in ("RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE", "BOLD", "DIM", "NC"):
|
|
39
|
+
setattr(Color, attr, "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Disable colors if not a TTY
|
|
43
|
+
if not sys.stdout.isatty():
|
|
44
|
+
Color.disable()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Output helpers ---
|
|
48
|
+
def ok(msg: str, indent: int = 2) -> None:
|
|
49
|
+
print(f"{' ' * indent}{Color.GREEN}OK{Color.NC} {msg}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def warn(msg: str, indent: int = 2) -> None:
|
|
53
|
+
print(f"{' ' * indent}{Color.YELLOW}WARN{Color.NC} {msg}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fail(msg: str, indent: int = 2) -> None:
|
|
57
|
+
print(f"{' ' * indent}{Color.RED}FAIL{Color.NC} {msg}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error(msg: str) -> None:
|
|
61
|
+
print(f"{Color.RED}Error: {msg}{Color.NC}", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def info(msg: str) -> None:
|
|
66
|
+
print(f" {Color.GREEN}✓{Color.NC} {msg}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def issue(msg: str) -> None:
|
|
70
|
+
print(f" {Color.RED}✗{Color.NC} {msg}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def linked(path: str) -> None:
|
|
74
|
+
print(f" {Color.GREEN}LINKED{Color.NC} {path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def removed(path: str) -> None:
|
|
78
|
+
print(f" {Color.GREEN}REMOVED{Color.NC} {path}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def skipped(path: str, reason: str = "not a symlink — remove manually") -> None:
|
|
82
|
+
print(f" {Color.YELLOW}SKIP{Color.NC} {path} ({reason})")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _terminal_width() -> int:
|
|
86
|
+
return shutil.get_terminal_size((80, 24)).columns
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _clear_screen() -> None:
|
|
90
|
+
"""Clear terminal. No-op when stdout is not a TTY (CI, pipes, tests)."""
|
|
91
|
+
if sys.stdout.isatty():
|
|
92
|
+
sys.stdout.write('\033[2J\033[H')
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _render_step_header(step: int, total: int, version: str = '') -> None:
|
|
97
|
+
"""Print the wizard top bar: app name on left, step counter on right."""
|
|
98
|
+
width = _terminal_width()
|
|
99
|
+
left_plain = f" AgentNotes{f' v{version}' if version else ''}"
|
|
100
|
+
right_plain = f"Step {step} of {total} "
|
|
101
|
+
padding = max(1, width - len(left_plain) - len(right_plain))
|
|
102
|
+
left_col = f" {Color.BOLD}AgentNotes{Color.NC}{f' {Color.CYAN}v{version}{Color.NC}' if version else ''}"
|
|
103
|
+
right_col = f"{Color.DIM}Step {step} of {total}{Color.NC} "
|
|
104
|
+
sys.stdout.write(f"{left_col}{' ' * padding}{right_col}\n")
|
|
105
|
+
sys.stdout.write(f"{Color.DIM}{'─' * width}{Color.NC}\n\n")
|
|
106
|
+
sys.stdout.flush()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_nav_footer(mode: str = 'checkbox') -> None:
|
|
110
|
+
"""Print the bottom separator + key hint line."""
|
|
111
|
+
width = _terminal_width()
|
|
112
|
+
sys.stdout.write(f"\n{Color.DIM}{'─' * width}{Color.NC}\n")
|
|
113
|
+
if mode == 'checkbox':
|
|
114
|
+
hints = (f" {Color.DIM}↑↓{Color.NC} navigate"
|
|
115
|
+
f" {Color.DIM}space{Color.NC} toggle"
|
|
116
|
+
f" {Color.DIM}enter{Color.NC} confirm"
|
|
117
|
+
f" {Color.DIM}q{Color.NC} quit")
|
|
118
|
+
else:
|
|
119
|
+
hints = (f" {Color.DIM}↑↓{Color.NC} navigate"
|
|
120
|
+
f" {Color.DIM}enter{Color.NC} confirm"
|
|
121
|
+
f" {Color.DIM}q{Color.NC} quit")
|
|
122
|
+
sys.stdout.write(hints + "\n")
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- TUI primitives ---
|
|
127
|
+
def _safe_input(prompt: str, default: str = "") -> str:
|
|
128
|
+
"""Safe input that handles EOF and interrupts."""
|
|
129
|
+
try:
|
|
130
|
+
result = input(prompt).strip()
|
|
131
|
+
return result if result else default
|
|
132
|
+
except (KeyboardInterrupt, EOFError):
|
|
133
|
+
print("\nInstallation cancelled.")
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _can_interactive() -> bool:
|
|
138
|
+
"""Check if interactive TUI is available."""
|
|
139
|
+
return _HAS_TTY and sys.stdin.isatty()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_key():
|
|
143
|
+
"""Read a single keypress."""
|
|
144
|
+
fd = sys.stdin.fileno()
|
|
145
|
+
old_settings = termios.tcgetattr(fd)
|
|
146
|
+
try:
|
|
147
|
+
tty.setraw(fd)
|
|
148
|
+
ch = sys.stdin.read(1)
|
|
149
|
+
if ch == '\x1b': # Escape sequence
|
|
150
|
+
ch2 = sys.stdin.read(1)
|
|
151
|
+
ch3 = sys.stdin.read(1)
|
|
152
|
+
if ch2 == '[':
|
|
153
|
+
if ch3 == 'A': return 'up'
|
|
154
|
+
if ch3 == 'B': return 'down'
|
|
155
|
+
return 'escape'
|
|
156
|
+
if ch == ' ': return 'space'
|
|
157
|
+
if ch in ('\r', '\n'): return 'enter'
|
|
158
|
+
if ch == '\x03': raise KeyboardInterrupt
|
|
159
|
+
return ch
|
|
160
|
+
finally:
|
|
161
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _checkbox_select(title: str, options: List[Tuple[str, str]], defaults: Set[str] = None,
|
|
165
|
+
step: int = 0, total: int = 0, version: str = '') -> Set[str]:
|
|
166
|
+
"""Interactive checkbox selector.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
title: Header text
|
|
170
|
+
options: List of (label, value) tuples
|
|
171
|
+
defaults: Set of values that are pre-selected
|
|
172
|
+
step: Wizard step number (0 = legacy in-place mode)
|
|
173
|
+
total: Total wizard steps
|
|
174
|
+
version: App version string
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Set of selected values
|
|
178
|
+
"""
|
|
179
|
+
if defaults is None:
|
|
180
|
+
defaults = {v for _, v in options}
|
|
181
|
+
|
|
182
|
+
selected = set(defaults)
|
|
183
|
+
cursor = 0
|
|
184
|
+
|
|
185
|
+
if not _can_interactive():
|
|
186
|
+
return selected
|
|
187
|
+
|
|
188
|
+
if step > 0:
|
|
189
|
+
def render():
|
|
190
|
+
_clear_screen()
|
|
191
|
+
_render_step_header(step, total, version)
|
|
192
|
+
sys.stdout.write(f" {title}\n\n")
|
|
193
|
+
for i, (label, value) in enumerate(options):
|
|
194
|
+
check = "✓" if value in selected else " "
|
|
195
|
+
pointer = "›" if i == cursor else " "
|
|
196
|
+
sys.stdout.write(f" {pointer} [{check}] {label}\n")
|
|
197
|
+
_render_nav_footer('checkbox')
|
|
198
|
+
sys.stdout.flush()
|
|
199
|
+
|
|
200
|
+
render()
|
|
201
|
+
|
|
202
|
+
while True:
|
|
203
|
+
key = _read_key()
|
|
204
|
+
|
|
205
|
+
if key == 'up':
|
|
206
|
+
cursor = (cursor - 1) % len(options)
|
|
207
|
+
elif key == 'down':
|
|
208
|
+
cursor = (cursor + 1) % len(options)
|
|
209
|
+
elif key == 'space':
|
|
210
|
+
value = options[cursor][1]
|
|
211
|
+
if value in selected:
|
|
212
|
+
selected.discard(value)
|
|
213
|
+
else:
|
|
214
|
+
selected.add(value)
|
|
215
|
+
elif key == 'enter':
|
|
216
|
+
return selected
|
|
217
|
+
elif key == 'escape':
|
|
218
|
+
return selected
|
|
219
|
+
elif key in ('q', 'Q'):
|
|
220
|
+
print("\nInstallation cancelled.")
|
|
221
|
+
sys.exit(0)
|
|
222
|
+
|
|
223
|
+
render()
|
|
224
|
+
else:
|
|
225
|
+
# Legacy in-place mode
|
|
226
|
+
prev_lines = 0
|
|
227
|
+
|
|
228
|
+
def render():
|
|
229
|
+
nonlocal prev_lines
|
|
230
|
+
if prev_lines:
|
|
231
|
+
sys.stdout.write(f"\033[{prev_lines}A")
|
|
232
|
+
lines = []
|
|
233
|
+
header = f"{title} (↑↓ navigate, space toggle, enter confirm)"
|
|
234
|
+
lines.extend(header.split("\n"))
|
|
235
|
+
lines.append("") # blank separator
|
|
236
|
+
for i, (label, value) in enumerate(options):
|
|
237
|
+
check = "✓" if value in selected else " "
|
|
238
|
+
pointer = "›" if i == cursor else " "
|
|
239
|
+
lines.append(f" {pointer} [{check}] {label}")
|
|
240
|
+
lines.append("") # trailing blank
|
|
241
|
+
for line in lines:
|
|
242
|
+
sys.stdout.write(f"\r\033[K{line}\n")
|
|
243
|
+
sys.stdout.flush()
|
|
244
|
+
prev_lines = len(lines)
|
|
245
|
+
|
|
246
|
+
render()
|
|
247
|
+
|
|
248
|
+
while True:
|
|
249
|
+
key = _read_key()
|
|
250
|
+
|
|
251
|
+
if key == 'up':
|
|
252
|
+
cursor = (cursor - 1) % len(options)
|
|
253
|
+
elif key == 'down':
|
|
254
|
+
cursor = (cursor + 1) % len(options)
|
|
255
|
+
elif key == 'space':
|
|
256
|
+
value = options[cursor][1]
|
|
257
|
+
if value in selected:
|
|
258
|
+
selected.discard(value)
|
|
259
|
+
else:
|
|
260
|
+
selected.add(value)
|
|
261
|
+
elif key == 'enter':
|
|
262
|
+
return selected
|
|
263
|
+
elif key == 'escape':
|
|
264
|
+
return selected
|
|
265
|
+
elif key in ('q', 'Q'):
|
|
266
|
+
print("\nInstallation cancelled.")
|
|
267
|
+
sys.exit(0)
|
|
268
|
+
|
|
269
|
+
render()
|
|
270
|
+
|
|
271
|
+
return selected
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _radio_select(title: str, options: List[Tuple[str, str]], default: int = 0,
|
|
275
|
+
step: int = 0, total: int = 0, version: str = ''):
|
|
276
|
+
"""Interactive single-choice selector.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
title: Header text (may contain \\n for multi-line titles)
|
|
280
|
+
options: List of (label, value) tuples
|
|
281
|
+
default: Index of default selection
|
|
282
|
+
step: Wizard step number (0 = legacy in-place mode)
|
|
283
|
+
total: Total wizard steps
|
|
284
|
+
version: App version string
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Selected value
|
|
288
|
+
"""
|
|
289
|
+
cursor = default
|
|
290
|
+
|
|
291
|
+
if not _can_interactive():
|
|
292
|
+
return options[default][1]
|
|
293
|
+
|
|
294
|
+
if step > 0:
|
|
295
|
+
def render():
|
|
296
|
+
_clear_screen()
|
|
297
|
+
_render_step_header(step, total, version)
|
|
298
|
+
sys.stdout.write(f" {title}\n\n")
|
|
299
|
+
for i, (label, value) in enumerate(options):
|
|
300
|
+
dot = "●" if i == cursor else "○"
|
|
301
|
+
pointer = "›" if i == cursor else " "
|
|
302
|
+
sys.stdout.write(f" {pointer} {dot} {label}\n")
|
|
303
|
+
_render_nav_footer('radio')
|
|
304
|
+
sys.stdout.flush()
|
|
305
|
+
|
|
306
|
+
render()
|
|
307
|
+
|
|
308
|
+
while True:
|
|
309
|
+
key = _read_key()
|
|
310
|
+
|
|
311
|
+
if key == 'up':
|
|
312
|
+
cursor = (cursor - 1) % len(options)
|
|
313
|
+
elif key == 'down':
|
|
314
|
+
cursor = (cursor + 1) % len(options)
|
|
315
|
+
elif key == 'enter':
|
|
316
|
+
return options[cursor][1]
|
|
317
|
+
elif key == 'escape':
|
|
318
|
+
return options[cursor][1]
|
|
319
|
+
elif key in ('q', 'Q'):
|
|
320
|
+
print("\nInstallation cancelled.")
|
|
321
|
+
sys.exit(0)
|
|
322
|
+
|
|
323
|
+
render()
|
|
324
|
+
else:
|
|
325
|
+
# Legacy in-place mode
|
|
326
|
+
prev_lines = 0
|
|
327
|
+
|
|
328
|
+
def render():
|
|
329
|
+
nonlocal prev_lines
|
|
330
|
+
if prev_lines:
|
|
331
|
+
sys.stdout.write(f"\033[{prev_lines}A")
|
|
332
|
+
lines = []
|
|
333
|
+
header = f"{title} (↑↓ navigate, enter confirm)"
|
|
334
|
+
lines.extend(header.split("\n"))
|
|
335
|
+
lines.append("")
|
|
336
|
+
for i, (label, value) in enumerate(options):
|
|
337
|
+
dot = "●" if i == cursor else "○"
|
|
338
|
+
pointer = "›" if i == cursor else " "
|
|
339
|
+
lines.append(f" {pointer} {dot} {label}")
|
|
340
|
+
lines.append("")
|
|
341
|
+
for line in lines:
|
|
342
|
+
sys.stdout.write(f"\r\033[K{line}\n")
|
|
343
|
+
sys.stdout.flush()
|
|
344
|
+
prev_lines = len(lines)
|
|
345
|
+
|
|
346
|
+
render()
|
|
347
|
+
|
|
348
|
+
while True:
|
|
349
|
+
key = _read_key()
|
|
350
|
+
|
|
351
|
+
if key == 'up':
|
|
352
|
+
cursor = (cursor - 1) % len(options)
|
|
353
|
+
elif key == 'down':
|
|
354
|
+
cursor = (cursor + 1) % len(options)
|
|
355
|
+
elif key == 'enter':
|
|
356
|
+
return options[cursor][1]
|
|
357
|
+
elif key == 'escape':
|
|
358
|
+
return options[cursor][1]
|
|
359
|
+
elif key in ('q', 'Q'):
|
|
360
|
+
print("\nInstallation cancelled.")
|
|
361
|
+
sys.exit(0)
|
|
362
|
+
|
|
363
|
+
render()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _checkbox_select_fallback(title: str, options: List[Tuple[str, str]], defaults: Set[str] = None,
|
|
367
|
+
step: int = 0, total: int = 0, version: str = '') -> Set[str]:
|
|
368
|
+
"""Fallback checkbox using numbered input."""
|
|
369
|
+
if defaults is None:
|
|
370
|
+
defaults = {v for _, v in options}
|
|
371
|
+
|
|
372
|
+
if step > 0:
|
|
373
|
+
print(f"\n {Color.BOLD}AgentNotes{Color.NC}{f' {Color.CYAN}v{version}{Color.NC}' if version else ''} — Step {step} of {total}\n")
|
|
374
|
+
|
|
375
|
+
print(f"{title}\n")
|
|
376
|
+
for i, (label, value) in enumerate(options, 1):
|
|
377
|
+
marker = "*" if value in defaults else " "
|
|
378
|
+
print(f" {i}) [{marker}] {label}")
|
|
379
|
+
print(f"\n Enter numbers to toggle (comma-separated), or press enter for defaults.")
|
|
380
|
+
|
|
381
|
+
choice = _safe_input("Choice: ", "").strip()
|
|
382
|
+
if not choice:
|
|
383
|
+
return set(defaults)
|
|
384
|
+
|
|
385
|
+
selected = set(defaults)
|
|
386
|
+
for part in choice.split(","):
|
|
387
|
+
try:
|
|
388
|
+
idx = int(part.strip()) - 1
|
|
389
|
+
if 0 <= idx < len(options):
|
|
390
|
+
value = options[idx][1]
|
|
391
|
+
if value in selected:
|
|
392
|
+
selected.discard(value)
|
|
393
|
+
else:
|
|
394
|
+
selected.add(value)
|
|
395
|
+
except ValueError:
|
|
396
|
+
continue
|
|
397
|
+
return selected
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _radio_select_fallback(title: str, options: List[Tuple[str, str]], default: int = 0,
|
|
401
|
+
step: int = 0, total: int = 0, version: str = ''):
|
|
402
|
+
"""Fallback radio using numbered input."""
|
|
403
|
+
if step > 0:
|
|
404
|
+
print(f"\n {Color.BOLD}AgentNotes{Color.NC}{f' {Color.CYAN}v{version}{Color.NC}' if version else ''} — Step {step} of {total}\n")
|
|
405
|
+
|
|
406
|
+
print(f"{title}\n")
|
|
407
|
+
for i, (label, value) in enumerate(options, 1):
|
|
408
|
+
marker = "*" if i - 1 == default else " "
|
|
409
|
+
print(f" {i}) {marker} {label}")
|
|
410
|
+
print("")
|
|
411
|
+
|
|
412
|
+
choice = _safe_input(f"Choice [{default + 1}]: ", str(default + 1))
|
|
413
|
+
try:
|
|
414
|
+
idx = int(choice) - 1
|
|
415
|
+
if 0 <= idx < len(options):
|
|
416
|
+
return options[idx][1]
|
|
417
|
+
except ValueError:
|
|
418
|
+
pass
|
|
419
|
+
return options[default][1]
|