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
aisc/cli/output.py ADDED
@@ -0,0 +1,239 @@
1
+ """CLI output formatting — JSON envelope, human-readable text, and JSONL emitter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # JSON envelope (RFC §2)
14
+ # ---------------------------------------------------------------------------
15
+
16
+ PROTOCOL = "aisc.cli/v1"
17
+
18
+
19
+ def _utc_now() -> str:
20
+ """Return an ISO 8601 UTC timestamp string."""
21
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
22
+
23
+
24
+ def build_envelope(
25
+ command: str,
26
+ exit_code: int,
27
+ version: str,
28
+ data: Any = None,
29
+ errors: Optional[List[Dict[str, Any]]] = None,
30
+ *,
31
+ run_id: Optional[str] = None,
32
+ timestamp: Optional[str] = None,
33
+ ) -> Dict[str, Any]:
34
+ """Build a complete JSON envelope per RFC §2.
35
+
36
+ Parameters
37
+ ----------
38
+ command:
39
+ Subcommand name (e.g. ``"version"``, ``"doctor"``).
40
+ exit_code:
41
+ Process exit code — must match ``sys.exit()``.
42
+ version:
43
+ CLI product version.
44
+ data:
45
+ Command-specific payload. ``None`` is treated as ``null`` in JSON.
46
+ errors:
47
+ List of error objects. ``None`` is treated as ``[]``.
48
+ run_id:
49
+ UUID v4. Auto-generated when not provided. Falls back to the
50
+ ``AISC_RUN_ID`` env var (lifecycle-logging P1: the Workbench injects
51
+ its operation id so app-side op events, the envelope and the log
52
+ timeline share one id).
53
+ timestamp:
54
+ ISO 8601 UTC string. Auto-generated when not provided.
55
+ """
56
+ import os
57
+
58
+ effective_run_id = run_id or os.environ.get("AISC_RUN_ID") or str(uuid.uuid4())
59
+ return {
60
+ "meta": {
61
+ "protocol": PROTOCOL,
62
+ "command": command,
63
+ "exit_code": exit_code,
64
+ "timestamp": timestamp or _utc_now(),
65
+ "version": version,
66
+ "run_id": effective_run_id,
67
+ },
68
+ "data": data,
69
+ "errors": errors if errors is not None else [],
70
+ }
71
+
72
+
73
+ def build_error(code: str, message: str, hint: Optional[str] = None) -> Dict[str, Any]:
74
+ """Build a single error object (RFC §2.3)."""
75
+ return {"code": code, "message": message, "hint": hint}
76
+
77
+
78
+ def emit_json(envelope: Dict[str, Any]) -> None:
79
+ """Write *envelope* as JSON to stdout.
80
+
81
+ ``ensure_ascii=True`` keeps the stream pure ASCII: on locale-encoded
82
+ stdout (GBK on zh-CN Windows, cp1252 on en-US) any non-ASCII payload
83
+ would raise UnicodeEncodeError and kill the CLI mid-protocol (observed:
84
+ buildkit emoji in build.output). JSON escapes are semantically identical
85
+ to the consumer (serde_json).
86
+ """
87
+ print(json.dumps(envelope, ensure_ascii=True), flush=True)
88
+
89
+
90
+ def emit_json_usage_error(
91
+ command: str,
92
+ version: str,
93
+ error_code: str = "AISC_ERR_USAGE",
94
+ message: str = "Invalid command-line arguments",
95
+ ) -> None:
96
+ """Emit a JSON usage error envelope to stdout and ``sys.exit(2)``."""
97
+ env = build_envelope(
98
+ command=command,
99
+ exit_code=2,
100
+ version=version,
101
+ data=None,
102
+ errors=[build_error(error_code, message)],
103
+ )
104
+ emit_json(env)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Human-readable text helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+ _ANSI_COLORS = {
112
+ "red": "\033[31m",
113
+ "green": "\033[32m",
114
+ "yellow": "\033[33m",
115
+ "reset": "\033[0m",
116
+ "bold": "\033[1m",
117
+ }
118
+
119
+
120
+ def _maybe_style(text: str, style: str, use_color: bool) -> str:
121
+ if not use_color:
122
+ return text
123
+ code = _ANSI_COLORS.get(style, "")
124
+ reset = _ANSI_COLORS["reset"] if code else ""
125
+ return f"{code}{text}{reset}"
126
+
127
+
128
+ def print_doctor_text(report: Any, use_color: bool = True) -> None:
129
+ """Print a doctor report in human-readable format."""
130
+ from aisc.domain.models import CheckStatus
131
+
132
+ status_labels = {
133
+ CheckStatus.PASS: _maybe_style("PASS", "green", use_color),
134
+ CheckStatus.WARN: _maybe_style("WARN", "yellow", use_color),
135
+ CheckStatus.FAIL: _maybe_style("FAIL", "red", use_color),
136
+ CheckStatus.SKIP: "SKIP",
137
+ }
138
+
139
+ lines: List[str] = []
140
+ lines.append(_maybe_style("=== AISC Doctor (host) ===", "bold", use_color))
141
+ lines.append("")
142
+
143
+ for check in report.checks:
144
+ label = status_labels.get(check.status, check.status.upper())
145
+ lines.append(f" [{label}] {check.name}")
146
+ if check.message:
147
+ lines.append(f" {check.message}")
148
+ if check.detail:
149
+ lines.append(f" {check.detail}")
150
+ if check.hint:
151
+ lines.append(f" Hint: {check.hint}")
152
+ lines.append("")
153
+
154
+ s = report.summary
155
+ lines.append("--- Summary ---")
156
+ lines.append(
157
+ f" Passed: {s['passed']} Warnings: {s['warnings']} "
158
+ f"Failures: {s['failures']} Skipped: {s['skipped']}"
159
+ )
160
+ if report.error_message:
161
+ lines.append(f" Error: {report.error_message}")
162
+
163
+ for line in lines:
164
+ print(line)
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # JSONL Event Emitter (RFC §3)
169
+ # ---------------------------------------------------------------------------
170
+
171
+ class JsonlEmitter:
172
+ """Emit JSONL event lines to stdout during long commands (build/run).
173
+
174
+ Usage::
175
+
176
+ emitter = JsonlEmitter(command="build")
177
+ emitter.emit("build.start", data={"image_tag": "..."})
178
+ # ... docker build ...
179
+ emitter.emit("build.complete", data={"exit_code": 0}, terminal=True)
180
+
181
+ Each emit() writes one JSON line to stdout, increments seq by 1,
182
+ and enforces that ``terminal=True`` can only be called once as the
183
+ final event.
184
+ """
185
+
186
+ def __init__(self, command: str, *, run_id: Optional[str] = None):
187
+ self._command = command
188
+ self._run_id = run_id or str(uuid.uuid4())
189
+ self._seq = 0
190
+ self._terminated = False
191
+
192
+ @property
193
+ def run_id(self) -> str:
194
+ return self._run_id
195
+
196
+ @property
197
+ def seq(self) -> int:
198
+ return self._seq
199
+
200
+ @property
201
+ def terminated(self) -> bool:
202
+ """``True`` if a terminal event has already been emitted."""
203
+ return self._terminated
204
+
205
+ def emit(self, event_type: str, data: Dict[str, Any],
206
+ *, terminal: bool = False) -> None:
207
+ """Emit a single JSONL event line.
208
+
209
+ Args:
210
+ event_type: RFC event type, e.g. ``"build.start"``.
211
+ data: Event-specific payload dict.
212
+ terminal: ``True`` for the final terminating event only.
213
+ """
214
+ if self._terminated:
215
+ raise RuntimeError(
216
+ f"JSONL stream already terminated (last event was seq={self._seq})"
217
+ )
218
+ self._seq += 1
219
+ event = {
220
+ "protocol": PROTOCOL,
221
+ "command": self._command,
222
+ "run_id": self._run_id,
223
+ "seq": self._seq,
224
+ "type": event_type,
225
+ "ts": _utc_now(),
226
+ "data": data,
227
+ }
228
+ if terminal:
229
+ self._terminated = True
230
+ # ensure_ascii=True: see emit_json - locale stdout (GBK) cannot
231
+ # encode buildkit's non-ASCII output and would crash mid-stream.
232
+ print(json.dumps(event, ensure_ascii=True), flush=True)
233
+
234
+ def emit_terminal(self, terminal_type: str, exit_code: int,
235
+ extra_data: Optional[Dict[str, Any]] = None) -> None:
236
+ """Emit the terminal event with ``data.exit_code``."""
237
+ data: Dict[str, Any] = dict(extra_data or {})
238
+ data["exit_code"] = exit_code
239
+ self.emit(terminal_type, data, terminal=True)
aisc/cli/tutorial.py ADDED
@@ -0,0 +1,109 @@
1
+ """v2.1.7 S6: interactive-bash tutorial (Gate-S6 / D10 decision).
2
+
3
+ The Workbench's bash sessions open as ``docker exec -it <name> bash``. To
4
+ make a bare ``help`` answer INSIDE that shell (without touching the image,
5
+ the user's workspace, or any persistent profile — A-21766), the session
6
+ argv is rewritten to::
7
+
8
+ bash -c '<define help() + export -f it>; exec bash'
9
+
10
+ ``export -f`` serializes the function into the environment; the exec'd
11
+ interactive bash re-imports it at startup. ``help`` with NO arguments prints
12
+ the tutorial; ``help foo`` delegates to the shell builtin so nothing else
13
+ changes (A-21765). Non-interactive shells never see any of this — the
14
+ injection rides ONLY the interactive session-open path.
15
+
16
+ 2.1.9: the interactive shell is now **zsh** (2.1.8 T2/D-4), and zsh has no
17
+ env-var function import — ``help`` is defined directly in the managed
18
+ ``container/aisc-zshrc`` (§7). The text SSOT stays HERE; the zshrc copy is
19
+ pinned byte-for-byte by ``tests/test_tutorial_zsh.py``. The bash env path
20
+ above still serves the bash fallback (older images without the zsh suite).
21
+
22
+ The tutorial text lives here (content SSOT); the Workbench UI cards carry
23
+ their own short i18n summaries.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Dict
29
+
30
+ _TUTORIAL = """\
31
+ ╔══════════════ AISC Workbench 教学 ══════════════
32
+ ║ 三、五分钟核心用法
33
+ ║
34
+ ║ [Claude Code]
35
+ ║ claude 开始交互对话
36
+ ║ claude -c 继续最近一次会话
37
+ ║ claude -p "问题" 单次提问,直接返回答案
38
+ ║ claude --resume 挑选历史会话恢复
39
+ ║ 会话内: /help 命令列表 · /clear 清空 · /compact 压缩上下文
40
+ ║
41
+ ║ [Codex]
42
+ ║ codex 开始交互对话
43
+ ║ codex e "任务" 单次执行任务
44
+ ║ 会话内: /help · /new 新会话 · Ctrl+C 中断
45
+ ║
46
+ ║ [Workbench]
47
+ ║ 左侧栏 文件/变更/服务 三个页签(顶部可搜索)
48
+ ║ 右上 ⓘ 运行时状态抽屉 · Ctrl+, 设置 · + 号新建页
49
+ ║ 关闭工作区 = 自动清理临时容器(数据不丢)
50
+ ║
51
+ ║ 上手练习(复制即用):
52
+ ║ 1. claude --version 查看已安装的 Claude Code 版本
53
+ ║ 2. codex --version 查看 Codex 版本
54
+ ║ 3. 在 + ▾ 菜单打开 claude 页签,输入 /help 浏览命令
55
+ ║ 4. 左侧切到「变更」页签,点分组标题收拢/展开
56
+ ║ 配置好 Provider 后可试: claude -p "用一句话介绍自己"(调用 API,产生用量)
57
+ ║
58
+ ║ 再次查看本教学: help
59
+ ╚═══════════════════════════════════════════════"""
60
+
61
+ # Shell fragment executed via `bash -c`. NOTE: kept POSIX-simple on purpose —
62
+ # it runs before any profile, on the image's stock bash.
63
+ _SESSION_BASH_PRELUDE_TEMPLATE = """\
64
+ __aisc_tutorial() {
65
+ cat <<'__AISC_TUTORIAL_EOF__'
66
+ __AISC_TUTORIAL_TEXT__
67
+ __AISC_TUTORIAL_EOF__
68
+ }
69
+ help() {
70
+ if [ $# -eq 0 ]; then
71
+ __aisc_tutorial
72
+ else
73
+ builtin help "$@"
74
+ fi
75
+ }
76
+ export -f help __aisc_tutorial
77
+ exec bash
78
+ """
79
+
80
+
81
+ def session_bash_prelude() -> str:
82
+ """The ``bash -c`` script for interactive session opens (test seam)."""
83
+ # Plain replace — .format would need every shell brace escaped.
84
+ return _SESSION_BASH_PRELUDE_TEMPLATE.replace("__AISC_TUTORIAL_TEXT__", _TUTORIAL)
85
+
86
+
87
+ _FUNC_BODY = """\
88
+ if [ $# -eq 0 ]; then
89
+ cat <<'__AISC_TUTORIAL_EOF__'
90
+ {tutorial}
91
+ __AISC_TUTORIAL_EOF__
92
+ else
93
+ builtin help "$@"
94
+ fi
95
+ """
96
+
97
+
98
+ def help_function_env() -> Dict[str, str]:
99
+ """The exported-function env var for bash session opens.
100
+
101
+ bash re-imports ``BASH_FUNC_<name>%%`` env vars as functions at startup;
102
+ the wrapper's ``os.environ.copy()`` passes the docker-exec environment
103
+ straight through, so the function enters ONLY this session's shell (no
104
+ image, profile, or workspace writes — A-21766). ``help <args>`` delegates
105
+ to the builtin (A-21765).
106
+ """
107
+ body = _FUNC_BODY.replace("{tutorial}", _TUTORIAL)
108
+ return {"BASH_FUNC_help%%": f"() {{\n{body}}}"}
109
+
@@ -0,0 +1 @@
1
+ """Domain package — pure data models, no I/O."""
@@ -0,0 +1,279 @@
1
+ """Agent Artifact domain model and path policy (Stage 3, ART-01).
2
+
3
+ The authoritative fact layer for Agent deliverables: ``aisc.artifact/v1``.
4
+ Deliberately SEPARATE from ``packaging/artifact.py`` (release bundle builder) —
5
+ different schema, different namespace, different module. See
6
+ docs/archive/completed/aisc-next/stage-3-workspace-artifacts/.
7
+
8
+ Invariants (D3-01..D3-08):
9
+ - Skill provides semantics (title/category/open suggestion), not facts.
10
+ - Only ``aisc artifact record`` writes an authoritative record (provenance
11
+ ``manifest``). The watcher is a read model, never a fact writer.
12
+ - Agents submit workspace-relative paths; host absolute resolution is the
13
+ Rust side's job after canonical containment.
14
+ - ``extra`` is free-form but must not carry secrets.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import hashlib
21
+ import os
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path, PurePosixPath
25
+ from typing import Any, Dict, Optional
26
+
27
+ ARTIFACT_PROTOCOL = "aisc.artifact/v1"
28
+ ARTIFACT_SCHEMA_VERSION = 1
29
+
30
+ # UUID v4-ish (structural check; the CLI generates real UUIDs).
31
+ _ARTIFACT_ID_RE = re.compile(
32
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
33
+ )
34
+
35
+ # Control characters / separators never allowed in a relative path.
36
+ _FORBIDDEN = re.compile(r"[\x00-\x1f\x7f]")
37
+ _WINDOWS_RESERVED = re.compile(
38
+ r"(?i)^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)"
39
+ )
40
+
41
+
42
+ class ArtifactAction:
43
+ CREATED = "created"
44
+ MODIFIED = "modified"
45
+ DELETED = "deleted"
46
+ RENAMED = "renamed"
47
+ ALL = (CREATED, MODIFIED, DELETED, RENAMED)
48
+
49
+
50
+ class ArtifactKind:
51
+ DELIVERABLE = "deliverable"
52
+ SOURCE_CHANGE = "source_change"
53
+ GENERATED_OUTPUT = "generated_output"
54
+ ALL = (DELIVERABLE, SOURCE_CHANGE, GENERATED_OUTPUT)
55
+
56
+
57
+ class ArtifactState:
58
+ PRESENT = "present"
59
+ DELETED = "deleted"
60
+ MOVED = "moved"
61
+ MISSING = "missing"
62
+ ALL = (PRESENT, DELETED, MOVED, MISSING)
63
+
64
+
65
+ class ArtifactOpenWith:
66
+ PREVIEW = "preview"
67
+ SYSTEM = "system"
68
+ REVEAL = "reveal"
69
+ NONE = "none"
70
+ ALL = (PREVIEW, SYSTEM, REVEAL, NONE)
71
+
72
+
73
+ class ArtifactProvenance:
74
+ MANIFEST = "manifest" # authoritative: written by `aisc artifact record`
75
+ WORKSPACE_CHANGE = "workspace_change" # read model: watcher only
76
+
77
+
78
+ def validate_relative_path(raw: str) -> str:
79
+ """Validate a workspace-relative path and return it normalized (POSIX).
80
+
81
+ Rejects (A-ART05-* / R3-01):
82
+ - empty or whitespace-only paths;
83
+ - absolute paths (leading ``/``), Windows drive/UNC/root prefixes;
84
+ - NUL and other control characters;
85
+ - ``..`` traversal (any position);
86
+ - backslash separators (``\\`` on Windows; cross-platform we reject them so
87
+ a path written on one OS cannot silently mean something else on another);
88
+ - Windows reserved device names (CON, NUL, …).
89
+
90
+ Returns the normalized forward-slash path. Raises ValueError on violation.
91
+ """
92
+ if not raw or not raw.strip():
93
+ raise ValueError("artifact path must not be empty")
94
+
95
+ if _FORBIDDEN.search(raw):
96
+ raise ValueError("artifact path contains control characters")
97
+
98
+ # Backslashes: on POSIX they are legal filename bytes but ambiguous here;
99
+ # reject to keep identity consistent across OSes (Windows is the strict one).
100
+ if "\\" in raw:
101
+ raise ValueError("artifact path must use '/' separators, not '\\'")
102
+
103
+ if raw.startswith("/"):
104
+ raise ValueError("artifact path must be relative (no leading '/')")
105
+
106
+ # Windows drive / UNC / rooted prefixes (also caught by PurePosixPath but
107
+ # be explicit for clear error messages).
108
+ if re.match(r"^[A-Za-z]:", raw):
109
+ raise ValueError("artifact path must be relative (no drive prefix)")
110
+ if raw.startswith("//") or raw.startswith("\\\\"):
111
+ raise ValueError("artifact path must be relative (no UNC prefix)")
112
+
113
+ # Normalize via PurePosixPath (does NOT touch the filesystem; rejects
114
+ # '..' traversal and collapses '.', keeps 'a//b' -> 'a/b').
115
+ try:
116
+ pp = PurePosixPath(raw)
117
+ except ValueError as exc:
118
+ raise ValueError(f"invalid artifact path: {exc}") from exc
119
+
120
+ if not pp.parts or pp.parts[0] in ("", "/"):
121
+ raise ValueError("artifact path must be relative")
122
+
123
+ if ".." in pp.parts:
124
+ raise ValueError("artifact path must not contain '..'")
125
+
126
+ if "." == raw:
127
+ raise ValueError("artifact path must point to a file")
128
+
129
+ first = pp.parts[0]
130
+ if _WINDOWS_RESERVED.match(first):
131
+ raise ValueError(f"artifact path uses reserved device name: {first}")
132
+
133
+ return str(pp)
134
+
135
+
136
+ def validate_artifact_id(artifact_id: str) -> str:
137
+ """Validate a UUID-v4-shaped artifact id (structural, not version-4 bits)."""
138
+ if not _ARTIFACT_ID_RE.match(artifact_id):
139
+ raise ValueError("artifact_id must be a UUID")
140
+ return artifact_id.lower()
141
+
142
+
143
+ def workspace_hash(workspace: Path) -> str:
144
+ """Irreversible short hash of a canonical workspace path (for registry
145
+ keying; never records the raw path)."""
146
+ canon = str(workspace.resolve())
147
+ return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
148
+
149
+
150
+ def normalize_media_type(raw: Optional[str]) -> Optional[str]:
151
+ """Validate/normalize a media type, or None when absent."""
152
+ if raw is None:
153
+ return None
154
+ mt = raw.strip().lower()
155
+ if not mt or "/" not in mt or len(mt) > 127:
156
+ raise ValueError("media_type must be 'type/subtype' (<=127 chars)")
157
+ return mt
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class ArtifactRecord:
162
+ """One authoritative artifact fact (aisc.artifact/v1)."""
163
+
164
+ schema_version: int = ARTIFACT_SCHEMA_VERSION
165
+ artifact_id: str = ""
166
+ workspace_relative_path: str = ""
167
+ action: str = ArtifactAction.CREATED
168
+ kind: str = ArtifactKind.DELIVERABLE
169
+ media_type: Optional[str] = None
170
+ label: str = ""
171
+ open_with: str = ArtifactOpenWith.PREVIEW
172
+ producer: Dict[str, Any] = field(default_factory=dict)
173
+ state: str = ArtifactState.PRESENT
174
+ provenance: str = ArtifactProvenance.MANIFEST
175
+ recorded_at: str = ""
176
+ previous_path: Optional[str] = None
177
+ extra: Dict[str, Any] = field(default_factory=dict)
178
+
179
+ # -- construction / validation ---------------------------------------
180
+
181
+ def validate(self) -> "ArtifactRecord":
182
+ """Validate all fields; returns self (or raises ValueError)."""
183
+ validate_artifact_id(self.artifact_id)
184
+ validate_relative_path(self.workspace_relative_path)
185
+ if self.action not in ArtifactAction.ALL:
186
+ raise ValueError(f"invalid action: {self.action!r}")
187
+ if self.action == ArtifactAction.RENAMED:
188
+ if not self.previous_path:
189
+ raise ValueError("renamed action requires previous_path")
190
+ validate_relative_path(self.previous_path)
191
+ if self.kind not in ArtifactKind.ALL:
192
+ raise ValueError(f"invalid kind: {self.kind!r}")
193
+ if self.state not in ArtifactState.ALL:
194
+ raise ValueError(f"invalid state: {self.state!r}")
195
+ if self.open_with not in ArtifactOpenWith.ALL:
196
+ raise ValueError(f"invalid open_with: {self.open_with!r}")
197
+ if self.provenance != ArtifactProvenance.MANIFEST:
198
+ # The CLI only ever writes authoritative manifest facts. A watcher
199
+ # read model is a separate projection, never stored as manifest.
200
+ raise ValueError(
201
+ f"provenance must be {ArtifactProvenance.MANIFEST!r}"
202
+ )
203
+ if self.schema_version != ARTIFACT_SCHEMA_VERSION:
204
+ raise ValueError(
205
+ f"unsupported schema_version: {self.schema_version}"
206
+ )
207
+ # producer: agent/session_id/runtime_id are required; never secrets.
208
+ agent = self.producer.get("agent")
209
+ if not agent or agent not in ("claude", "codex", "bash", "cc-switch"):
210
+ raise ValueError(f"producer.agent must be one of claude/codex/bash/cc-switch")
211
+ if not self.producer.get("session_id"):
212
+ raise ValueError("producer.session_id is required")
213
+ if not self.producer.get("runtime_id"):
214
+ raise ValueError("producer.runtime_id is required")
215
+ if self.label and len(self.label) > 256:
216
+ raise ValueError("label must be <= 256 chars")
217
+ if self.media_type:
218
+ normalize_media_type(self.media_type)
219
+ return self
220
+
221
+ def to_dict(self) -> Dict[str, Any]:
222
+ d = {
223
+ "schema_version": self.schema_version,
224
+ "artifact_id": self.artifact_id,
225
+ "workspace_relative_path": self.workspace_relative_path,
226
+ "action": self.action,
227
+ "kind": self.kind,
228
+ "state": self.state,
229
+ "provenance": self.provenance,
230
+ "recorded_at": self.recorded_at,
231
+ "extra": dict(self.extra),
232
+ }
233
+ if self.media_type is not None:
234
+ d["media_type"] = self.media_type
235
+ if self.label:
236
+ d["label"] = self.label
237
+ if self.open_with != ArtifactOpenWith.PREVIEW:
238
+ d["open_with"] = self.open_with
239
+ if self.previous_path is not None:
240
+ d["previous_path"] = self.previous_path
241
+ d["producer"] = dict(self.producer)
242
+ return d
243
+
244
+ @classmethod
245
+ def from_dict(cls, raw: Dict[str, Any]) -> "ArtifactRecord":
246
+ """Build + validate from a dict; unknown fields are preserved in
247
+ ``extra`` (round-trip safe, A-ART01-1)."""
248
+ if not isinstance(raw, dict):
249
+ raise ValueError("artifact record must be a JSON object")
250
+ schema_version = raw.get("schema_version", ARTIFACT_SCHEMA_VERSION)
251
+ if schema_version != ARTIFACT_SCHEMA_VERSION:
252
+ raise ValueError(f"unsupported schema_version: {schema_version}")
253
+ known = {
254
+ "schema_version", "artifact_id", "workspace_relative_path",
255
+ "action", "kind", "media_type", "label", "open_with",
256
+ "producer", "state", "provenance", "recorded_at",
257
+ "previous_path", "extra",
258
+ }
259
+ extra = dict(raw.get("extra") or {})
260
+ for k, v in raw.items():
261
+ if k not in known:
262
+ extra[k] = v # preserve unknown fields on round-trip
263
+ rec = cls(
264
+ schema_version=schema_version,
265
+ artifact_id=raw.get("artifact_id", ""),
266
+ workspace_relative_path=raw.get("workspace_relative_path", ""),
267
+ action=raw.get("action", ArtifactAction.CREATED),
268
+ kind=raw.get("kind", ArtifactKind.DELIVERABLE),
269
+ media_type=raw.get("media_type"),
270
+ label=raw.get("label", ""),
271
+ open_with=raw.get("open_with", ArtifactOpenWith.PREVIEW),
272
+ producer=dict(raw.get("producer") or {}),
273
+ state=raw.get("state", ArtifactState.PRESENT),
274
+ provenance=raw.get("provenance", ArtifactProvenance.MANIFEST),
275
+ recorded_at=raw.get("recorded_at", ""),
276
+ previous_path=raw.get("previous_path"),
277
+ extra=extra,
278
+ )
279
+ return rec.validate()