@playcraft/cli 0.1.70 → 0.1.72

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 (43) hide show
  1. package/README.md +20 -1
  2. package/dist/cli-root-help.js +1 -0
  3. package/dist/commands/references.js +3 -0
  4. package/dist/commands/skills.js +68 -4
  5. package/dist/commands/tools-batch.js +2 -0
  6. package/dist/project-skills/discovery.js +248 -0
  7. package/dist/project-skills/messages.js +80 -0
  8. package/dist/project-skills/remote-activation.js +500 -0
  9. package/dist/project-skills/remote-client.js +117 -0
  10. package/dist/project-skills/remote-commands.js +87 -0
  11. package/dist/project-skills/remote-prepare.js +223 -0
  12. package/dist/project-skills/search-command.js +47 -0
  13. package/dist/project-skills/search.js +96 -0
  14. package/dist/recording/project-digest.js +1 -1
  15. package/dist/references/messages.js +2 -0
  16. package/dist/references/protocol.js +39 -12
  17. package/dist/workspace-runtime/batch/freeze.js +94 -24
  18. package/dist/workspace-runtime/batch/records.js +12 -6
  19. package/dist/workspace-runtime/batch/routes.js +31 -11
  20. package/dist/workspace-runtime/batch/source-candidate.js +29 -16
  21. package/dist/workspace-runtime/batch/source-capture.js +251 -20
  22. package/dist/workspace-runtime/codex/app-server.js +30 -1
  23. package/dist/workspace-runtime/codex/model-broker.js +93 -6
  24. package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
  25. package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
  26. package/dist/workspace-runtime/serve.js +5 -0
  27. package/dist/workspace-runtime/server/directory-access.js +10 -0
  28. package/dist/workspace-runtime/server/dispatch.js +121 -28
  29. package/dist/workspace-runtime/server/http.js +345 -117
  30. package/dist/workspace-runtime/server/operation-record.js +3 -1
  31. package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
  32. package/dist/workspace-runtime/workspaces/files.js +59 -23
  33. package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
  34. package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
  35. package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
  36. package/dist/workspace-runtime/workspaces/skills.js +13 -176
  37. package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
  38. package/package.json +4 -3
  39. package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  40. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  41. package/project-templates/empty/gitignore +1 -0
  42. package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  43. package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
package/README.md CHANGED
@@ -37,7 +37,26 @@ npx @playcraft/cli@latest workspace push
37
37
 
38
38
  ## ✨ 特性
39
39
 
40
- 远程技能的上传、更新、发布、审核、查询与下载见 [技能注册表命令](./SKILLS_REGISTRY.md)。入口为 `playcraft skills registry`,复用已有登录;本地技能发现与项目技能命令保持原有语义。
40
+ 远程技能的上传、更新、发布、审核、查询与下载见 [技能注册表命令](./SKILLS_REGISTRY.md)。入口为 `playcraft skills registry`,复用已有登录。
41
+
42
+ `skills match` 默认从当前身份有权访问的远端技能库推荐候选,不依赖本地安装目录;只查询名称、描述、触发词、标签、能力和引擎等元数据:
43
+
44
+ ```bash
45
+ playcraft skills match --intent "military,merge,base-building,strategy,island,battle" --engine threejs --json > /tmp/skills-match.json
46
+ playcraft skills prepare --from-match /tmp/skills-match.json --skills "<候选中的namespace/slug>" --json
47
+ ```
48
+
49
+ 选定后提交候选的精确版本标识和制品摘要,服务端复验权限并解析硬依赖;准备命令仅下载独立选择锁中的包,校验后冻结到 `.playcraft/skill-selections/<selectionId>/`。版本发布变化不会静默替换所选根版本;缺少可解析的依赖或有效图关系会明确失败,不改用其他引擎或最新版本。目录不进入 Git 或作品交付包,同一选择可离线复验复用。
50
+
51
+ 云端工程会话中,`prepare` 默认在下载校验完成后登记持久激活请求,返回 `activation_requested`、`executionReady: false`。当前回合保持原锁,正常结束后由平台在同一会话自动派发唯一后续回合;原生进程空闲后合并精确技能、生成新锁、重载技能并要求读取选定正文,继续原任务并保留已有修改,无需用户再次发送消息。沿用原模型、推理强度及不高于当前许可的权限。
52
+
53
+ 已启用且满足当前回合执行要求的相同精确技能返回 `already_active`,不重复接续;需要独占执行的技能若尚未获得本回合的明确授权,仍会安排独占后续回合。`--prepare-only` 可只下载,本地没有云端执行代理时仍返回 `prepared_requires_activation`。激活保留已有技能和版本,同引用不同修订或登记后项目锁变化时明确冲突,不静默升级。来源回合失败、取消、结果未知、工程改绑定、技能撤权或新用户回合已取代来源时,不重放任务。持久请求与固定操作编号支持断线恢复;声明与锁的中断写入通过私有事务收敛。
54
+
55
+ 运行程序公开 `skillActivationVersion: 1`。升级需一起发布 Runtime、Business 和沙箱命令行工具;旧程序缺少能力时明确拒绝自动接续。
56
+
57
+ `skills search --query "<关键词>" --json` 与 `skills match --installed ...` 保留为当前项目锁中已启用技能的查询。只有本地 Atom 编写场景显式使用 `match --local` / `--skills-dir`;远端请求失败不会自动回退本地扫描。
58
+
59
+ 沙箱通过当前会话的受限回环代理访问 Runtime,再由 Business 处理技能权限和版本;用户访问令牌和对象存储凭据不会交给执行模型。本地命令复用 `playcraft login` 的环境,也支持 `--url`。上线须同时更新 Business、Runtime 与沙箱命令行工具;模板更新不会自动改写已有锁定的推荐技能正文。
41
60
 
42
61
  - 🚀 **本地开发隧道** - 在本地 IDE 编写代码,云端编辑器实时预览与热更新
43
62
  - 📦 **Playable Ads 打包** - 支持 10 个主流广告平台的一键打包
@@ -8,6 +8,7 @@ export function getCliTopicsHelpText() {
8
8
  ' 素材库 library list/search/facets/get/files/download',
9
9
  ' 生成任务 assets estimate/generate/tasks/get/wait/cancel/download | assets animation',
10
10
  ' Remix 参考 references list/search/import(发现参考并读取当前工程中的固定快照)',
11
+ ' references prepare-starter-template(准备默认起始模板)',
11
12
  ' 视频理解 video models | video understand | video jobs get/cancel',
12
13
  ' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
13
14
  ' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
@@ -20,6 +20,8 @@ export function registerReferenceCommands(program) {
20
20
  const output = { schemaVersion: 'playcraft.references.v1', command: `references.${action}`, ...result };
21
21
  if (options.json)
22
22
  console.log(JSON.stringify(output));
23
+ else if (action === 'prepare-starter-template')
24
+ console.log(result.path);
23
25
  else if (action === 'import') {
24
26
  const reference = result.reference;
25
27
  console.log(`${reference.name}\n${reference.resolvedPath}`);
@@ -43,6 +45,7 @@ export function registerReferenceCommands(program) {
43
45
  const scope = (command) => command.option('--workspace <id>', message('workspace'))
44
46
  .option('--directory <name>', message('directory')).option('--json', message('json'));
45
47
  scope(references.command('list').description(message('list'))).action(options => run('list', options));
48
+ scope(references.command('prepare-starter-template').description(message('prepareStarterTemplate'))).action(options => run('prepare-starter-template', options));
46
49
  scope(references.command('search [query]').description(message('search'))
47
50
  .option('--source <source>', message('source'), 'recommended')
48
51
  .option('--tag <value>', message('tag'), (value, previous) => [...previous, value], [])
@@ -19,6 +19,11 @@
19
19
  * 4. <CLI 自身>/../../skills/playcraft-skills(monorepo 开发时,兼容旧路径 skills)
20
20
  */
21
21
  import { registerProjectSkillsCommands } from '../project-skills/commands.js';
22
+ import { matchRemoteSkills, registerRemoteSkillPrepareCommand } from '../project-skills/remote-commands.js';
23
+ import { readLockedProjectSkillCatalog } from '../project-skills/discovery.js';
24
+ import { findManagedSkillsRoot, filterLockedSkillsByEngine, normalizeSkillEngine, rankLockedProjectSkills } from '../project-skills/search.js';
25
+ import { registerSkillSearchCommand, printLockedSkillSearch, printSkillSearchError } from '../project-skills/search-command.js';
26
+ import { projectSkillsMessage as projectSkillMessage } from '../project-skills/messages.js';
22
27
  import { registerRegistryCommands } from '../skill-registry/commands.js';
23
28
  import * as fs from 'node:fs';
24
29
  import * as path from 'node:path';
@@ -1433,6 +1438,8 @@ export function registerSkillsCommands(program) {
1433
1438
  .command('skills')
1434
1439
  .description('开发时 Skill 发现与脚手架工具(按 engine/intent 推荐 skill 集合,或将 ref 文件复制到项目)');
1435
1440
  registerProjectSkillsCommands(skills);
1441
+ registerRemoteSkillPrepareCommand(skills);
1442
+ registerSkillSearchCommand(skills);
1436
1443
  registerRegistryCommands(skills);
1437
1444
  // 公共选项:skills 目录
1438
1445
  const addSkillsDirOption = (cmd) => cmd.option('--skills-dir <path>', '指定 skills 目录路径(默认自动发现)');
@@ -1487,11 +1494,68 @@ export function registerSkillsCommands(program) {
1487
1494
  // ── match ─────────────────────────────────────────────────────────────────
1488
1495
  addSkillsDirOption(skills
1489
1496
  .command('match')
1490
- .description('按 intent 推荐完整 skill 集合(含依赖与 scaffold);省略 --engine 时自动从 intent 推断引擎')
1491
- .option('--engine <name>', '目标引擎(threejs / phaser);省略则由 --intent 自动推断')
1492
- .option('--intent <tags>', '意图关键词,逗号分隔(如 board,3d,path,animation)', '')
1497
+ .description(projectSkillMessage('remoteMatch'))
1498
+ .option('--engine <name>', projectSkillMessage('remoteEngine'))
1499
+ .option('--intent <tags>', projectSkillMessage('remoteIntent'), '')
1500
+ .option('--dir <path>', projectSkillMessage('directory'), '.')
1501
+ .option('--limit <count>', projectSkillMessage('searchLimit'), '20')
1502
+ .option('--url <url>', projectSkillMessage('remoteUrl'))
1503
+ .option('--installed', projectSkillMessage('remoteInstalled'))
1504
+ .option('--local', projectSkillMessage('remoteLocal'))
1493
1505
  .option('--json', '输出 JSON 格式')
1494
- .option('--verbose', '输出 scaffold 明细与完整媒体/命令提示')).action(async (opts) => {
1506
+ .option('--verbose', projectSkillMessage('remoteVerbose'))).action(async (opts) => {
1507
+ try {
1508
+ if (!opts.installed && !opts.local && !opts.skillsDir) {
1509
+ await matchRemoteSkills(opts);
1510
+ return;
1511
+ }
1512
+ const root = await findManagedSkillsRoot(opts.dir);
1513
+ if (root) {
1514
+ if (opts.skillsDir)
1515
+ throw new Error(projectSkillMessage('searchManagedOverride'));
1516
+ const catalog = await readLockedProjectSkillCatalog(root);
1517
+ const engine = opts.engine ? normalizeSkillEngine(opts.engine) : undefined;
1518
+ const intentTags = opts.intent.split(/[\s,,、]+/).map(tag => tag.trim()).filter(Boolean);
1519
+ let entries = filterLockedSkillsByEngine(catalog.skills, engine).flatMap(skill => skill.manifest
1520
+ ? [{ atomId: skill.manifest.atomId, dir: skill.skillDir, manifest: skill.manifest }] : []);
1521
+ let matched = engine
1522
+ ? buildMatchResult(entries, engine, intentTags, opts.intent)
1523
+ : buildAutoMatchResult(entries, intentTags, opts.intent);
1524
+ if (!engine && matched.engine) {
1525
+ entries = filterLockedSkillsByEngine(catalog.skills, matched.engine).flatMap(skill => skill.manifest
1526
+ ? [{ atomId: skill.manifest.atomId, dir: skill.skillDir, manifest: skill.manifest }] : []);
1527
+ matched = { ...matched, ...buildMatchResult(entries, matched.engine, intentTags, opts.intent), engineSource: 'inferred' };
1528
+ }
1529
+ const byAtom = new Map(catalog.skills.filter(skill => skill.manifest).map(skill => [skill.manifest.atomId, skill]));
1530
+ const search = rankLockedProjectSkills(catalog.skills, { query: opts.intent, engine: engine ?? matched.engine ?? undefined });
1531
+ const result = { ...matched, source: 'project-lock', lockHash: catalog.lockHash,
1532
+ // Legacy fusion edges are not relationships from this immutable lock.
1533
+ fusionHints: [],
1534
+ items: matched.items.map(item => {
1535
+ const skill = byAtom.get(item.atomId);
1536
+ return { ...item, entryKey: skill.entryKey, skillRef: skill.skillRef,
1537
+ revision: skill.revision, artifactHash: skill.artifactHash };
1538
+ }),
1539
+ skills: search.skills, totalSkills: search.total,
1540
+ diagnostics: entries.length ? [] : [projectSkillMessage('searchNoAtoms')] };
1541
+ if (opts.json)
1542
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1543
+ else {
1544
+ if (entries.length)
1545
+ printMatchResult(result, !!opts.verbose, entries);
1546
+ for (const diagnostic of result.diagnostics)
1547
+ console.log(diagnostic);
1548
+ printLockedSkillSearch(search.skills, search.total);
1549
+ }
1550
+ return;
1551
+ }
1552
+ if (opts.installed)
1553
+ throw new Error(projectSkillMessage('searchRequiresLock'));
1554
+ }
1555
+ catch (error) {
1556
+ printSkillSearchError(error, opts.json);
1557
+ return;
1558
+ }
1495
1559
  const config = await loadConfig({}).catch(() => ({ skillsDir: undefined }));
1496
1560
  const dirs = resolveSkillsDirs(opts.skillsDir, config.skillsDir);
1497
1561
  if (dirs.length === 0) {
@@ -100,6 +100,8 @@ export function registerBatchCommands(tools) {
100
100
  tools.command('get-batch')
101
101
  .description(t.status)
102
102
  .requiredOption('--batch-id <id>', t.id)
103
+ // Output is always JSON; accept the flag so status reads match create-batch and sync-batch.
104
+ .option('--json', 'JSON (JavaScript Object Notation, 数据交换格式)')
103
105
  .action(async (opts) => {
104
106
  try {
105
107
  const client = new AgentApiClient();
@@ -0,0 +1,248 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, readFile, readdir, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { load } from 'js-yaml';
6
+ import { canonicalProjectSkillsJson, createCanonicalBundleArchive, effectiveProjectSkills, extractBundleArchive, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
7
+ import { projectSkillsCache } from './local-store.js';
8
+ import { ensureSharedDirectory, writeSharedFile } from '../workspace-runtime/workspaces/shared-write.js';
9
+ /** Runtime availability is determined by the locked metadata, independent of unsynced source edits. */
10
+ export async function readLockedProjectSkillSnapshot(root) {
11
+ const management = await lstat(join(root, '.playcraft'));
12
+ const declaration = join(root, '.playcraft/skills.json');
13
+ const stat = await lstat(declaration);
14
+ if (!management.isDirectory() || management.isSymbolicLink() || !stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
15
+ throw new Error('workspace_skills_invalid');
16
+ return validateProjectSkillsSnapshot(await readFile(declaration, 'utf8'), await readProjectSkillsLockFile(root));
17
+ }
18
+ export async function ensureProjectSkillDirectory(root, path, reuseExisting = false) {
19
+ let current = root;
20
+ let missing = false;
21
+ for (const segment of path.split('/')) {
22
+ current = join(current, segment);
23
+ // Validate every existing segment before creating anything, so a link never redirects the tree.
24
+ const stat = await lstat(current).catch((error) => {
25
+ if (error.code === 'ENOENT')
26
+ return undefined;
27
+ throw error;
28
+ });
29
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
30
+ throw new Error('workspace_skills_invalid');
31
+ if (!stat)
32
+ missing = true;
33
+ }
34
+ if (reuseExisting && !missing)
35
+ return current;
36
+ await ensureSharedDirectory(root, current);
37
+ return current;
38
+ }
39
+ /** Cache misses may reconstruct only the exact locked repository bytes, never a newer source. */
40
+ export async function readLockedProjectSkillFiles(root, cache, entry, view, loadArtifact, reuseExisting = false) {
41
+ const persistent = await ensureProjectSkillDirectory(root, '.playcraft/skill-artifacts', reuseExisting);
42
+ const saved = join(persistent, `${entry.revision.artifactHash}.tar.gz`);
43
+ const path = join(cache, `${entry.revision.artifactHash}.tar.gz`);
44
+ const contentHash = view.lock.lockVersion === 4 ? entry.revision.contentHash : undefined;
45
+ const contentSaved = contentHash ? join(persistent, `content-${contentHash}.tar.gz`) : undefined;
46
+ const contentPath = contentHash ? join(cache, `content-${contentHash}.tar.gz`) : undefined;
47
+ let archive;
48
+ let contentAddressed = false;
49
+ let persisted = false;
50
+ try {
51
+ const stat = await lstat(saved);
52
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 80 * 1024 * 1024)
53
+ throw new Error('workspace_skills_invalid_bundle');
54
+ archive = await readFile(saved);
55
+ persisted = true;
56
+ }
57
+ catch (error) {
58
+ if (error.code !== 'ENOENT')
59
+ throw error;
60
+ try {
61
+ if (!contentSaved)
62
+ throw error;
63
+ const stat = await lstat(contentSaved);
64
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 80 * 1024 * 1024)
65
+ throw new Error('workspace_skills_invalid_bundle');
66
+ archive = await readFile(contentSaved);
67
+ contentAddressed = true;
68
+ persisted = true;
69
+ }
70
+ catch (cacheError) {
71
+ if (cacheError.code !== 'ENOENT')
72
+ throw cacheError;
73
+ try {
74
+ archive = await readFile(path);
75
+ }
76
+ catch (error) {
77
+ if (error.code !== 'ENOENT')
78
+ throw error;
79
+ try {
80
+ if (!contentPath)
81
+ throw error;
82
+ archive = await readFile(contentPath);
83
+ contentAddressed = true;
84
+ }
85
+ catch (contentError) {
86
+ if (contentError.code !== 'ENOENT')
87
+ throw contentError;
88
+ const declaration = view.declaration.entries.find(item => item.entryKey === entry.entryKey);
89
+ if (entry.revision.kind === 'source' && declaration.source.kind === 'repository') {
90
+ const source = await readProjectSkillSource(root, declaration);
91
+ if (source.sourceTreeHash !== entry.sourceTreeHash)
92
+ throw new Error('workspace_skills_source_changed');
93
+ if (contentHash) {
94
+ if (source.sourceTreeHash !== contentHash)
95
+ throw new Error('workspace_skills_digest_mismatch');
96
+ return source.files.map(file => ({ path: file.path, data: Buffer.from(file.content) }));
97
+ }
98
+ archive = createCanonicalBundleArchive(source.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
99
+ }
100
+ else {
101
+ if (!loadArtifact)
102
+ throw new Error('workspace_skills_artifact_unavailable');
103
+ archive = await loadArtifact(entry);
104
+ contentAddressed = Boolean(contentHash);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ if (!contentAddressed && createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
111
+ throw new Error('workspace_skills_digest_mismatch');
112
+ const extracted = extractBundleArchive(archive);
113
+ if (extracted.issues.length)
114
+ throw new Error('workspace_skills_invalid_bundle');
115
+ if (contentHash && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== contentHash)
116
+ throw new Error('workspace_skills_digest_mismatch');
117
+ if (entry.revision.kind === 'source' && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.sourceTreeHash)
118
+ throw new Error('workspace_skills_digest_mismatch');
119
+ if (!reuseExisting || !persisted)
120
+ await writeSharedFile(root, contentAddressed ? contentSaved : saved, archive);
121
+ return extracted.files;
122
+ }
123
+ export function projectSkillMetadata(entryKey, content) {
124
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
125
+ const parsed = frontmatter ? load(frontmatter[1]) : {};
126
+ const data = parsed && typeof parsed === 'object' ? parsed : {};
127
+ return { entryKey, name: typeof data.name === 'string' ? data.name : entryKey,
128
+ description: typeof data.description === 'string' ? data.description : '', content };
129
+ }
130
+ /** Read-only task generation lives outside source discovery and is omitted from Git/snapshots. */
131
+ async function freezeProjectSkillFiles(root, cache, loadArtifact, reuseExisting = false) {
132
+ const snapshot = await readLockedProjectSkillSnapshot(root);
133
+ const entries = effectiveProjectSkills(snapshot.declaration, snapshot.lock, 'sandbox-agent');
134
+ const base = await ensureProjectSkillDirectory(root, '.playcraft/skills', reuseExisting);
135
+ const lockHash = createHash('sha256').update(canonicalProjectSkillsJson(snapshot.lock)).digest('hex');
136
+ const generation = await ensureProjectSkillDirectory(base, lockHash, reuseExisting);
137
+ const skills = [];
138
+ const expectedFiles = new Set();
139
+ for (const entry of entries) {
140
+ const files = await readLockedProjectSkillFiles(root, cache, entry, snapshot, loadArtifact, reuseExisting);
141
+ for (const file of files) {
142
+ const target = join(generation, entry.entryKey, file.path);
143
+ const relative = join(entry.entryKey, file.path);
144
+ expectedFiles.add(relative);
145
+ await ensureProjectSkillDirectory(generation, dirname(relative), reuseExisting);
146
+ const existing = await lstat(target).catch((error) => {
147
+ if (error.code !== 'ENOENT')
148
+ throw error;
149
+ return undefined;
150
+ });
151
+ if (!existing)
152
+ await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(error => {
153
+ if (error.code !== 'EEXIST')
154
+ throw error;
155
+ });
156
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
157
+ try {
158
+ const stat = await handle.stat();
159
+ if (!stat.isFile() || stat.nlink !== 1 || !file.data.equals(await handle.readFile()))
160
+ throw new Error('workspace_skills_digest_mismatch');
161
+ // A private controller umask must not remove the developer's read access.
162
+ // An already shared read-only inode may belong to the controller rather than this reader.
163
+ // Repair only when needed, after verifying the bytes on the same open descriptor.
164
+ if ((stat.mode & 0o777) !== 0o444)
165
+ await handle.chmod(0o444);
166
+ }
167
+ finally {
168
+ await handle.close();
169
+ }
170
+ }
171
+ skills.push({ entry, files, skillDir: join(generation, entry.entryKey),
172
+ path: join(generation, entry.entryKey, 'SKILL.md') });
173
+ }
174
+ const verifyTree = async (directory, relative = '') => {
175
+ for (const item of await readdir(directory, { withFileTypes: true })) {
176
+ const path = join(relative, item.name);
177
+ if (item.isSymbolicLink())
178
+ throw new Error('workspace_skills_invalid_bundle');
179
+ if (item.isDirectory()) {
180
+ if (![...expectedFiles].some(file => file.startsWith(`${path}/`)))
181
+ throw new Error('workspace_skills_invalid_bundle');
182
+ await verifyTree(join(directory, item.name), path);
183
+ }
184
+ else if (!item.isFile() || !expectedFiles.has(path))
185
+ throw new Error('workspace_skills_invalid_bundle');
186
+ }
187
+ };
188
+ await verifyTree(generation);
189
+ return { snapshot, lockHash, skills };
190
+ }
191
+ /** Compatibility view used by native runners; discovery and execution share the same verified files. */
192
+ export async function freezeLockedProjectSkills(root, cache, loadArtifact) {
193
+ const frozen = await freezeProjectSkillFiles(root, cache, loadArtifact);
194
+ return { declarationHash: frozen.snapshot.lock.declarationHash, lockHash: frozen.lockHash,
195
+ skills: frozen.skills.map(({ entry, files, path }) => {
196
+ const body = files.find(file => file.path === 'SKILL.md');
197
+ if (!body)
198
+ throw new Error('workspace_skills_body_missing');
199
+ const info = projectSkillMetadata(entry.entryKey, body.data.toString('utf8'));
200
+ return { name: info.name, description: info.description, skillRef: entry.revision.skillRef,
201
+ revision: entry.revision.kind === 'source' ? entry.revision.sourceRevision : entry.revision.version, path };
202
+ }) };
203
+ }
204
+ function metadataTerms(value) {
205
+ if (typeof value === 'string')
206
+ return value.split(/[,,、\n]+/).map(term => term.trim()).filter(Boolean);
207
+ if (Array.isArray(value))
208
+ return value.filter((term) => typeof term === 'string').map(term => term.trim()).filter(Boolean);
209
+ return [];
210
+ }
211
+ function atomManifest(value) {
212
+ if (!value || typeof value !== 'object')
213
+ return undefined;
214
+ const record = value;
215
+ const label = record.label && typeof record.label === 'object' ? record.label : undefined;
216
+ if (typeof record.atomId !== 'string' || !record.atomId.trim() || typeof record.category !== 'string'
217
+ || typeof label?.zh !== 'string' || typeof label.en !== 'string'
218
+ || !Array.isArray(record.tags) || record.tags.some(tag => typeof tag !== 'string'))
219
+ return undefined;
220
+ return value;
221
+ }
222
+ /** Searches only enabled sandbox skills from the validated project lock, never live source scans. */
223
+ export async function readLockedProjectSkillCatalog(root, cache = projectSkillsCache(), loadArtifact) {
224
+ const frozen = await freezeProjectSkillFiles(root, cache, loadArtifact, true);
225
+ const skills = frozen.skills.map(({ entry, files, skillDir, path }) => {
226
+ const body = files.find(file => file.path === 'SKILL.md');
227
+ if (!body)
228
+ throw new Error('workspace_skills_body_missing');
229
+ const content = body.data.toString('utf8');
230
+ const info = projectSkillMetadata(entry.entryKey, content);
231
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
232
+ const parsed = frontmatter ? load(frontmatter[1]) : {};
233
+ const data = parsed && typeof parsed === 'object' ? parsed : {};
234
+ const manifestFile = files.find(file => file.path === 'manifest.json');
235
+ let manifest;
236
+ if (manifestFile) {
237
+ const value = JSON.parse(manifestFile.data.toString('utf8'));
238
+ manifest = atomManifest(value);
239
+ }
240
+ return { entryKey: entry.entryKey, skillRef: entry.revision.skillRef,
241
+ revision: entry.revision.kind === 'source' ? entry.revision.sourceRevision : entry.revision.version,
242
+ artifactHash: entry.revision.artifactHash, name: info.name, description: info.description,
243
+ triggers: metadataTerms(data.triggers), tags: [...new Set([...metadataTerms(data.tags), ...metadataTerms(manifest?.tags)])],
244
+ keywords: [...new Set([...metadataTerms(data.keywords), ...metadataTerms(manifest?.keywords)])],
245
+ ...(manifest ? { manifest } : {}), skillDir, path };
246
+ });
247
+ return { snapshot: frozen.snapshot, lockHash: frozen.lockHash, skills };
248
+ }
@@ -1,5 +1,45 @@
1
1
  const messages = {
2
2
  en: {
3
+ remoteMatch: 'Recommend accessible remote skill candidates by intent and engine; download only after exact selection',
4
+ remoteEngine: 'Filter by engine (threejs / phaser); omit to discover candidates across engines',
5
+ remoteIntent: 'Task intent, keywords or natural-language query',
6
+ remoteVerbose: 'Show scaffold details for explicitly local matching',
7
+ remoteUrl: 'Platform environment URL (defaults to the saved login environment)',
8
+ remoteUnavailable: 'The remote skill request did not complete. No local directory or different version was substituted.',
9
+ remoteAccessDenied: 'Remote skill access was denied. Check the current platform login or execution scope.',
10
+ remoteBridgeInvalid: 'The current execution skill bridge is invalid; refusing another identity or environment.',
11
+ remoteTooLarge: 'The remote skill response exceeds the allowed size',
12
+ remoteSelectionChanged: 'The selected exact skill revision does not match the remote preparation receipt',
13
+ remoteDigestMismatch: 'The downloaded skill does not match its locked artifact digest',
14
+ remoteInvalidEngine: 'The engine must be phaser or threejs',
15
+ remoteSource: 'Source: authorized remote skill registry candidates (not yet installed)',
16
+ remoteNoMatches: 'No accessible remote skill candidates match this query.',
17
+ remotePrepareHint: 'Save the --json match result, then use skills prepare --from-match <file> --skills <skillRef,...> to download chosen exact versions and dependencies.',
18
+ remoteInstalled: 'Search the current enabled project lock explicitly',
19
+ remoteLocal: 'Use local Atom authoring directories explicitly (no remote lookup)',
20
+ remotePrepare: 'Prepare exact candidates and dependencies; in a cloud session, activate them and continue automatically after this turn',
21
+ remotePrepareOnly: 'Download only; do not request activation or continuation',
22
+ remoteActivationRequested: 'Activation is queued. Finish this turn normally; the platform will load the exact selected skills and continue this task in the next turn. Do not execute the prepared copy or request another continuation.',
23
+ remoteAlreadyActive: 'These exact skills are already enabled in the current lock. Use its frozen paths; no additional turn was requested.',
24
+ remoteMatchFile: 'A saved JSON result from skills match',
25
+ remoteSelectedRefs: 'Comma-separated skillRef or skillRef@version values from that match result',
26
+ remoteInvalidMatchFile: 'Expected a regular match result file smaller than 4 MiB',
27
+ remoteInvalidSelection: 'Choose 1 to 50 unambiguous skill references from the saved match result',
28
+ remotePrepared: 'Selected exact skill bundles are prepared locally.',
29
+ remoteActivationRequired: 'This selection is prepared only. Automatic activation requires the current cloud execution bridge; the local preparation did not change the project or start a turn.',
30
+ search: 'Search enabled skills in the current project lock by name, description, triggers and tags',
31
+ searchQuery: 'Keywords or a natural-language query; omit to list enabled skills',
32
+ searchEngine: 'Filter declared engine compatibility (threejs / phaser)',
33
+ searchLimit: 'Maximum results, from 1 to 100',
34
+ searchInvalidLimit: 'The result limit must be an integer from 1 to 100',
35
+ searchRequiresLock: 'No project skill lock was found. In Cloud Workspace, open Skills management to configure skills. To discover platform candidates, use skills registry list --scope platform --query <text> --json; candidates must be explicitly added and locked before use.',
36
+ searchFailed: 'Skill search failed',
37
+ searchNoMatches: 'No enabled locked skills match this query.',
38
+ searchSummary: 'Matching locked skills:',
39
+ searchNoAtoms: 'This lock has no enabled Atom manifests. Search results below include ordinary skills; an empty Atom list does not mean no skills are available.',
40
+ searchLockedSource: 'Source: enabled project skills (exact locked revisions)',
41
+ searchManagedOverride: 'A managed project can only search its current skill lock; --skills-dir cannot override it.',
42
+ searchReadPath: 'Read the frozen SKILL.md at the returned path before using a skill.',
3
43
  migrateLockV4: 'Upgrade a v3 lock using verified original artifacts and retain a backup',
4
44
  migrationMissingArchive: 'Cannot upgrade: the exact locked artifact is missing',
5
45
  migrationInvalidArchive: 'Cannot upgrade: the locked artifact is invalid or changed',
@@ -45,6 +85,46 @@ const messages = {
45
85
  changed: 'Source files have changed since the saved revision',
46
86
  },
47
87
  zh: {
88
+ remoteMatch: '按意图和引擎推荐远端可访问技能候选,精确选定后再按需下载',
89
+ remoteEngine: '按引擎过滤(threejs / phaser);省略时发现不同引擎的候选',
90
+ remoteIntent: '任务意图、关键词或自然语言查询',
91
+ remoteVerbose: '显式使用本地匹配时显示脚手架明细',
92
+ remoteUrl: '平台环境地址(默认使用已保存登录的环境)',
93
+ remoteUnavailable: '远端技能请求未完成;未改用本地目录或其他版本。',
94
+ remoteAccessDenied: '远端技能访问被拒绝,请检查平台登录或当前执行范围。',
95
+ remoteBridgeInvalid: '当前执行的技能代理配置无效,拒绝改用其他身份或环境。',
96
+ remoteTooLarge: '远端技能响应超出允许大小',
97
+ remoteSelectionChanged: '所选技能的精确修订与远端准备回执不一致',
98
+ remoteDigestMismatch: '下载的技能与锁定制品摘要不一致',
99
+ remoteInvalidEngine: '引擎必须是 phaser 或 threejs',
100
+ remoteSource: '来源:有权访问的远端技能候选(尚未安装)',
101
+ remoteNoMatches: '远端可访问技能中没有匹配此查询的候选。',
102
+ remotePrepareHint: '保存 --json 匹配结果,再用 skills prepare --from-match <文件> --skills <skillRef,...> 下载选定的精确版本及依赖。',
103
+ remoteInstalled: '显式检索当前项目锁中已启用的技能',
104
+ remoteLocal: '显式使用本地 Atom 编写目录,不查询远端',
105
+ remotePrepare: '下载选定精确版本及依赖;在云端会话中,本回合结束后自动激活并接续任务',
106
+ remotePrepareOnly: '只下载准备,不请求激活或任务接续',
107
+ remoteActivationRequested: '已登记激活请求。请正常结束本回合,平台将在下一回合加载选定的精确技能并继续原任务;不要执行准备副本或再次申请接续。',
108
+ remoteAlreadyActive: '这些精确技能已在当前锁中启用,请使用当前锁的冻结路径;未重复申请新回合。',
109
+ remoteMatchFile: 'skills match 保存的结构化结果文件',
110
+ remoteSelectedRefs: '匹配结果中的 skillRef 或 skillRef@version,多个用逗号分隔',
111
+ remoteInvalidMatchFile: '匹配结果须为小于 4 MiB 的普通文件',
112
+ remoteInvalidSelection: '请从匹配结果中选择 1 到 50 个无歧义的技能引用',
113
+ remotePrepared: '选定技能的精确制品已在本地准备完成。',
114
+ remoteActivationRequired: '此选择仅已准备。自动激活需要当前云端执行代理;本地准备未改写项目或启动回合。',
115
+ search: '按名称、描述、触发词和标签搜索当前项目锁中已启用的技能',
116
+ searchQuery: '关键词或自然语言查询;省略则列出已启用技能',
117
+ searchEngine: '按声明的引擎兼容性过滤(threejs / phaser)',
118
+ searchLimit: '最多返回条数,范围 1 到 100',
119
+ searchInvalidLimit: '返回条数必须是 1 到 100 的整数',
120
+ searchRequiresLock: '未找到项目技能锁。请在云工作区的技能管理中配置技能。发现平台候选可用 skills registry list --scope platform --query <text> --json;候选须显式加入项目并锁定后才能使用。',
121
+ searchFailed: '技能检索失败',
122
+ searchNoMatches: '当前锁中已启用的技能没有匹配此查询的结果。',
123
+ searchSummary: '匹配的锁定技能:',
124
+ searchNoAtoms: '当前锁没有已启用的 Atom manifest。下方搜索结果包含普通技能;Atom 列表为空不代表没有可用技能。',
125
+ searchLockedSource: '来源:项目已启用技能(精确锁定修订)',
126
+ searchManagedOverride: '受控项目只能检索当前技能锁,不能通过 --skills-dir 覆盖。',
127
+ searchReadPath: '使用技能前,请读取结果 path 指向的冻结 SKILL.md。',
48
128
  migrateLockV4: '使用已核验的原始技能包升级 v3 锁并保留备份',
49
129
  migrationMissingArchive: '无法升级:缺少锁定的原始技能包',
50
130
  migrationInvalidArchive: '无法升级:锁定的技能包无效或已更改',