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
scc_cli/sessions.py ADDED
@@ -0,0 +1,425 @@
1
+ """
2
+ Manage Claude Code sessions.
3
+
4
+ Track recent sessions, workspaces, containers, and enable resuming.
5
+
6
+ Container Linking:
7
+ - Sessions are linked to their Docker container names
8
+ - Container names are deterministic: scc-<workspace>-<hash>
9
+ - This enables seamless resume of Claude Code conversations
10
+ """
11
+
12
+ import json
13
+ from dataclasses import asdict, dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+
18
+ from . import config
19
+ from .constants import AGENT_CONFIG_DIR
20
+ from .utils.locks import file_lock, lock_path
21
+
22
+ # ═══════════════════════════════════════════════════════════════════════════════
23
+ # Data Classes
24
+ # ═══════════════════════════════════════════════════════════════════════════════
25
+
26
+
27
+ @dataclass
28
+ class SessionRecord:
29
+ """A recorded Claude Code session with container linking."""
30
+
31
+ workspace: str
32
+ team: str | None = None
33
+ name: str | None = None
34
+ container_name: str | None = None
35
+ branch: str | None = None
36
+ last_used: str | None = None
37
+ created_at: str | None = None
38
+ schema_version: int = 1 # For future migration support
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ """Convert the record to a dictionary for JSON serialization."""
42
+ return {k: v for k, v in asdict(self).items() if v is not None}
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict[str, Any]) -> "SessionRecord":
46
+ """Create a SessionRecord from a dictionary."""
47
+ return cls(
48
+ workspace=data.get("workspace", ""),
49
+ team=data.get("team"),
50
+ name=data.get("name"),
51
+ container_name=data.get("container_name"),
52
+ branch=data.get("branch"),
53
+ last_used=data.get("last_used"),
54
+ created_at=data.get("created_at"),
55
+ schema_version=data.get("schema_version", 1),
56
+ )
57
+
58
+
59
+ # ═══════════════════════════════════════════════════════════════════════════════
60
+ # Session Operations
61
+ # ═══════════════════════════════════════════════════════════════════════════════
62
+
63
+
64
+ def get_most_recent() -> dict[str, Any] | None:
65
+ """
66
+ Return the most recently used session.
67
+
68
+ Returns:
69
+ Session dict with workspace, team, container_name, etc. or None if no sessions.
70
+ """
71
+ sessions = _load_sessions()
72
+
73
+ if not sessions:
74
+ return None
75
+
76
+ # Sort by last_used descending and return first
77
+ sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
78
+ return sessions[0]
79
+
80
+
81
+ def list_recent(limit: int = 10) -> list[dict[str, Any]]:
82
+ """
83
+ Return recent sessions with container and relative time info.
84
+
85
+ Returns list of dicts with: name, workspace, team, last_used, container_name, branch
86
+ """
87
+ sessions = _load_sessions()
88
+
89
+ # Sort by last_used descending
90
+ sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
91
+
92
+ # Limit results
93
+ sessions = sessions[:limit]
94
+
95
+ # Format for display
96
+ result = []
97
+ for s in sessions:
98
+ last_used = s.get("last_used", "")
99
+ if last_used:
100
+ try:
101
+ dt = datetime.fromisoformat(last_used)
102
+ last_used = format_relative_time(dt)
103
+ except ValueError:
104
+ pass
105
+
106
+ result.append(
107
+ {
108
+ "name": s.get("name") or _generate_session_name(s),
109
+ "workspace": s.get("workspace", ""),
110
+ "team": s.get("team"),
111
+ "last_used": last_used,
112
+ "container_name": s.get("container_name"),
113
+ "branch": s.get("branch"),
114
+ }
115
+ )
116
+
117
+ return result
118
+
119
+
120
+ def _generate_session_name(session: dict[str, Any]) -> str:
121
+ """Generate a display name for a session without an explicit name."""
122
+ workspace = session.get("workspace", "")
123
+ if workspace:
124
+ return Path(workspace).name
125
+ return "Unnamed"
126
+
127
+
128
+ def record_session(
129
+ workspace: str,
130
+ team: str | None = None,
131
+ session_name: str | None = None,
132
+ container_name: str | None = None,
133
+ branch: str | None = None,
134
+ ) -> SessionRecord:
135
+ """
136
+ Record a new session or update an existing one.
137
+
138
+ Key sessions by workspace + branch combination.
139
+ """
140
+ lock_file = lock_path("sessions")
141
+ with file_lock(lock_file):
142
+ sessions = _load_sessions()
143
+ now = datetime.now().isoformat()
144
+
145
+ # Find existing session for this workspace+branch
146
+ existing_idx = None
147
+ for idx, s in enumerate(sessions):
148
+ if s.get("workspace") == workspace and s.get("branch") == branch:
149
+ existing_idx = idx
150
+ break
151
+
152
+ record = SessionRecord(
153
+ workspace=workspace,
154
+ team=team,
155
+ name=session_name,
156
+ container_name=container_name,
157
+ branch=branch,
158
+ last_used=now,
159
+ created_at=(
160
+ sessions[existing_idx].get("created_at", now) if existing_idx is not None else now
161
+ ),
162
+ )
163
+
164
+ if existing_idx is not None:
165
+ # Update existing
166
+ sessions[existing_idx] = record.to_dict()
167
+ else:
168
+ # Add new
169
+ sessions.insert(0, record.to_dict())
170
+
171
+ _save_sessions(sessions)
172
+ return record
173
+
174
+
175
+ def update_session_container(
176
+ workspace: str,
177
+ container_name: str,
178
+ branch: str | None = None,
179
+ ) -> None:
180
+ """
181
+ Update the container name for an existing session.
182
+
183
+ Call when a container is created for a session.
184
+ """
185
+ lock_file = lock_path("sessions")
186
+ with file_lock(lock_file):
187
+ sessions = _load_sessions()
188
+
189
+ for s in sessions:
190
+ if s.get("workspace") == workspace:
191
+ if branch is None or s.get("branch") == branch:
192
+ s["container_name"] = container_name
193
+ s["last_used"] = datetime.now().isoformat()
194
+ break
195
+
196
+ _save_sessions(sessions)
197
+
198
+
199
+ def find_session_by_container(container_name: str) -> dict[str, Any] | None:
200
+ """
201
+ Find a session by its container name.
202
+
203
+ Use for resume operations.
204
+ """
205
+ sessions = _load_sessions()
206
+ for s in sessions:
207
+ if s.get("container_name") == container_name:
208
+ return s
209
+ return None
210
+
211
+
212
+ def find_session_by_workspace(
213
+ workspace: str,
214
+ branch: str | None = None,
215
+ ) -> dict[str, Any] | None:
216
+ """
217
+ Find a session by workspace and optionally branch.
218
+
219
+ Return the most recent matching session.
220
+ """
221
+ sessions = _load_sessions()
222
+
223
+ # Sort by last_used descending
224
+ sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
225
+
226
+ for s in sessions:
227
+ if s.get("workspace") == workspace:
228
+ if branch is None or s.get("branch") == branch:
229
+ return s
230
+ return None
231
+
232
+
233
+ def get_container_for_workspace(
234
+ workspace: str,
235
+ branch: str | None = None,
236
+ ) -> str | None:
237
+ """
238
+ Return the container name for a workspace (and optionally branch).
239
+
240
+ Return None if no container has been recorded.
241
+ """
242
+ session = find_session_by_workspace(workspace, branch)
243
+ if session:
244
+ return session.get("container_name")
245
+ return None
246
+
247
+
248
+ # ═══════════════════════════════════════════════════════════════════════════════
249
+ # History Management
250
+ # ═══════════════════════════════════════════════════════════════════════════════
251
+
252
+
253
+ def clear_history() -> int:
254
+ """
255
+ Clear all session history.
256
+
257
+ Return the number of sessions cleared.
258
+ """
259
+ lock_file = lock_path("sessions")
260
+ with file_lock(lock_file):
261
+ sessions = _load_sessions()
262
+ count = len(sessions)
263
+ _save_sessions([])
264
+ return count
265
+
266
+
267
+ def remove_session(workspace: str, branch: str | None = None) -> bool:
268
+ """
269
+ Remove a specific session from history.
270
+
271
+ Args:
272
+ workspace: Workspace path to remove
273
+ branch: Optional branch (if None, removes all sessions for workspace)
274
+
275
+ Returns:
276
+ True if session was found and removed
277
+ """
278
+ lock_file = lock_path("sessions")
279
+ with file_lock(lock_file):
280
+ sessions = _load_sessions()
281
+ original_count = len(sessions)
282
+
283
+ if branch:
284
+ sessions = [
285
+ s
286
+ for s in sessions
287
+ if not (s.get("workspace") == workspace and s.get("branch") == branch)
288
+ ]
289
+ else:
290
+ sessions = [s for s in sessions if s.get("workspace") != workspace]
291
+
292
+ _save_sessions(sessions)
293
+ return len(sessions) < original_count
294
+
295
+
296
+ def prune_orphaned_sessions() -> int:
297
+ """
298
+ Remove sessions whose workspaces no longer exist.
299
+
300
+ Return the number of sessions pruned.
301
+ """
302
+ lock_file = lock_path("sessions")
303
+ with file_lock(lock_file):
304
+ sessions = _load_sessions()
305
+ original_count = len(sessions)
306
+
307
+ valid_sessions = [s for s in sessions if Path(s.get("workspace", "")).expanduser().exists()]
308
+
309
+ _save_sessions(valid_sessions)
310
+ return original_count - len(valid_sessions)
311
+
312
+
313
+ # ═══════════════════════════════════════════════════════════════════════════════
314
+ # Claude Code Integration
315
+ # ═══════════════════════════════════════════════════════════════════════════════
316
+
317
+
318
+ def get_claude_sessions_dir() -> Path:
319
+ """Return the Claude Code sessions directory."""
320
+ # Claude Code stores sessions in its config directory
321
+ return Path.home() / AGENT_CONFIG_DIR
322
+
323
+
324
+ def get_claude_recent_sessions() -> list[dict[Any, Any]]:
325
+ """
326
+ Return recent sessions from Claude Code's own storage.
327
+
328
+ Read from ~/.claude/ if available.
329
+ Note: Claude Code's session format may change; this is best-effort.
330
+ """
331
+ claude_dir = get_claude_sessions_dir()
332
+ sessions_file = claude_dir / "sessions.json"
333
+
334
+ if sessions_file.exists():
335
+ try:
336
+ with open(sessions_file) as f:
337
+ data = json.load(f)
338
+ return cast(list[dict[Any, Any]], data.get("sessions", []))
339
+ except (OSError, json.JSONDecodeError):
340
+ pass
341
+
342
+ return []
343
+
344
+
345
+ # ═══════════════════════════════════════════════════════════════════════════════
346
+ # Internal Helpers
347
+ # ═══════════════════════════════════════════════════════════════════════════════
348
+
349
+
350
+ def _migrate_legacy_sessions(sessions: list[dict[Any, Any]]) -> list[dict[Any, Any]]:
351
+ """Migrate legacy session records to current format.
352
+
353
+ Migrations performed:
354
+ - team == "base" → team = None (standalone mode)
355
+
356
+ This allows sessions created with the old hardcoded "base" fallback
357
+ to be safely loaded without causing "Team Not Found" errors.
358
+
359
+ Args:
360
+ sessions: List of raw session dicts from JSON.
361
+
362
+ Returns:
363
+ Migrated session list (same list, mutated in place).
364
+ """
365
+ for session in sessions:
366
+ # Migration: "base" was never a real team, treat as standalone
367
+ if session.get("team") == "base":
368
+ session["team"] = None
369
+
370
+ return sessions
371
+
372
+
373
+ def _load_sessions() -> list[dict[Any, Any]]:
374
+ """Load and return sessions from the config file.
375
+
376
+ Performs legacy migrations on load to handle sessions saved
377
+ with older schema versions.
378
+ """
379
+ sessions_file = config.SESSIONS_FILE
380
+
381
+ if sessions_file.exists():
382
+ try:
383
+ with open(sessions_file) as f:
384
+ data = json.load(f)
385
+ sessions = cast(list[dict[Any, Any]], data.get("sessions", []))
386
+ # Apply migrations for legacy sessions
387
+ return _migrate_legacy_sessions(sessions)
388
+ except (OSError, json.JSONDecodeError):
389
+ pass
390
+
391
+ return []
392
+
393
+
394
+ def _save_sessions(sessions: list[dict[str, Any]]) -> None:
395
+ """Save the sessions list to the config file."""
396
+ sessions_file = config.SESSIONS_FILE
397
+
398
+ # Ensure parent directory exists
399
+ sessions_file.parent.mkdir(parents=True, exist_ok=True)
400
+
401
+ with open(sessions_file, "w") as f:
402
+ json.dump({"sessions": sessions}, f, indent=2)
403
+
404
+
405
+ def format_relative_time(dt: datetime) -> str:
406
+ """Format a datetime as a relative time string (e.g., '2h ago')."""
407
+ now = datetime.now()
408
+ diff = now - dt
409
+
410
+ seconds = diff.total_seconds()
411
+
412
+ if seconds < 60:
413
+ return "just now"
414
+ elif seconds < 3600:
415
+ minutes = int(seconds / 60)
416
+ return f"{minutes}m ago"
417
+ elif seconds < 86400:
418
+ hours = int(seconds / 3600)
419
+ return f"{hours}h ago"
420
+ elif seconds < 604800:
421
+ days = int(seconds / 86400)
422
+ return f"{days}d ago"
423
+ else:
424
+ weeks = int(seconds / 604800)
425
+ return f"{weeks}w ago"