aisc-cli 0.1.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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,252 @@
1
+ """Read-only legacy-layout scanner (Stage 7, 7c, DATA-02).
2
+
3
+ Walks the pre-Stage-7 AISC namespaces in a workspace and classifies every
4
+ file against the domain allowlist (``domain/data_migration.py``): owned /
5
+ transient / unknown / conflict, plus namespace-level ``foreign`` detection
6
+ (a ``.claude`` that predates AISC — no init marker — is the user's own and
7
+ is never migrated). Produces the migration manifest draft
8
+ (``aisc.data-migration/v1``) that 7d executes.
9
+
10
+ Safety properties:
11
+ - strictly read-only: no directory is created, no file touched;
12
+ - symlinks/reparse points inside a namespace are never followed — they
13
+ classify as ``unknown`` (quarantine candidate), so a link can neither
14
+ leak content out of the workspace nor smuggle a target in;
15
+ - ``owned``/``unknown`` files are hashed (SHA-256, streamed) because the
16
+ manifest commits to exact bytes; conflicts compare hashes, never mtimes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import os
23
+ import stat
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Dict, List
27
+
28
+ from aisc.domain.data_migration import (
29
+ AISC_INIT_MARKERS,
30
+ ENTRY_CONFLICT,
31
+ ENTRY_OWNED,
32
+ ENTRY_TRANSIENT,
33
+ ENTRY_UNKNOWN,
34
+ MIGRATION_PROTOCOL,
35
+ MIGRATION_SCHEMA_VERSION,
36
+ NAMESPACE_AISC,
37
+ NAMESPACE_FOREIGN,
38
+ STATE_PREPARED,
39
+ MigrationEntry,
40
+ MigrationManifest,
41
+ classify,
42
+ )
43
+ from aisc.domain.data_root import ResolvedDataRoot
44
+
45
+ _HASH_CHUNK = 1 << 16
46
+
47
+ _FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 # windows.h (symlink, junction, …)
48
+ _IO_REPARSE_TAG_AF_UNIX = 0x80000023 # winsock AF_UNIX socket files
49
+
50
+
51
+ def _is_reparse(path: Path) -> bool:
52
+ """True for symlinks AND junctions/OneDrive placeholders (Python's
53
+ ``is_symlink`` misses junctions; the attribute bit does not)."""
54
+ try:
55
+ st = os.lstat(path)
56
+ except OSError:
57
+ return False
58
+ if stat.S_ISLNK(st.st_mode):
59
+ return True
60
+ return bool(getattr(st, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT)
61
+
62
+
63
+ def _sha256_file(path: Path) -> str:
64
+ digest = hashlib.sha256()
65
+ with open(path, "rb") as f:
66
+ while True:
67
+ chunk = f.read(_HASH_CHUNK)
68
+ if not chunk:
69
+ break
70
+ digest.update(chunk)
71
+ return digest.hexdigest()
72
+
73
+
74
+ @dataclass
75
+ class NamespaceFinding:
76
+ """Namespace-level report row (doctor / dry-run UX)."""
77
+
78
+ namespace: str # e.g. ".cc-switch" or ".local/state/cc-switch"
79
+ kind: str # NAMESPACE_AISC | NAMESPACE_FOREIGN
80
+ file_count: int = 0
81
+ total_bytes: int = 0
82
+
83
+ def to_dict(self) -> Dict[str, object]:
84
+ return {
85
+ "namespace": self.namespace,
86
+ "kind": self.kind,
87
+ "file_count": self.file_count,
88
+ "total_bytes": self.total_bytes,
89
+ }
90
+
91
+
92
+ @dataclass
93
+ class LegacyScan:
94
+ """Full read-only scan result for one workspace."""
95
+
96
+ workspace: Path
97
+ resolved: ResolvedDataRoot
98
+ findings: List[NamespaceFinding] = field(default_factory=list)
99
+ entries: List[MigrationEntry] = field(default_factory=list)
100
+
101
+ # -- rollups ------------------------------------------------------------
102
+
103
+ def counts(self) -> Dict[str, int]:
104
+ out = dict.fromkeys(("owned", "transient", "unknown", "conflict"), 0)
105
+ for e in self.entries:
106
+ out[e.classification] = out.get(e.classification, 0) + 1
107
+ return out
108
+
109
+ def owned_bytes(self) -> int:
110
+ return sum(e.size for e in self.entries if e.classification == ENTRY_OWNED)
111
+
112
+ def summary(self) -> Dict[str, object]:
113
+ """Doctor/dry-run payload (counts and sizes only; paths stay in the
114
+ manifest the user explicitly exports)."""
115
+ return {
116
+ "schema": MIGRATION_PROTOCOL,
117
+ "workspace_hash": self.resolved.workspace_hash,
118
+ "namespaces": [f.to_dict() for f in self.findings],
119
+ "counts": self.counts(),
120
+ "owned_bytes": self.owned_bytes(),
121
+ "has_unknowns": any(e.classification == ENTRY_UNKNOWN for e in self.entries),
122
+ "has_conflicts": any(e.classification == ENTRY_CONFLICT for e in self.entries),
123
+ "has_foreign": any(f.kind == NAMESPACE_FOREIGN for f in self.findings),
124
+ }
125
+
126
+ def to_manifest(self) -> MigrationManifest:
127
+ """Draft manifest: 7d persists it (state=prepared) before copying."""
128
+ return MigrationManifest(
129
+ schema=MIGRATION_PROTOCOL,
130
+ schema_version=MIGRATION_SCHEMA_VERSION,
131
+ workspace_hash=self.resolved.workspace_hash,
132
+ source=str(self.workspace.resolve()),
133
+ target=str(self.resolved.workspace_dir),
134
+ entries=list(self.entries),
135
+ state=STATE_PREPARED,
136
+ )
137
+
138
+
139
+ def _is_aisc_initialized(workspace: Path) -> bool:
140
+ return any((workspace / m).exists() for m in AISC_INIT_MARKERS)
141
+
142
+
143
+ def scan_legacy_workspace(workspace: Path, resolved: ResolvedDataRoot) -> LegacyScan:
144
+ """Scan *workspace* for legacy AISC namespaces. Read-only; missing
145
+ namespaces are simply absent from the findings."""
146
+ ws = Path(workspace)
147
+ scan = LegacyScan(workspace=ws, resolved=resolved)
148
+ aisc_initialized = _is_aisc_initialized(ws)
149
+
150
+ for namespace in (".aisc", ".claude", ".codex", ".cc-switch", ".local"):
151
+ ns_root = ws / namespace
152
+ if not ns_root.exists():
153
+ continue
154
+ finding = NamespaceFinding(namespace=namespace, kind=NAMESPACE_AISC)
155
+ if not aisc_initialized:
156
+ # None of the AISC init markers exist: these namespaces are the
157
+ # user's own (D7-03) — report, never migrate.
158
+ finding.kind = NAMESPACE_FOREIGN
159
+ finding.file_count, finding.total_bytes = _count_tree(ns_root)
160
+ scan.findings.append(finding)
161
+ continue
162
+ scan.findings.append(finding)
163
+ _scan_namespace(ws, namespace, ns_root, resolved, scan, finding)
164
+
165
+ scan.entries.sort(key=lambda e: e.relative)
166
+ return scan
167
+
168
+
169
+ def _scan_namespace(
170
+ ws: Path,
171
+ namespace: str,
172
+ ns_root: Path,
173
+ resolved: ResolvedDataRoot,
174
+ scan: LegacyScan,
175
+ finding: NamespaceFinding,
176
+ ) -> None:
177
+ for dirpath, dirnames, filenames in os.walk(ns_root, followlinks=False):
178
+ # Never descend into symlinked/junctioned dirs (junctions are not
179
+ # ``is_symlink`` — check the reparse attribute too).
180
+ dirnames[:] = [d for d in dirnames if not _is_reparse(Path(dirpath) / d)]
181
+ for fname in filenames:
182
+ abs_path = Path(dirpath) / fname
183
+ rel_ns = abs_path.relative_to(ns_root).as_posix()
184
+ finding.file_count += 1
185
+
186
+ if _is_reparse(abs_path):
187
+ # Windows AF_UNIX sockets are reparse points (Python on
188
+ # Windows does not set S_IFSOCK — decode the tag); they are
189
+ # transient coordination files, not quarantine candidates.
190
+ try:
191
+ st = os.lstat(abs_path)
192
+ is_socket = stat.S_ISSOCK(st.st_mode) or (
193
+ getattr(st, "st_reparse_tag", 0) == _IO_REPARSE_TAG_AF_UNIX
194
+ )
195
+ if is_socket:
196
+ scan.entries.append(
197
+ MigrationEntry(relative=f"{namespace}/{rel_ns}",
198
+ classification=ENTRY_TRANSIENT)
199
+ )
200
+ continue
201
+ except OSError:
202
+ pass
203
+ # Never follow or stat the target (a symlink to a huge file
204
+ # must not inflate sizes); quarantine candidate.
205
+ scan.entries.append(
206
+ MigrationEntry(relative=f"{namespace}/{rel_ns}",
207
+ classification=ENTRY_UNKNOWN)
208
+ )
209
+ continue
210
+
211
+ try:
212
+ size = abs_path.stat().st_size
213
+ except OSError:
214
+ size = 0
215
+ finding.total_bytes += size
216
+
217
+ classification, target_rel = classify(namespace, rel_ns)
218
+ if classification == ENTRY_TRANSIENT:
219
+ scan.entries.append(
220
+ MigrationEntry(relative=f"{namespace}/{rel_ns}",
221
+ classification=ENTRY_TRANSIENT, size=size)
222
+ )
223
+ continue
224
+
225
+ digest = _sha256_file(abs_path)
226
+ if classification == ENTRY_OWNED and target_rel:
227
+ target_abs = resolved.workspace_dir / Path(*target_rel.split("/"))
228
+ if target_abs.exists():
229
+ # Fail closed on any mismatch; identical bytes = already
230
+ # migrated (skipped by 7d, not re-copied).
231
+ classification = (
232
+ ENTRY_OWNED if _sha256_file(target_abs) == digest
233
+ else ENTRY_CONFLICT
234
+ )
235
+ scan.entries.append(
236
+ MigrationEntry(relative=f"{namespace}/{rel_ns}",
237
+ classification=classification,
238
+ sha256=digest, size=size, target=target_rel)
239
+ )
240
+
241
+
242
+ def _count_tree(root: Path) -> tuple[int, int]:
243
+ files = 0
244
+ total = 0
245
+ for dirpath, _dirnames, filenames in os.walk(root, followlinks=False):
246
+ for fname in filenames:
247
+ files += 1
248
+ try:
249
+ total += (Path(dirpath) / fname).stat().st_size
250
+ except OSError:
251
+ pass
252
+ return files, total