agent-notes 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. agent_notes/VERSION +1 -0
  2. agent_notes/__init__.py +1 -0
  3. agent_notes/__main__.py +4 -0
  4. agent_notes/cli.py +348 -0
  5. agent_notes/commands/__init__.py +27 -0
  6. agent_notes/commands/_install_helpers.py +262 -0
  7. agent_notes/commands/build.py +170 -0
  8. agent_notes/commands/doctor.py +112 -0
  9. agent_notes/commands/info.py +95 -0
  10. agent_notes/commands/install.py +99 -0
  11. agent_notes/commands/list.py +169 -0
  12. agent_notes/commands/memory.py +430 -0
  13. agent_notes/commands/regenerate.py +152 -0
  14. agent_notes/commands/set_role.py +143 -0
  15. agent_notes/commands/uninstall.py +26 -0
  16. agent_notes/commands/update.py +169 -0
  17. agent_notes/commands/validate.py +199 -0
  18. agent_notes/commands/wizard.py +720 -0
  19. agent_notes/config.py +154 -0
  20. agent_notes/data/agents/agents.yaml +352 -0
  21. agent_notes/data/agents/analyst.md +45 -0
  22. agent_notes/data/agents/api-reviewer.md +47 -0
  23. agent_notes/data/agents/architect.md +46 -0
  24. agent_notes/data/agents/coder.md +28 -0
  25. agent_notes/data/agents/database-specialist.md +45 -0
  26. agent_notes/data/agents/debugger.md +47 -0
  27. agent_notes/data/agents/devil.md +47 -0
  28. agent_notes/data/agents/devops.md +38 -0
  29. agent_notes/data/agents/explorer.md +23 -0
  30. agent_notes/data/agents/integrations.md +44 -0
  31. agent_notes/data/agents/lead.md +216 -0
  32. agent_notes/data/agents/performance-profiler.md +44 -0
  33. agent_notes/data/agents/refactorer.md +48 -0
  34. agent_notes/data/agents/reviewer.md +44 -0
  35. agent_notes/data/agents/security-auditor.md +44 -0
  36. agent_notes/data/agents/system-auditor.md +38 -0
  37. agent_notes/data/agents/tech-writer.md +32 -0
  38. agent_notes/data/agents/test-runner.md +36 -0
  39. agent_notes/data/agents/test-writer.md +39 -0
  40. agent_notes/data/cli/claude.yaml +25 -0
  41. agent_notes/data/cli/copilot.yaml +18 -0
  42. agent_notes/data/cli/opencode.yaml +22 -0
  43. agent_notes/data/commands/brainstorm.md +8 -0
  44. agent_notes/data/commands/debug.md +9 -0
  45. agent_notes/data/commands/review.md +10 -0
  46. agent_notes/data/global-claude.md +290 -0
  47. agent_notes/data/global-copilot.md +27 -0
  48. agent_notes/data/global-opencode.md +40 -0
  49. agent_notes/data/hooks/session-context.md.tpl +19 -0
  50. agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
  51. agent_notes/data/models/claude-opus-4-1.yaml +16 -0
  52. agent_notes/data/models/claude-opus-4-5.yaml +16 -0
  53. agent_notes/data/models/claude-opus-4-6.yaml +16 -0
  54. agent_notes/data/models/claude-opus-4-7.yaml +15 -0
  55. agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
  56. agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
  57. agent_notes/data/models/claude-sonnet-4.yaml +16 -0
  58. agent_notes/data/pricing.yaml +33 -0
  59. agent_notes/data/roles/orchestrator.yaml +5 -0
  60. agent_notes/data/roles/reasoner.yaml +5 -0
  61. agent_notes/data/roles/scout.yaml +5 -0
  62. agent_notes/data/roles/worker.yaml +5 -0
  63. agent_notes/data/rules/code-quality.md +9 -0
  64. agent_notes/data/rules/safety.md +10 -0
  65. agent_notes/data/scripts/cost-report +211 -0
  66. agent_notes/data/skills/brainstorming/SKILL.md +57 -0
  67. agent_notes/data/skills/code-review/SKILL.md +64 -0
  68. agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
  69. agent_notes/data/skills/docker-compose/SKILL.md +318 -0
  70. agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
  71. agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
  72. agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
  73. agent_notes/data/skills/git/SKILL.md +87 -0
  74. agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
  75. agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
  76. agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
  77. agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
  78. agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
  79. agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
  80. agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
  81. agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
  82. agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
  83. agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
  84. agent_notes/data/skills/rails-lib/SKILL.md +101 -0
  85. agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
  86. agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
  87. agent_notes/data/skills/rails-models/SKILL.md +459 -0
  88. agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
  89. agent_notes/data/skills/rails-routes/SKILL.md +804 -0
  90. agent_notes/data/skills/rails-style/SKILL.md +538 -0
  91. agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
  92. agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
  93. agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
  94. agent_notes/data/skills/rails-validations/SKILL.md +108 -0
  95. agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
  96. agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
  97. agent_notes/data/skills/rails-views/SKILL.md +413 -0
  98. agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
  99. agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
  100. agent_notes/data/skills/tdd/SKILL.md +57 -0
  101. agent_notes/data/templates/__init__.py +1 -0
  102. agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
  103. agent_notes/data/templates/frontmatter/__init__.py +1 -0
  104. agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
  105. agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
  106. agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
  107. agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
  108. agent_notes/data/templates/frontmatter/claude.py +44 -0
  109. agent_notes/data/templates/frontmatter/opencode.py +104 -0
  110. agent_notes/doctor_checks.py +189 -0
  111. agent_notes/domain/__init__.py +17 -0
  112. agent_notes/domain/agent.py +34 -0
  113. agent_notes/domain/cli_backend.py +40 -0
  114. agent_notes/domain/diagnostics.py +29 -0
  115. agent_notes/domain/diff.py +44 -0
  116. agent_notes/domain/model.py +27 -0
  117. agent_notes/domain/role.py +13 -0
  118. agent_notes/domain/rule.py +13 -0
  119. agent_notes/domain/skill.py +15 -0
  120. agent_notes/domain/state.py +46 -0
  121. agent_notes/install_state.py +11 -0
  122. agent_notes/registries/__init__.py +16 -0
  123. agent_notes/registries/_base.py +46 -0
  124. agent_notes/registries/agent_registry.py +107 -0
  125. agent_notes/registries/cli_registry.py +89 -0
  126. agent_notes/registries/model_registry.py +85 -0
  127. agent_notes/registries/role_registry.py +64 -0
  128. agent_notes/registries/rule_registry.py +80 -0
  129. agent_notes/registries/skill_registry.py +141 -0
  130. agent_notes/services/__init__.py +8 -0
  131. agent_notes/services/diagnostics/__init__.py +47 -0
  132. agent_notes/services/diagnostics/_checks.py +272 -0
  133. agent_notes/services/diagnostics/_display.py +346 -0
  134. agent_notes/services/diagnostics/_fix.py +169 -0
  135. agent_notes/services/diff.py +349 -0
  136. agent_notes/services/fs.py +195 -0
  137. agent_notes/services/install_state_builder.py +210 -0
  138. agent_notes/services/installer.py +293 -0
  139. agent_notes/services/memory_backend.py +155 -0
  140. agent_notes/services/rendering.py +329 -0
  141. agent_notes/services/session_context.py +23 -0
  142. agent_notes/services/settings_writer.py +79 -0
  143. agent_notes/services/state_store.py +249 -0
  144. agent_notes/services/ui.py +419 -0
  145. agent_notes/services/user_config.py +62 -0
  146. agent_notes/services/validation.py +67 -0
  147. agent_notes/state.py +21 -0
  148. agent_notes-2.0.4.dist-info/METADATA +14 -0
  149. agent_notes-2.0.4.dist-info/RECORD +162 -0
  150. agent_notes-2.0.4.dist-info/WHEEL +5 -0
  151. agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
  152. agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
  153. agent_notes-2.0.4.dist-info/top_level.txt +2 -0
  154. tests/conftest.py +20 -0
  155. tests/functional/__init__.py +0 -0
  156. tests/functional/test_build_commands.py +88 -0
  157. tests/functional/test_registries.py +128 -0
  158. tests/integration/__init__.py +0 -0
  159. tests/integration/test_build_output.py +129 -0
  160. tests/plugins/__init__.py +0 -0
  161. tests/plugins/test_agents.py +93 -0
  162. tests/plugins/test_skills.py +77 -0
@@ -0,0 +1,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]