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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|
aisc/domain/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain package — pure data models, no I/O."""
|
aisc/domain/artifacts.py
ADDED
|
@@ -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()
|