@tianacloud/agent-skills 1.0.10 → 1.0.12

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.
@@ -1,6 +1,6 @@
1
1
  # 使用模板工程
2
2
 
3
- 模板目录来自本次 `tiana web list-template --json` 实际返回的数据。每页 `data.items` 含 name、display_name、description、status 和变量摘要;CLI 的 `--after` 查询下一页。选择符合用户核心功能与数据结构的工程;没有匹配项时按通用 Web 方法制作。
3
+ 模板目录来自本次 `tiana web list-template --json` 实际返回的数据。每页 `data.items` 含 name、display_name、description、status 和变量摘要;CLI 的 `--after` 查询下一页。即使 items 为空,存在 next_cursor 时仍继续需要的页。选择符合用户核心功能与数据结构的工程;没有匹配项时按通用 Web 方法制作。
4
4
 
5
5
  新应用默认由 Agent 创建所需的 SQLite 和 Git,按当前应用命名并保存 CLI 返回的实际实例 ID;用户明确指定已有实例时核实后复用。保存资源回执后执行:
6
6
 
@@ -10,6 +10,8 @@ tiana web init-template NAME --dir PROJECT_DIR --json
10
10
 
11
11
  成功结果的 `data.directory` 是工程根目录,`data.metadata_path` 是元信息文件。CLI 负责下载校验和解压,工程仍保留占位符、SQL 和原始文件。下载失败先核对实际结果,再重新运行;不用链接地址当作创建成功依据。
12
12
 
13
+ metadata.json 包含 `repositories` 或 `functions` 时,按[后端服务模板的源码开发步骤](function-templates.md#下载源码后继续开发)保留资源关系;后端私有数据库不绑定给 Web。以下单仓库、Web 直连 SQLite 的步骤适用于相应模板。
14
+
13
15
  ## metadata.json 和变量
14
16
 
15
17
  读取根部 metadata.json、README、依赖锁文件及构建脚本,按实际工程开发。元信息示例:
@@ -19,6 +21,7 @@ tiana web init-template NAME --dir PROJECT_DIR --json
19
21
  "name": "notes",
20
22
  "display_name": "记事本",
21
23
  "description": "个人笔记、文件夹、全文搜索和自动保存。",
24
+ "database_required": true,
22
25
  "variables": [
23
26
  {
24
27
  "name": "SQLITE_INSTANCE_ID",
@@ -55,10 +58,18 @@ JSON 中要正确转义字符串,SQL 中按实际语法处理,源码中保
55
58
 
56
59
  SQL 返回失败、断线或未知结果时停止后续发布,用独立只读查询核对已完成的结构与数据,不自动重放写入。初始化成功后进行应用定制、依赖安装、源码同步、构建及发布;工程 README 中的步骤与实际帮助一起核对。
57
60
 
61
+ ## 预置素材
62
+
63
+ 按[数据与素材存储](storage-and-assets.md)检查声明及现有实现。`file_seeds` 为
64
+ `template-files` 时,按[模板素材文件](template-files.md)核对源字节、相对路径、
65
+ 当前 Web ID 的首次文件准备和干净构建。CLI 解压成功不会自动上传素材,普通
66
+ `web publish` 也不会灌入文件。没有素材声明时沿用原流程;已有应用不因再次发布
67
+ 而恢复被用户修改或删除的初始文件。
68
+
58
69
  ## 模板图标
59
70
 
60
71
  模板的源 `icon.png` 是用户应用的初始图标,复用工程的静态资源复制配置。变量替换时不把 PNG 当文本处理。新应用缺图、继承的是通用占位/标题首字图片或图形与本次功能不符时,按[应用图标](csr-apps.md#应用图标)优先调用 AI 生图制作并保存体现功能的图标,用户明确指定的图片优先。干净构建后确认产物根目录 `icon.png` 可以解码且与选定源图一致,再发布;以后更新模板本身不会直接改变已有用户应用的图标。
61
72
 
62
73
  ## 构建和来源
63
74
 
64
- 模板 `source.repository`、`source.commit` 说明下载包的源工程来源,区别于用户应用自己的 Git 提交。创建用户仓库后记录实际 git_instance_id;提交和构建按 [源码流程](../../tiana-git/references/source-releases.md)。按下载工程的 README 和构建脚本确定命令、入口与产物目录。若脚本接收源码提交号并输出 TIANA_PROJECT_PARAMS,核对其中的 entry、database_instance_id、git_instance_id 和 source_commit,再用于配置 MGR Web 项目;database_instance_id 必须与本次执行 schema/seeds 的 SQLite 一致。核对模块、资源及 MGR Web 项目的 SQLite/Git 绑定后执行 `web publish`,再用 publish_id 查询状态。
75
+ 模板 `source.repository`、`source.commit` 说明下载包的源工程来源,区别于用户应用自己的 Git 提交。创建用户仓库后记录实际 git_instance_id;提交和构建按 [源码流程](../../tiana-git/references/source-releases.md)。按下载工程的 README 和构建脚本确定命令、入口与产物目录。创建 Web 时传入实际 entry 和 Git 绑定;直连模式的数据库绑定必须与本次执行 schema/seeds 的 SQLite 一致。源码提交号记录为构建证据。核对模块、资源及 MGR Web 项目的 SQLite/Git 绑定后执行 `web publish`,再用 publish_id 查询状态。
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Package a reviewed source snapshot and its clean build as an installable template."""
3
3
  import argparse
4
+ import base64
4
5
  import hashlib
5
6
  import io
6
7
  import json
@@ -13,6 +14,17 @@ MIB = 1 << 20
13
14
  MAX_FILE = 16 * MIB
14
15
  MAX_EXPANDED = 128 * MIB
15
16
  MAX_ENTRIES = 5000
17
+ MAX_FILE_SEEDS = 256
18
+ FILE_TYPES = {
19
+ ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
21
+ ".avif": "image/avif", ".pdf": "application/pdf", ".txt": "text/plain",
22
+ ".json": "application/json", ".csv": "text/csv", ".md": "text/markdown",
23
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
24
+ ".mp4": "video/mp4", ".webm": "video/webm", ".woff": "font/woff",
25
+ ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
26
+ ".glb": "model/gltf-binary", ".gltf": "model/gltf+json",
27
+ }
16
28
 
17
29
 
18
30
  def archive(files):
@@ -58,17 +70,31 @@ def directory_count(data):
58
70
 
59
71
  def source_files(data):
60
72
  count = directory_count(data)
61
- files, total, seen = {}, 0, set()
73
+ files, directories, total, seen = {}, set(), 0, set()
62
74
  with zipfile.ZipFile(io.BytesIO(data)) as source:
63
75
  for item in source.infolist():
64
- name = item.filename.rstrip("/")
76
+ decoded = item.orig_filename
77
+ if not item.flag_bits & 0x800:
78
+ decoded = decoded.encode("cp437").decode("utf-8")
79
+ name = decoded.removesuffix("/")
65
80
  parts = name.split("/")
66
81
  mode = item.external_attr >> 16
67
82
  if (not name or "\\" in name or "\0" in name or ":" in name
68
83
  or any(part in ("", ".", "..") or part.lower() == ".git" for part in parts)
69
84
  or PurePosixPath(name).is_absolute() or name in seen
70
- or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)):
85
+ or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)
86
+ or item.is_dir() and stat.S_IFMT(mode) == stat.S_IFREG
87
+ or not item.is_dir() and stat.S_IFMT(mode) == stat.S_IFDIR
88
+ or name in directories and not item.is_dir()):
71
89
  raise ValueError(f"Unsafe source ZIP path or type: {name}")
90
+ for parent in PurePosixPath(name).parents:
91
+ if str(parent) == ".":
92
+ continue
93
+ if str(parent) in files:
94
+ raise ValueError(f"Source ZIP file/directory collision: {name}")
95
+ directories.add(str(parent))
96
+ if item.is_dir():
97
+ directories.add(name)
72
98
  seen.add(name)
73
99
  if item.file_size > MAX_FILE or total + item.file_size > MAX_EXPANDED:
74
100
  raise ValueError("Source ZIP exceeds expanded size budget")
@@ -77,17 +103,115 @@ def source_files(data):
77
103
  if len(value) > MAX_FILE or total + len(value) > MAX_EXPANDED:
78
104
  raise ValueError("Source ZIP exceeds actual expanded size budget")
79
105
  total += len(value)
106
+ if item.is_dir() and value:
107
+ raise ValueError(f"Source ZIP directory contains file bytes: {name}")
80
108
  if not item.is_dir():
81
109
  files[name] = value
82
110
  return files, total, count
83
111
 
84
112
 
113
+ def template_file_materials(snapshot, metadata):
114
+ names = [name for name in snapshot if name == "template-files" or name.startswith("template-files/")]
115
+ if "file_seeds" not in metadata:
116
+ if names:
117
+ raise ValueError("template-files requires a file_seeds declaration")
118
+ return None
119
+ if metadata["file_seeds"] != "template-files":
120
+ raise ValueError("file_seeds must be template-files")
121
+ if not 1 <= len(names) <= MAX_FILE_SEEDS:
122
+ raise ValueError("template-files requires 1..256 files")
123
+ entries, materials, total = [], {}, 0
124
+ for name in sorted(names, key=lambda value: value.encode("utf-8")):
125
+ relative = name[len("template-files/"):]
126
+ if (not relative or len(relative.encode("utf-8")) > 1024
127
+ or any(part in ("", ".", "..") for part in relative.split("/"))
128
+ or any(character in "\\%" or ord(character) < 32 or 127 <= ord(character) <= 159 for character in relative)):
129
+ raise ValueError(f"Invalid template file path: {relative}")
130
+ value = snapshot[name]
131
+ total += len(value)
132
+ if len(value) > MAX_FILE or total > MAX_FILE:
133
+ raise ValueError("template-files exceeds 16 MiB")
134
+ entries.append({"path": relative, "size": len(value),
135
+ "sha256": hashlib.sha256(value).hexdigest(),
136
+ "content_type": FILE_TYPES.get(PurePosixPath(relative).suffix.lower(), "application/octet-stream")})
137
+ materials["files/" + relative] = value
138
+ return {"manifest": {"version": 1, "root": "template-assets/", "entries": entries},
139
+ "materials": materials, "bytes": total}
140
+
141
+
142
+ def check_seed_build_output(name, data, seeds):
143
+ if seeds is None:
144
+ return
145
+ if any(part in ("template-files", "template-assets") for part in PurePosixPath(name).parts):
146
+ raise ValueError(f"External template files must not enter the Web build: {name}")
147
+ # The platform requires this exact root icon independently of file seeds.
148
+ if name == "icon.png":
149
+ return
150
+ for seed, content in seeds["materials"].items():
151
+ if content and (data == content or len(content) >= 16 and (content in data or base64.b64encode(content) in data)):
152
+ raise ValueError(f"External template file embedded in Web build: {seed} in {name}")
153
+
154
+
155
+ def encode_install_manifest(manifest, seeds):
156
+ if seeds is None:
157
+ return (json.dumps(manifest, ensure_ascii=False, allow_nan=False) + "\n").encode(), None
158
+ raw_files = json.dumps(seeds["manifest"], ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()
159
+ base = json.dumps({**manifest, "format_version": 2, "requires": ["template_files_v1"]}, ensure_ascii=False, allow_nan=False).encode()
160
+ return base[:-1] + b',"files":' + raw_files + b"}\n", hashlib.sha256(raw_files).hexdigest()
161
+
162
+
163
+ def unique_json_object(pairs):
164
+ result = {}
165
+ for key, value in pairs:
166
+ if key in result:
167
+ raise ValueError(f"Duplicate metadata JSON key: {key}")
168
+ result[key] = value
169
+ return result
170
+
171
+
172
+ def validate_backend_materials(metadata, snapshot, site):
173
+ repositories = {r["key"]: r for r in metadata.get("repositories", [])}
174
+ for repo in repositories.values():
175
+ prefix = "" if repo["path"] == "." else repo["path"] + "/"
176
+ if not any(name.startswith(prefix) for name in snapshot):
177
+ raise ValueError(f"Repository source missing: {repo['key']}")
178
+ for variable in repo.get("variables", []):
179
+ for name in variable["files"]:
180
+ if variable["placeholder"].encode() not in snapshot.get(prefix + name, b""):
181
+ raise ValueError(f"Source placeholder missing: {repo['key']}/{name}")
182
+ for fn in metadata.get("functions", []):
183
+ repo = repositories.get(fn["repository"])
184
+ if repo is None:
185
+ raise ValueError(f"Function repository missing: {fn['key']}")
186
+ prefix = "" if repo["path"] == "." else repo["path"] + "/"
187
+ directory = fn.get("source_dir", "").rstrip("/")
188
+ if fn.get("source_dir") != "/" and (not directory.startswith("/") or any(part in ("", ".", "..") for part in directory[1:].split("/")) or "\\" in directory or "\0" in directory):
189
+ raise ValueError("Function source_dir must be a repository path starting with /")
190
+ prefix += directory[1:] + "/" if directory else ""
191
+ if not any(name.startswith(prefix) and "/" not in name[len(prefix):] and name.endswith(".go") for name in snapshot):
192
+ raise ValueError(f"Function entry Go source missing: {fn['key']}")
193
+ for variable in metadata.get("site_variables", []):
194
+ for name in variable["files"]:
195
+ if variable["placeholder"].encode() not in site.get("data/" + name, b""):
196
+ raise ValueError(f"Built site placeholder missing: {name}")
197
+
198
+
85
199
  def pack(source_zip, dist, entry, output, without_source=False):
86
200
  source = read_file(Path(source_zip))
87
201
  snapshot, source_size, source_count = source_files(source)
88
- metadata = json.loads(snapshot["metadata.json"])
202
+ raw_metadata = snapshot["metadata.json"]
203
+ if len(raw_metadata) > MIB:
204
+ raise ValueError("metadata.json exceeds 1 MiB")
205
+ metadata = json.loads(raw_metadata.decode("utf-8"), object_pairs_hook=unique_json_object)
206
+ if type(metadata) is not dict:
207
+ raise ValueError("metadata.json must be an object")
89
208
  if type(metadata.get("database_required")) is not bool:
90
209
  raise ValueError("metadata.json requires an explicit database_required boolean")
210
+ if without_source and metadata.get("functions"):
211
+ raise ValueError("Function templates require source.zip to compile their backends")
212
+ seeds = template_file_materials(snapshot, metadata)
213
+ if seeds is not None and without_source:
214
+ raise ValueError("File templates require source.zip for server byte verification; --without-source is only available for templates without file_seeds")
91
215
  names = ["showcase/preview.html", "showcase/cover.webp"]
92
216
  if not without_source:
93
217
  names.append("showcase/prompt.md")
@@ -104,18 +228,27 @@ def pack(source_zip, dist, entry, output, without_source=False):
104
228
  sql_names[name] = target
105
229
  site = {"web.yaml": b""}
106
230
  dist = Path(dist)
231
+ if dist.is_symlink() or not dist.is_dir():
232
+ raise ValueError("Build directory must be a real directory")
107
233
  site_size = 0
108
234
  for file in sorted(dist.rglob("*")):
109
235
  if file.is_symlink():
110
236
  raise ValueError(f"Build contains a symlink: {file.relative_to(dist)}")
237
+ relative = file.relative_to(dist).as_posix()
238
+ if seeds is not None and any(part in ("template-files", "template-assets") for part in file.relative_to(dist).parts):
239
+ raise ValueError(f"External template files must not enter the Web build: {relative}")
111
240
  if file.is_file():
112
241
  data = read_file(file)
242
+ check_seed_build_output(relative, data, seeds)
113
243
  site_size += len(data)
114
244
  if site_size > MAX_FILE or len(site) >= 4097:
115
245
  raise ValueError("Build exceeds the Web archive resource budget")
116
246
  site["data/" + file.relative_to(dist).as_posix()] = data
247
+ elif not file.is_dir():
248
+ raise ValueError(f"Build contains a nonregular entry: {relative}")
117
249
  if not entry or "data/" + entry not in site:
118
250
  raise ValueError("Build is missing its fixed entry")
251
+ validate_backend_materials(metadata, snapshot, site)
119
252
  compiled = archive(site)
120
253
  if len(compiled) > MAX_FILE:
121
254
  raise ValueError("site.tweb exceeds 16 MiB")
@@ -132,7 +265,14 @@ def pack(source_zip, dist, entry, output, without_source=False):
132
265
  files["icon.png"] = site["data/icon.png"]
133
266
  files["site.tweb"] = compiled
134
267
  manifest = {"metadata": metadata, "entry": entry, "database_required": metadata["database_required"], "variables": variables}
135
- files["install.json"] = (json.dumps(manifest, ensure_ascii=False) + "\n").encode()
268
+ for field in ("private_database", "web_repository", "repositories", "functions", "site_variables"):
269
+ if field in metadata:
270
+ manifest[field] = metadata[field]
271
+ files["install.json"], file_manifest_digest = encode_install_manifest(manifest, seeds)
272
+ if len(files["install.json"]) > MIB:
273
+ raise ValueError("install.json exceeds 1 MiB")
274
+ if seeds is not None:
275
+ files.update(seeds["materials"])
136
276
  expanded = sum(map(len, files.values())) + site_size + (0 if without_source else source_size)
137
277
  entries = len(files) + len(site) + (0 if without_source else source_count)
138
278
  if expanded > MAX_EXPANDED or entries > MAX_ENTRIES or any(len(value) > MAX_FILE for value in files.values()):
@@ -141,7 +281,10 @@ def pack(source_zip, dist, entry, output, without_source=False):
141
281
  if len(data) > 32 * MIB:
142
282
  raise ValueError("install.zip exceeds 32 MiB")
143
283
  Path(output).write_bytes(data)
144
- return {"name": metadata["name"], "file": str(Path(output).resolve()), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "has_source": not without_source, "database_required": metadata["database_required"], "expanded_bytes": expanded, "entries": entries}
284
+ result = {"name": metadata["name"], "file": str(Path(output).resolve()), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "has_source": not without_source, "database_required": metadata["database_required"], "expanded_bytes": expanded, "entries": entries}
285
+ if seeds is not None:
286
+ result.update({"format_version": 2, "file_count": len(seeds["manifest"]["entries"]), "file_bytes": seeds["bytes"], "file_manifest_sha256": file_manifest_digest})
287
+ return result
145
288
 
146
289
 
147
290
  if __name__ == "__main__":
@@ -150,6 +293,6 @@ if __name__ == "__main__":
150
293
  parser.add_argument("--dist", required=True, help="Clean build output directory")
151
294
  parser.add_argument("--entry", required=True, help="Fixed entry relative to dist")
152
295
  parser.add_argument("--output", required=True, help="Destination install.zip")
153
- parser.add_argument("--without-source", action="store_true", help="Publish installation materials without source or customization prompt")
296
+ parser.add_argument("--without-source", action="store_true", help="No-file templates only: omit source and customization prompt")
154
297
  args = parser.parse_args()
155
298
  print(json.dumps(pack(args.source, args.dist, args.entry, args.output, args.without_source), ensure_ascii=False))