scc-cli 1.4.0__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.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (112) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +259 -0
  8. scc_cli/cli_admin.py +683 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1405 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,431 @@
1
+ """Core navigation engine for interactive list-based UI.
2
+
3
+ This module provides the ListScreen component - the heart of the interactive
4
+ UI system. It handles:
5
+ - State management (cursor, scroll, filter, selection)
6
+ - Key handling (using the keys module)
7
+ - Rendering (using the chrome module)
8
+ - Event loop (blocking key reads → state updates → re-render)
9
+
10
+ The design is mode-agnostic: SINGLE_SELECT, MULTI_SELECT, and ACTIONABLE
11
+ modes use the same engine with different handlers for actions.
12
+
13
+ Example:
14
+ >>> items = [ListItem(value=t, label=t.name) for t in teams]
15
+ >>> screen = ListScreen(items, title="Select Team")
16
+ >>> selected = screen.run() # Blocking until selection or cancel
17
+ >>> if selected:
18
+ ... print(f"Selected: {selected.value}")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable, Sequence
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum, auto
26
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
27
+
28
+ from rich.console import RenderableType
29
+ from rich.live import Live
30
+ from rich.text import Text
31
+
32
+ from scc_cli.theme import Indicators
33
+
34
+ from .chrome import Chrome, ChromeConfig
35
+ from .keys import Action, ActionType, KeyReader, TeamSwitchRequested
36
+
37
+ if TYPE_CHECKING:
38
+ pass
39
+
40
+ T = TypeVar("T")
41
+
42
+
43
+ class ListMode(Enum):
44
+ """Operating mode for ListScreen.
45
+
46
+ Determines how the screen handles selection and confirmation.
47
+ """
48
+
49
+ SINGLE_SELECT = auto() # Enter returns single item
50
+ MULTI_SELECT = auto() # Space toggles, Enter returns list
51
+ ACTIONABLE = auto() # Action keys dispatch callbacks
52
+
53
+
54
+ @dataclass
55
+ class ListItem(Generic[T]):
56
+ """Wrapper for items in a list with display metadata.
57
+
58
+ Attributes:
59
+ value: The underlying value (domain object).
60
+ label: Primary display text.
61
+ description: Secondary text (optional).
62
+ metadata: Additional key-value pairs for display.
63
+ governance_status: "blocked", "warning", or None.
64
+ """
65
+
66
+ value: T
67
+ label: str
68
+ description: str = ""
69
+ metadata: dict[str, str] = field(default_factory=dict)
70
+ governance_status: str | None = None
71
+
72
+
73
+ @dataclass
74
+ class ListState(Generic[T]):
75
+ """Mutable state for ListScreen navigation and selection.
76
+
77
+ Attributes:
78
+ items: All items in the list.
79
+ cursor: Current cursor position (in filtered items).
80
+ scroll_offset: Scroll position for viewport.
81
+ filter_query: Current type-to-filter query.
82
+ selected: Set of selected indices (for multi-select).
83
+ viewport_height: Max items visible at once.
84
+ """
85
+
86
+ items: Sequence[ListItem[T]]
87
+ cursor: int = 0
88
+ scroll_offset: int = 0
89
+ filter_query: str = ""
90
+ selected: set[int] = field(default_factory=set)
91
+ viewport_height: int = 10
92
+
93
+ @property
94
+ def filtered_items(self) -> list[ListItem[T]]:
95
+ """Items matching the current filter query."""
96
+ if not self.filter_query:
97
+ return list(self.items)
98
+ query = self.filter_query.lower()
99
+ return [
100
+ item
101
+ for item in self.items
102
+ if query in item.label.lower() or query in item.description.lower()
103
+ ]
104
+
105
+ @property
106
+ def visible_items(self) -> list[ListItem[T]]:
107
+ """Items visible in the current viewport."""
108
+ filtered = self.filtered_items
109
+ end = min(self.scroll_offset + self.viewport_height, len(filtered))
110
+ return filtered[self.scroll_offset : end]
111
+
112
+ @property
113
+ def current_item(self) -> ListItem[T] | None:
114
+ """Item at cursor position, or None if list empty."""
115
+ filtered = self.filtered_items
116
+ if 0 <= self.cursor < len(filtered):
117
+ return filtered[self.cursor]
118
+ return None
119
+
120
+ def move_cursor(self, delta: int) -> bool:
121
+ """Move cursor by delta, clamping to valid range.
122
+
123
+ Returns True if cursor position changed.
124
+ """
125
+ filtered = self.filtered_items
126
+ if not filtered:
127
+ return False
128
+
129
+ old_cursor = self.cursor
130
+ self.cursor = max(0, min(len(filtered) - 1, self.cursor + delta))
131
+
132
+ # Adjust scroll to keep cursor visible
133
+ if self.cursor < self.scroll_offset:
134
+ self.scroll_offset = self.cursor
135
+ elif self.cursor >= self.scroll_offset + self.viewport_height:
136
+ self.scroll_offset = self.cursor - self.viewport_height + 1
137
+
138
+ return self.cursor != old_cursor
139
+
140
+ def toggle_selection(self) -> bool:
141
+ """Toggle selection state of current item."""
142
+ if self.current_item is None:
143
+ return False
144
+
145
+ if self.cursor in self.selected:
146
+ self.selected.discard(self.cursor)
147
+ else:
148
+ self.selected.add(self.cursor)
149
+ return True
150
+
151
+ def toggle_all(self) -> bool:
152
+ """Toggle all items selected/deselected."""
153
+ filtered = self.filtered_items
154
+ if not filtered:
155
+ return False
156
+
157
+ all_selected = len(self.selected) == len(filtered)
158
+ if all_selected:
159
+ self.selected.clear()
160
+ else:
161
+ self.selected = set(range(len(filtered)))
162
+ return True
163
+
164
+ def add_filter_char(self, char: str) -> bool:
165
+ """Add character to filter query."""
166
+ self.filter_query += char
167
+ # Reset cursor and selection when filter changes
168
+ # Selection indices become stale when filtered list shrinks
169
+ self.cursor = 0
170
+ self.scroll_offset = 0
171
+ self.selected.clear()
172
+ return True
173
+
174
+ def delete_filter_char(self) -> bool:
175
+ """Remove last character from filter query."""
176
+ if not self.filter_query:
177
+ return False
178
+ self.filter_query = self.filter_query[:-1]
179
+ # Reset cursor and selection when filter changes
180
+ # Selection indices become stale when filtered list changes
181
+ self.cursor = 0
182
+ self.scroll_offset = 0
183
+ self.selected.clear()
184
+ return True
185
+
186
+ def clear_filter(self) -> bool:
187
+ """Clear the entire filter query.
188
+
189
+ Returns True if filter was cleared, False if already empty.
190
+ """
191
+ if not self.filter_query:
192
+ return False
193
+ self.filter_query = ""
194
+ # Reset cursor and selection when filter changes
195
+ # Selection indices become stale when filtered list changes
196
+ self.cursor = 0
197
+ self.scroll_offset = 0
198
+ self.selected.clear()
199
+ return True
200
+
201
+
202
+ class ListScreen(Generic[T]):
203
+ """Core navigation engine for list-based UI.
204
+
205
+ ListScreen combines state management, key handling, and rendering
206
+ into a cohesive event loop that blocks for user input and returns
207
+ the selected value(s).
208
+
209
+ Attributes:
210
+ state: Current list state.
211
+ mode: Operating mode (SINGLE_SELECT, MULTI_SELECT, ACTIONABLE).
212
+ title: Display title for the chrome.
213
+ custom_actions: Custom key handlers for ACTIONABLE mode.
214
+ """
215
+
216
+ def __init__(
217
+ self,
218
+ items: Sequence[ListItem[T]],
219
+ *,
220
+ title: str = "Select",
221
+ mode: ListMode = ListMode.SINGLE_SELECT,
222
+ custom_actions: dict[str, Callable[[ListItem[T]], None]] | None = None,
223
+ viewport_height: int = 10,
224
+ ) -> None:
225
+ """Initialize the list screen.
226
+
227
+ Args:
228
+ items: Items to display in the list.
229
+ title: Title for the chrome header.
230
+ mode: Operating mode for selection behavior.
231
+ custom_actions: Key → handler map for ACTIONABLE mode.
232
+ viewport_height: Max items visible at once.
233
+ """
234
+ self.state = ListState(items=items, viewport_height=viewport_height)
235
+ self.mode = mode
236
+ self.title = title
237
+ self.custom_actions = custom_actions or {}
238
+ from ..console import get_err_console
239
+
240
+ self._console = get_err_console()
241
+
242
+ def run(self) -> T | list[T] | None:
243
+ """Run the interactive list screen.
244
+
245
+ Blocks until the user makes a selection or cancels.
246
+
247
+ Returns:
248
+ - SINGLE_SELECT: The selected item's value, or None if cancelled.
249
+ - MULTI_SELECT: List of selected values, or None if cancelled.
250
+ - ACTIONABLE: None (actions handled via callbacks).
251
+ """
252
+ # Set up key reader with custom keys if in actionable mode
253
+ custom_keys = {k: k for k in self.custom_actions} if self.custom_actions else None
254
+ reader = KeyReader(custom_keys=custom_keys, enable_filter=True)
255
+
256
+ # Use Rich Live for efficient updates
257
+ with Live(
258
+ self._render(),
259
+ console=self._console,
260
+ auto_refresh=False, # Manual refresh for instant response
261
+ transient=True, # Clear on exit
262
+ ) as live:
263
+ while True:
264
+ action = reader.read(filter_active=bool(self.state.filter_query))
265
+
266
+ # Handle action based on type
267
+ result = self._handle_action(action)
268
+
269
+ if result is not None:
270
+ return result
271
+
272
+ if action.should_exit:
273
+ return None
274
+
275
+ # Re-render if state changed
276
+ if action.state_changed:
277
+ live.update(self._render(), refresh=True)
278
+
279
+ def _render(self) -> RenderableType:
280
+ """Render the current state to a Rich renderable."""
281
+ # Build the list body
282
+ body = self._render_list_body()
283
+
284
+ # Get appropriate chrome config
285
+ config = self._get_chrome_config()
286
+
287
+ # Render with chrome
288
+ chrome = Chrome(config)
289
+ return chrome.render(body, search_query=self.state.filter_query)
290
+
291
+ def _render_list_body(self) -> Text:
292
+ """Render the list items."""
293
+ text = Text()
294
+ filtered = self.state.filtered_items
295
+ visible = self.state.visible_items
296
+
297
+ if not filtered:
298
+ text.append("No matches found", style="dim italic")
299
+ if self.state.filter_query:
300
+ text.append(" — ", style="dim")
301
+ text.append("Backspace", style="cyan")
302
+ text.append(" to edit filter", style="dim")
303
+ return text
304
+
305
+ for i, item in enumerate(visible):
306
+ # Calculate actual index in filtered list
307
+ actual_index = self.state.scroll_offset + i
308
+ is_cursor = actual_index == self.state.cursor
309
+ is_selected = actual_index in self.state.selected
310
+
311
+ # Build line with cursor indicator
312
+ if is_cursor:
313
+ text.append(f"{Indicators.get('CURSOR')} ", style="cyan bold")
314
+ else:
315
+ text.append(" ")
316
+
317
+ # Selection checkbox for multi-select
318
+ if self.mode == ListMode.MULTI_SELECT:
319
+ if is_selected:
320
+ text.append(f"[{Indicators.get('PASS')}] ", style="green")
321
+ else:
322
+ text.append("[ ] ", style="dim")
323
+
324
+ # Governance indicator
325
+ if item.governance_status == "blocked":
326
+ text.append("⛔ ", style="red")
327
+ elif item.governance_status == "warning":
328
+ text.append("⚠️ ", style="yellow")
329
+
330
+ # Label and description
331
+ label_style = "bold" if is_cursor else ""
332
+ text.append(item.label, style=label_style)
333
+
334
+ if item.description:
335
+ text.append(f" {item.description}", style="dim")
336
+
337
+ # Metadata badges
338
+ if item.metadata:
339
+ for key, value in item.metadata.items():
340
+ if value: # Only show non-empty values
341
+ text.append(f" [{value}]", style="cyan dim")
342
+
343
+ text.append("\n")
344
+
345
+ # Scroll indicators
346
+ if self.state.scroll_offset > 0:
347
+ text.append(f"{Indicators.get('SCROLL_UP')} more above\n", style="dim")
348
+ if self.state.scroll_offset + self.state.viewport_height < len(filtered):
349
+ text.append(f"{Indicators.get('SCROLL_DOWN')} more below\n", style="dim")
350
+
351
+ return text
352
+
353
+ def _get_chrome_config(self) -> ChromeConfig:
354
+ """Get the appropriate chrome config for current mode."""
355
+ filtered_count = len(self.state.filtered_items)
356
+
357
+ if self.mode == ListMode.MULTI_SELECT:
358
+ return ChromeConfig.for_multi_select(
359
+ self.title,
360
+ len(self.state.selected),
361
+ filtered_count,
362
+ )
363
+ # SINGLE_SELECT and ACTIONABLE use picker style
364
+ return ChromeConfig.for_picker(self.title, item_count=filtered_count)
365
+
366
+ def _handle_action(self, action: Action[Any]) -> T | list[T] | None:
367
+ """Handle an action and return result if selection complete."""
368
+ match action.action_type:
369
+ case ActionType.NAVIGATE_UP:
370
+ self.state.move_cursor(-1)
371
+
372
+ case ActionType.NAVIGATE_DOWN:
373
+ self.state.move_cursor(1)
374
+
375
+ case ActionType.SELECT:
376
+ if self.mode == ListMode.SINGLE_SELECT:
377
+ item = self.state.current_item
378
+ if item:
379
+ return item.value
380
+ elif self.mode == ListMode.MULTI_SELECT:
381
+ # In MULTI_SELECT, Enter confirms selection (same as CONFIRM)
382
+ # This prevents should_exit from returning None as "cancelled"
383
+ filtered = self.state.filtered_items
384
+ selected_indices = self.state.selected & set(range(len(filtered)))
385
+ return [filtered[i].value for i in sorted(selected_indices)]
386
+
387
+ case ActionType.TOGGLE:
388
+ if self.mode == ListMode.MULTI_SELECT:
389
+ self.state.toggle_selection()
390
+
391
+ case ActionType.TOGGLE_ALL:
392
+ if self.mode == ListMode.MULTI_SELECT:
393
+ self.state.toggle_all()
394
+
395
+ case ActionType.CONFIRM:
396
+ if self.mode == ListMode.MULTI_SELECT:
397
+ # Return all selected items
398
+ filtered = self.state.filtered_items
399
+ return [filtered[i].value for i in sorted(self.state.selected)]
400
+
401
+ case ActionType.FILTER_CHAR:
402
+ if action.filter_char:
403
+ self.state.add_filter_char(action.filter_char)
404
+
405
+ case ActionType.FILTER_DELETE:
406
+ self.state.delete_filter_char()
407
+
408
+ case ActionType.CUSTOM:
409
+ if action.custom_key and action.custom_key in self.custom_actions:
410
+ item = self.state.current_item
411
+ if item:
412
+ self.custom_actions[action.custom_key](item)
413
+
414
+ case ActionType.CANCEL | ActionType.QUIT:
415
+ # Will be handled by should_exit in caller
416
+ pass
417
+
418
+ case ActionType.TEAM_SWITCH:
419
+ # Bubble up to orchestrator for consistent team switching
420
+ raise TeamSwitchRequested()
421
+
422
+ case ActionType.HELP:
423
+ # Show mode-aware help overlay
424
+ from .help import HelpMode, show_help_overlay
425
+
426
+ help_mode = (
427
+ HelpMode.MULTI_SELECT if self.mode == ListMode.MULTI_SELECT else HelpMode.PICKER
428
+ )
429
+ show_help_overlay(help_mode, self._console)
430
+
431
+ return None