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,452 @@
1
+ """Config service — read-only validate & effective (S5.2 ora-7 final)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from aisc.domain.config import (
11
+ IssueSeverity, PlatformPathConfig, PathPolicy, SchemaIssue,
12
+ )
13
+ from aisc.schemas.config_schema import validate_config
14
+ from aisc.adapters.config_reader import (
15
+ safe_read_config_bytes, parse_config_json, ReadError,
16
+ check_root_exists, check_dir_component,
17
+ )
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Stable status literals — external contract (frozen set)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ STATUS_LOADED = "loaded"
24
+ STATUS_MISSING = "missing"
25
+ STATUS_PERMISSION_DENIED = "permission_denied"
26
+ STATUS_INVALID_SOURCE = "invalid_source" # structural
27
+ STATUS_ERROR = "error"
28
+
29
+ ERR_CONFIG_INVALID = "AISC_ERR_CONFIG_INVALID"
30
+ ERR_CONFIG_MISSING = "AISC_ERR_CONFIG_MISSING"
31
+ ERR_PERMISSION_DENIED = "AISC_ERR_PERMISSION_DENIED"
32
+ ERR_GENERAL = "AISC_ERR_GENERAL"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Result types
36
+ # ---------------------------------------------------------------------------
37
+
38
+ @dataclass(frozen=True)
39
+ class SourceInfo:
40
+ kind: str; path: str; status: str
41
+
42
+ @dataclass(frozen=True)
43
+ class ConfigIssue:
44
+ severity: str; source: str; path: str; reason_code: str; message: str
45
+
46
+ @dataclass
47
+ class ServiceResult:
48
+ valid: bool = False
49
+ exit_code: int = 0
50
+ error_code: str = ""
51
+ error_message: str = ""
52
+ data: Dict[str, Any] = field(default_factory=dict)
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def _mk_src(kind, path, status):
59
+ return SourceInfo(kind=kind, path=path, status=status)
60
+
61
+ def _early_err(code, errc, msg, user_src, ws_src):
62
+ """Build an early ServiceResult with exactly [user, workspace] sources."""
63
+ return ServiceResult(valid=False, exit_code=code, error_code=errc,
64
+ error_message=msg,
65
+ data={"valid": False,
66
+ "sources": [_src_d(user_src), _src_d(ws_src)],
67
+ "issues": []})
68
+
69
+ def _mk_iss(severity, source, path, rc, msg):
70
+ return ConfigIssue(severity=severity, source=source, path=path, reason_code=rc, message=msg)
71
+
72
+ def _src_d(si): return {"kind": si.kind, "path": si.path, "status": si.status}
73
+ def _iss_d(ci): return {"severity": ci.severity, "source": ci.source, "path": ci.path,
74
+ "reason_code": ci.reason_code, "message": ci.message}
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Content error types — structured, never echoes input
79
+ # ---------------------------------------------------------------------------
80
+
81
+ class ContentErrorKind:
82
+ INVALID_UTF8 = "invalid_utf8"
83
+ INVALID_JSON = "invalid_json"
84
+ DUPLICATE_KEY = "duplicate_key"
85
+ CONFIG_NOT_OBJECT = "config_not_object"
86
+ JSON_DEPTH_LIMIT = "json_depth_limit"
87
+ JSON_NODE_LIMIT = "json_node_limit"
88
+ JSON_STRING_LIMIT = "json_string_limit"
89
+
90
+
91
+ _CE_MESSAGES = {
92
+ ContentErrorKind.INVALID_UTF8: "Config file is not valid UTF-8",
93
+ ContentErrorKind.INVALID_JSON: "Invalid JSON in config file",
94
+ ContentErrorKind.DUPLICATE_KEY: "Duplicate key in config JSON",
95
+ ContentErrorKind.CONFIG_NOT_OBJECT: "Config must be a JSON object",
96
+ ContentErrorKind.JSON_DEPTH_LIMIT: "JSON nesting too deep",
97
+ ContentErrorKind.JSON_NODE_LIMIT: "JSON node limit exceeded",
98
+ ContentErrorKind.JSON_STRING_LIMIT: "JSON string too long",
99
+ }
100
+
101
+
102
+ def _classify_content_error(exc: Exception) -> Optional[str]:
103
+ """Classify a parser exception into a ContentErrorKind string."""
104
+ msg = str(exc)
105
+ if isinstance(exc, UnicodeDecodeError):
106
+ return ContentErrorKind.INVALID_UTF8
107
+ if "Duplicate key" in msg:
108
+ return ContentErrorKind.DUPLICATE_KEY
109
+ if "not a JSON object" in msg or "not object" in msg.lower() or "Config must be a JSON object" in msg:
110
+ return ContentErrorKind.CONFIG_NOT_OBJECT
111
+ if "too deep" in msg.lower() or "nesting too deep" in msg.lower():
112
+ return ContentErrorKind.JSON_DEPTH_LIMIT
113
+ if "node limit" in msg.lower():
114
+ return ContentErrorKind.JSON_NODE_LIMIT
115
+ if "too long" in msg.lower() or "string too long" in msg.lower():
116
+ return ContentErrorKind.JSON_STRING_LIMIT
117
+ if isinstance(exc, ValueError):
118
+ return ContentErrorKind.INVALID_JSON
119
+ return None
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Read one layer — returns (data, source_info, content_error_kind)
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def _read_layer(file_path: Path, is_explicit: bool, kind: str) -> Tuple[Optional[dict], SourceInfo, Optional[str]]:
127
+ """Read/parse one config file. content_error_kind is None on success."""
128
+ fp = str(file_path)
129
+ try:
130
+ raw = safe_read_config_bytes(file_path)
131
+ except FileNotFoundError:
132
+ if is_explicit: raise
133
+ return None, _mk_src(kind, fp, STATUS_MISSING), None
134
+ except PermissionError:
135
+ return None, _mk_src(kind, fp, STATUS_PERMISSION_DENIED), None
136
+ except ReadError:
137
+ return None, _mk_src(kind, fp, STATUS_INVALID_SOURCE), None
138
+ except OSError:
139
+ return None, _mk_src(kind, fp, STATUS_ERROR), None
140
+
141
+ try:
142
+ data = parse_config_json(raw)
143
+ except (UnicodeDecodeError, ValueError) as exc:
144
+ ce = _classify_content_error(exc)
145
+ return None, _mk_src(kind, fp, STATUS_LOADED), ce # external status = loaded
146
+ return data, _mk_src(kind, fp, STATUS_LOADED), None
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Overlay
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def _overlay(base: dict, overrides: dict) -> dict:
154
+ result = dict(base)
155
+ if "defaults" in overrides and isinstance(overrides["defaults"], dict):
156
+ od = overrides["defaults"]
157
+ cur = result.get("defaults", {})
158
+ if not isinstance(cur, dict): cur = {}
159
+ result["defaults"] = {**cur}
160
+ for k in ("profile", "network"):
161
+ if k in od: result["defaults"][k] = od[k]
162
+ return result
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Unified core — validate + effective share reader/validation logic
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def _read_layers(
170
+ explicit_config, workspace, home, env, platform_name,
171
+ ) -> Tuple[Optional[ServiceResult], Optional[dict], Optional[dict],
172
+ List[SourceInfo], List[ConfigIssue]]:
173
+ """Read both config layers. Returns early ServiceResult on fatal error,
174
+ else (user_data, ws_data, sources, issues). Always exactly 2 sources."""
175
+ # --- Establish user_cfg_path before any early return ---
176
+ if explicit_config:
177
+ explicit_config = os.path.abspath(explicit_config)
178
+ user_cfg_path = Path(explicit_config)
179
+ pp = PlatformPathConfig(config_dir="", state_dir="")
180
+ user_status = STATUS_MISSING # not yet read, but path is known
181
+ else:
182
+ try:
183
+ pp = _resolve_platform(home, env, platform_name)
184
+ except ReadError:
185
+ return (_early_err(1, ERR_GENERAL, "Platform path error",
186
+ _mk_src("user", "", STATUS_MISSING),
187
+ _mk_src("workspace", "", STATUS_MISSING)),
188
+ None, None, [], [])
189
+ except PermissionError:
190
+ return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing platform",
191
+ _mk_src("user", "", STATUS_PERMISSION_DENIED),
192
+ _mk_src("workspace", "", STATUS_MISSING)),
193
+ None, None, [], [])
194
+ except OSError:
195
+ return (_early_err(1, ERR_GENERAL, "Cannot access platform config root",
196
+ _mk_src("user", "", STATUS_ERROR),
197
+ _mk_src("workspace", "", STATUS_MISSING)),
198
+ None, None, [], [])
199
+ user_cfg_path = Path(os.path.join(pp.config_dir, "config.json"))
200
+ ws_abs = os.path.abspath(workspace) if workspace else os.path.abspath(os.getcwd())
201
+ policy = PathPolicy(platform=pp, workspace=ws_abs, aisc_root=None)
202
+
203
+ # --- Validate workspace root ---
204
+ try:
205
+ check_root_exists(ws_abs)
206
+ except FileNotFoundError:
207
+ return (_early_err(7, ERR_CONFIG_MISSING, "Workspace root not found",
208
+ _mk_src("user", str(user_cfg_path), "missing"),
209
+ _mk_src("workspace", ws_abs, STATUS_MISSING)),
210
+ None, None, [], [])
211
+ except ReadError:
212
+ return (_early_err(1, ERR_GENERAL, "Workspace root is a symlink",
213
+ _mk_src("user", str(user_cfg_path), "missing"),
214
+ _mk_src("workspace", ws_abs, STATUS_INVALID_SOURCE)),
215
+ None, None, [], [])
216
+ except PermissionError:
217
+ return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing workspace",
218
+ _mk_src("user", str(user_cfg_path), "missing"),
219
+ _mk_src("workspace", ws_abs, STATUS_PERMISSION_DENIED)),
220
+ None, None, [], [])
221
+ except OSError:
222
+ return (_early_err(1, ERR_GENERAL, "Cannot access workspace root",
223
+ _mk_src("user", str(user_cfg_path), "missing"),
224
+ _mk_src("workspace", ws_abs, STATUS_ERROR)),
225
+ None, None, [], [])
226
+ # check_root_exists already verified existence + directory — no second check needed
227
+
228
+ # --- Platform config root (only when auto-detecting user config) ---
229
+ if not explicit_config:
230
+ try:
231
+ check_root_exists(pp.config_dir)
232
+ except FileNotFoundError:
233
+ pass # auto-detected platform root missing → user config will be missing, OK
234
+ except ReadError:
235
+ return (_early_err(1, ERR_GENERAL, "Platform config root is a symlink",
236
+ _mk_src("user", str(user_cfg_path), STATUS_INVALID_SOURCE),
237
+ _mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
238
+ None, None, [], [])
239
+ except PermissionError:
240
+ return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing platform config root",
241
+ _mk_src("user", str(user_cfg_path), STATUS_PERMISSION_DENIED),
242
+ _mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
243
+ None, None, [], [])
244
+ except OSError:
245
+ return (_early_err(1, ERR_GENERAL, "Cannot access platform config root",
246
+ _mk_src("user", str(user_cfg_path), STATUS_ERROR),
247
+ _mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
248
+ None, None, [], [])
249
+
250
+ # --- Read user layer ---
251
+ try:
252
+ user_data, user_src, user_ce = _read_layer(user_cfg_path, bool(explicit_config), "user")
253
+ except FileNotFoundError:
254
+ return (ServiceResult(valid=False, exit_code=7, error_code=ERR_CONFIG_MISSING,
255
+ error_message="Explicit config file not found",
256
+ data={"valid": False, "sources": [
257
+ _src_d(_mk_src("user", str(user_cfg_path), "missing")),
258
+ _src_d(_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
259
+ ], "issues": []}), None, None, [], [])
260
+ except PermissionError:
261
+ return (ServiceResult(valid=False, exit_code=9, error_code=ERR_PERMISSION_DENIED,
262
+ error_message="Permission denied reading user config",
263
+ data={"valid": False, "sources": [
264
+ _src_d(_mk_src("user", str(user_cfg_path), STATUS_PERMISSION_DENIED)),
265
+ _src_d(_mk_src("workspace", _ws_path_str(ws_abs), STATUS_MISSING)),
266
+ ], "issues": []}), None, None, [], [])
267
+
268
+ # --- Read workspace layer ---
269
+ ws_path = _ws_path_str(ws_abs)
270
+ ws_file_path = Path(ws_path)
271
+ legacy_ws_file = Path(ws_abs) / ".aisc" / "config.json"
272
+ if ws_file_path == legacy_ws_file:
273
+ # Structural reparse check only applies to the legacy layout; the
274
+ # data-root path was validated by the resolver at resolve time.
275
+ try:
276
+ check_dir_component(str(ws_file_path.parent.parent), ".aisc")
277
+ except FileNotFoundError:
278
+ pass # .aisc doesn't exist → config.json will be missing (handled by _read_layer)
279
+ except ReadError:
280
+ return (_early_err(1, ERR_GENERAL, "Workspace config component is a symlink",
281
+ user_src,
282
+ _mk_src("workspace", ws_path, STATUS_INVALID_SOURCE)),
283
+ None, None, [], [])
284
+ except PermissionError:
285
+ return (_early_err(9, ERR_PERMISSION_DENIED, "Permission denied accessing workspace config",
286
+ user_src,
287
+ _mk_src("workspace", ws_path, STATUS_PERMISSION_DENIED)),
288
+ None, None, [], [])
289
+ except OSError:
290
+ return (_early_err(1, ERR_GENERAL, "Cannot access workspace config component",
291
+ user_src,
292
+ _mk_src("workspace", ws_path, STATUS_ERROR)),
293
+ None, None, [], [])
294
+
295
+ ws_data, ws_src, ws_ce = _read_layer(ws_file_path, False, "workspace")
296
+
297
+ # Build sources and issues
298
+ sources = [user_src, ws_src]
299
+ issues: List[ConfigIssue] = []
300
+
301
+ # Content errors for user
302
+ if user_ce:
303
+ issues.append(_mk_iss("error", "user", "(root)", user_ce,
304
+ _CE_MESSAGES.get(user_ce, "Content error")))
305
+ # Content errors for workspace
306
+ if ws_ce:
307
+ issues.append(_mk_iss("error", "workspace", "(root)", ws_ce,
308
+ _CE_MESSAGES.get(ws_ce, "Content error")))
309
+
310
+ return None, user_data, ws_data, sources, issues
311
+
312
+
313
+ def _ws_path_str(ws_abs: str) -> str:
314
+ """Workspace config layer path (Stage 7).
315
+
316
+ Canonical: ``<data-root>/workspaces/<hash>/config.json``. The legacy
317
+ ``<ws>/.aisc/config.json`` remains the read fallback until migrations
318
+ catch up. This is a read-only layer — if the data root is unusable we
319
+ degrade to the legacy path instead of failing the whole command
320
+ (writes go through ``workspace_state_dir`` and fail closed there).
321
+ """
322
+ canonical = None
323
+ try:
324
+ from aisc.application.data_root import DataRootResolver
325
+
326
+ resolved = DataRootResolver().resolve(Path(ws_abs))
327
+ canonical = resolved.workspace_dir / "config.json"
328
+ if canonical.is_file():
329
+ return str(canonical)
330
+ except Exception:
331
+ canonical = None
332
+ legacy = Path(ws_abs) / ".aisc" / "config.json"
333
+ # Existing legacy wins over an absent canonical (transition read);
334
+ # otherwise report the canonical location (honest fresh state).
335
+ if legacy.is_file():
336
+ return str(legacy)
337
+ return str(canonical) if canonical is not None else str(legacy)
338
+
339
+
340
+ def _finalize_classify(valid, sources, issues, effective=None, provenance=None):
341
+ """Determine exit code and build ServiceResult."""
342
+ has_error = any(i.severity == "error" for i in issues)
343
+ structural = any(s.status in (STATUS_INVALID_SOURCE, STATUS_ERROR) for s in sources)
344
+ perm = any(s.status == STATUS_PERMISSION_DENIED for s in sources)
345
+
346
+ if structural:
347
+ ec, errc, msg = 1, ERR_GENERAL, "Structural error reading config"
348
+ elif perm:
349
+ ec, errc, msg = 9, ERR_PERMISSION_DENIED, "Permission denied"
350
+ elif has_error:
351
+ ec, errc, msg = 6, ERR_CONFIG_INVALID, "Config validation failed"
352
+ else:
353
+ ec, errc, msg = 0, "", ""
354
+
355
+ d: dict = {"valid": not (has_error or structural or perm),
356
+ "sources": [_src_d(s) for s in sources],
357
+ "issues": [_iss_d(i) for i in issues]}
358
+ if provenance is not None:
359
+ d["effective"] = effective
360
+ d["provenance"] = provenance
361
+ return ServiceResult(valid=d["valid"], exit_code=ec, error_code=errc,
362
+ error_message=msg, data=d)
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Public entry points
367
+ # ---------------------------------------------------------------------------
368
+
369
+ def run_config_validate(*, explicit_config=None, workspace=None, home=None,
370
+ env=None, platform_name=None) -> ServiceResult:
371
+ early, user_data, ws_data, sources, issues = _read_layers(
372
+ explicit_config, workspace, home, env, platform_name)
373
+ if early: return early
374
+
375
+ if user_data is not None:
376
+ for si in validate_config(user_data, is_workspace=False):
377
+ issues.append(ConfigIssue(severity=si.severity.value, source="user",
378
+ path=si.path, reason_code=si.reason_code, message=si.message))
379
+ if ws_data is not None:
380
+ for si in validate_config(ws_data, is_workspace=True):
381
+ issues.append(ConfigIssue(severity=si.severity.value, source="workspace",
382
+ path=si.path, reason_code=si.reason_code, message=si.message))
383
+ return _finalize_classify(True, sources, issues)
384
+
385
+
386
+ def run_config_effective(*, explicit_config=None, workspace=None, home=None,
387
+ env=None, platform_name=None) -> ServiceResult:
388
+ early, user_data, ws_data, sources, issues = _read_layers(
389
+ explicit_config, workspace, home, env, platform_name)
390
+ if early:
391
+ # Early result from validate path — add effective/provenance fields
392
+ early.data["effective"] = None
393
+ early.data["provenance"] = {}
394
+ return early
395
+
396
+ if user_data is not None:
397
+ for si in validate_config(user_data, is_workspace=False):
398
+ issues.append(ConfigIssue(severity=si.severity.value, source="user",
399
+ path=si.path, reason_code=si.reason_code, message=si.message))
400
+ if ws_data is not None:
401
+ for si in validate_config(ws_data, is_workspace=True):
402
+ issues.append(ConfigIssue(severity=si.severity.value, source="workspace",
403
+ path=si.path, reason_code=si.reason_code, message=si.message))
404
+
405
+ has_any = any(s.status not in (STATUS_LOADED, STATUS_MISSING) for s in sources) or \
406
+ any(i.severity == "error" for i in issues)
407
+ if has_any:
408
+ return _finalize_classify(False, sources, issues, effective=None, provenance={})
409
+
410
+ effective = {
411
+ "schema_version": 1,
412
+ "defaults": {"profile": "safe", "network": "direct"},
413
+ }
414
+ provenance: dict = {"defaults.profile": "default", "defaults.network": "default"}
415
+
416
+ if user_data is not None:
417
+ effective = _overlay(effective, user_data)
418
+ if "defaults" in user_data and isinstance(user_data["defaults"], dict):
419
+ ud = user_data["defaults"]
420
+ if "profile" in ud: provenance["defaults.profile"] = "user"
421
+ if "network" in ud: provenance["defaults.network"] = "user"
422
+ if ws_data is not None:
423
+ effective = _overlay(effective, ws_data)
424
+ if "defaults" in ws_data and isinstance(ws_data["defaults"], dict):
425
+ wd = ws_data["defaults"]
426
+ if "profile" in wd: provenance["defaults.profile"] = "workspace"
427
+ if "network" in wd: provenance["defaults.network"] = "workspace"
428
+ return _finalize_classify(True, sources, issues, effective=effective, provenance=provenance)
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Platform
433
+ # ---------------------------------------------------------------------------
434
+
435
+ def _user_config_path(policy: PathPolicy) -> Path:
436
+ return Path(os.path.join(policy.platform.config_dir, "config.json"))
437
+
438
+ def _resolve_platform(home=None, env=None, platform=None):
439
+ if home is None: home = os.path.expanduser("~")
440
+ if env is None: env = dict(os.environ)
441
+ if platform is None: import sys; platform = sys.platform
442
+ if platform == "win32":
443
+ appdata = env.get("APPDATA", os.path.join(home, "AppData", "Roaming"))
444
+ config_dir = os.path.join(appdata, "aisc")
445
+ elif platform == "darwin":
446
+ config_dir = os.path.join(home, "Library", "Application Support", "aisc")
447
+ else:
448
+ xdg = env.get("XDG_CONFIG_HOME", os.path.join(home, ".config"))
449
+ if not os.path.isabs(xdg):
450
+ raise ReadError("structural_error", "XDG_CONFIG_HOME must be absolute")
451
+ config_dir = os.path.join(xdg, "aisc")
452
+ return PlatformPathConfig(config_dir=config_dir, state_dir="")