@playcraft/cli 0.1.23 → 0.1.25

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 (65) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/playable.js +53 -0
  3. package/dist/commands/tools-3d.js +11 -0
  4. package/dist/commands/tools-batch.js +42 -14
  5. package/dist/commands/tools-generation.js +6 -0
  6. package/dist/commands/video.js +111 -0
  7. package/dist/index.js +29 -3
  8. package/dist/project-skills/builtin-import.js +225 -0
  9. package/dist/project-skills/commands.js +6 -1
  10. package/dist/project-skills/local-store.js +73 -1
  11. package/dist/project-skills/messages.js +8 -0
  12. package/dist/recording/messages.js +40 -0
  13. package/dist/recording/project-digest.js +85 -0
  14. package/dist/recording/recorder.js +293 -0
  15. package/dist/recording/scenario.js +50 -0
  16. package/dist/utils/agent-api-client.js +39 -3
  17. package/dist/utils/video-evidence-git.js +81 -0
  18. package/dist/video-replication/client.js +222 -0
  19. package/dist/video-replication/comparison-evidence.js +149 -0
  20. package/dist/video-replication/comparison-files.js +57 -0
  21. package/dist/video-replication/comparison-log.js +72 -0
  22. package/dist/video-replication/comparison-messages.js +104 -0
  23. package/dist/video-replication/comparison-output.js +63 -0
  24. package/dist/video-replication/comparison-preflight.js +66 -0
  25. package/dist/video-replication/comparison.js +216 -0
  26. package/dist/video-replication/evidence.js +165 -0
  27. package/dist/video-replication/media.js +111 -0
  28. package/dist/video-replication/messages.js +89 -0
  29. package/dist/workspace-runtime/codex/app-server.js +65 -12
  30. package/dist/workspace-runtime/codex/config-toml.js +3 -2
  31. package/dist/workspace-runtime/persistence/native-subagents.js +162 -0
  32. package/dist/workspace-runtime/persistence/sqlite-client.js +17 -1
  33. package/dist/workspace-runtime/persistence/sqlite-worker.js +116 -74
  34. package/dist/workspace-runtime/production/native.js +21 -1
  35. package/dist/workspace-runtime/production/save-command.js +5 -1
  36. package/dist/workspace-runtime/production/source-copy.js +91 -0
  37. package/dist/workspace-runtime/production/stages.js +48 -21
  38. package/dist/workspace-runtime/serve.js +3 -0
  39. package/dist/workspace-runtime/server/dispatch.js +34 -6
  40. package/dist/workspace-runtime/server/http.js +60 -3
  41. package/dist/workspace-runtime/server/terminal-host.js +2 -2
  42. package/dist/workspace-runtime/workspaces/artifact-events.js +55 -0
  43. package/dist/workspace-runtime/workspaces/artifact-files.js +195 -0
  44. package/dist/workspace-runtime/workspaces/artifact-receipts.js +89 -0
  45. package/dist/workspace-runtime/workspaces/artifacts.js +260 -0
  46. package/dist/workspace-runtime/workspaces/attachments.js +70 -4
  47. package/dist/workspace-runtime/workspaces/batch-commit.js +1 -1
  48. package/dist/workspace-runtime/workspaces/cli-context.js +23 -0
  49. package/dist/workspace-runtime/workspaces/directories.js +10 -3
  50. package/dist/workspace-runtime/workspaces/directory-access.js +9 -0
  51. package/dist/workspace-runtime/workspaces/disk-snapshot.js +31 -0
  52. package/dist/workspace-runtime/workspaces/empty-template.js +12 -4
  53. package/dist/workspace-runtime/workspaces/files.js +8 -1
  54. package/dist/workspace-runtime/workspaces/prepare.js +2 -1
  55. package/dist/workspace-runtime/workspaces/registry.js +13 -5
  56. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +45 -0
  57. package/dist/workspace-runtime/workspaces/skills.js +28 -18
  58. package/dist/workspace-runtime/workspaces/video-attachments.js +49 -0
  59. package/package.json +7 -4
  60. package/project-template/.claude/skills/playcraft-replica-validation/SKILL.md +25 -0
  61. package/project-template/.claude/skills/playcraft-replica-validation/references/contracts.md +79 -0
  62. package/project-template/.claude/skills/playcraft-video-understanding/SKILL.md +29 -0
  63. package/project-template/.claude/skills/playcraft-video-understanding/references/contracts.md +37 -0
  64. package/project-templates/empty/AGENTS.md +2 -0
  65. package/project-templates/empty/gitignore +2 -0
@@ -0,0 +1,225 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { canonicalProjectSkillsJson, declarationDigest, dependencyDigest, extractBundleArchive, readProjectSkillSource, readProjectSkillsLockFile, validateProjectSkillsSnapshot, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
5
+ import { LEGACY_VIDEO_SOURCE_HASHES } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { projectSkillsCache } from './local-store.js';
7
+ export const IMPORTABLE_BUILTIN_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
8
+ export const platformSkillRef = (name) => `remix/${name}`;
9
+ const hash = (value) => createHash('sha256').update(canonicalProjectSkillsJson(value)).digest('hex');
10
+ async function safeDirectory(root, path) {
11
+ let current = root;
12
+ for (const part of path.split('/')) {
13
+ current = join(current, part);
14
+ await mkdir(current, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
15
+ throw error; });
16
+ const stat = await lstat(current);
17
+ if (!stat.isDirectory() || stat.isSymbolicLink())
18
+ throw new Error('workspace_skills_invalid');
19
+ }
20
+ return current;
21
+ }
22
+ /** Selects published platform revisions once; neither directory contents nor names authorize a replacement. */
23
+ export async function selectPlatformSkillSnapshot(names, access) {
24
+ if (!names.length || new Set(names).size !== names.length
25
+ || names.some(name => !IMPORTABLE_BUILTIN_SKILLS.includes(name)))
26
+ throw new Error('workspace_skills_builtin_unknown');
27
+ if (!access)
28
+ throw new Error('workspace_skills_platform_required');
29
+ const requested = names.map(platformSkillRef);
30
+ const response = await access.resolve(requested);
31
+ const snapshot = validateProjectSkillsSnapshot(response?.declaration, response?.lock);
32
+ if (snapshot.declaration.entries.length !== names.length || snapshot.lock.entries.length !== names.length)
33
+ throw new Error('workspace_skills_invalid');
34
+ for (const name of names) {
35
+ const entry = snapshot.declaration.entries.find(item => item.entryKey === name);
36
+ const locked = snapshot.lock.entries.find(item => item.entryKey === name);
37
+ if (!entry || !locked || entry.source.kind !== 'registry' || entry.source.skillRef !== platformSkillRef(name)
38
+ || entry.surface !== 'sandbox-agent' || !entry.enabled || locked.revision.kind !== 'release'
39
+ || locked.dependencies.length !== 0 || locked.dependencyLockHash !== dependencyDigest([], []))
40
+ throw new Error('workspace_skills_invalid');
41
+ }
42
+ const artifacts = await Promise.all(snapshot.lock.entries.map(async (entry) => {
43
+ const archive = await access.loadArtifact(entry);
44
+ if (archive.length > 80 * 1024 * 1024 || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
45
+ throw new Error('workspace_skills_digest_mismatch');
46
+ const extracted = extractBundleArchive(archive);
47
+ if (extracted.issues.length || !extracted.files.some(file => file.path === 'SKILL.md'))
48
+ throw new Error('workspace_skills_invalid_bundle');
49
+ return { artifactHash: entry.revision.artifactHash, archive };
50
+ }));
51
+ return { ...snapshot, artifacts };
52
+ }
53
+ async function commitSnapshot(directory, snapshot, originalDeclaration, staged) {
54
+ const declarationPath = join(directory, PROJECT_SKILLS_DECLARATION_PATH);
55
+ const pending = [];
56
+ for (const [path, value] of [[PROJECT_SKILLS_DECLARATION_PATH, snapshot.declaration], [PROJECT_SKILLS_LOCK_PATH, snapshot.lock]]) {
57
+ const temporary = `${join(directory, path)}.${randomUUID()}.tmp`;
58
+ staged.push(temporary);
59
+ await writeFile(temporary, `${canonicalProjectSkillsJson(value)}\n`, { flag: 'wx', mode: 0o644 });
60
+ pending.push(temporary);
61
+ }
62
+ await rename(pending[0], declarationPath);
63
+ try {
64
+ await rename(pending[1], join(directory, PROJECT_SKILLS_LOCK_PATH));
65
+ }
66
+ catch (error) {
67
+ // The old lock still exists. Restore its exact declaration before releasing the writer lease.
68
+ const restore = `${declarationPath}.${randomUUID()}.tmp`;
69
+ staged.push(restore);
70
+ await writeFile(restore, originalDeclaration, { flag: 'wx', mode: 0o644 });
71
+ await rename(restore, declarationPath);
72
+ throw error;
73
+ }
74
+ }
75
+ async function cacheArtifacts(cache, selected) {
76
+ await mkdir(cache, { recursive: true, mode: 0o700 });
77
+ for (const artifact of selected.artifacts) {
78
+ const path = join(cache, `${artifact.artifactHash}.tar.gz`);
79
+ try {
80
+ await writeFile(path, artifact.archive, { flag: 'wx', mode: 0o600 });
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'EEXIST' || !(await readFile(path)).equals(artifact.archive))
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+ /** Explicit imports keep prior locks and unsynced repository sources byte-for-byte. */
89
+ export async function importBuiltinProjectSkills(directory, options, cache = projectSkillsCache(), access, selected, enabled = false) {
90
+ if (!options.names.length || new Set(options.names).size !== options.names.length
91
+ || options.names.some(name => !IMPORTABLE_BUILTIN_SKILLS.includes(name)))
92
+ throw new Error('workspace_skills_builtin_unknown');
93
+ const management = await safeDirectory(directory, '.playcraft');
94
+ const lease = join(management, 'skills-write.lock');
95
+ await writeFile(lease, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
96
+ const staged = [];
97
+ try {
98
+ const declarationPath = join(directory, PROJECT_SKILLS_DECLARATION_PATH);
99
+ const declarationStat = await lstat(declarationPath);
100
+ if (!declarationStat.isFile() || declarationStat.isSymbolicLink() || declarationStat.size > 1024 * 1024)
101
+ throw new Error('workspace_skills_invalid');
102
+ const originalDeclaration = await readFile(declarationPath);
103
+ const previous = validateProjectSkillsSnapshot(originalDeclaration.toString('utf8'), await readProjectSkillsLockFile(directory));
104
+ if (options.expectedDeclarationHash !== previous.lock.declarationHash || options.expectedLockHash !== hash(previous.lock))
105
+ throw new Error('skills_revision_conflict');
106
+ const next = structuredClone(previous);
107
+ const unchanged = [], missing = [];
108
+ for (const name of options.names) {
109
+ const existing = previous.declaration.entries.find(entry => entry.source.kind === 'registry' && entry.source.skillRef === platformSkillRef(name));
110
+ if (existing) {
111
+ unchanged.push(existing.entryKey);
112
+ continue;
113
+ }
114
+ if (previous.declaration.entries.some(entry => entry.entryKey === name || entry.source.kind === 'repository' && basename(entry.source.root) === name))
115
+ throw new Error('workspace_skills_builtin_conflict');
116
+ for (const sourceRoot of ['.agents/skills', '.claude/skills', '.opencode/skills', '.opencode/skill']) {
117
+ if (await lstat(join(directory, sourceRoot, name)).then(() => true, error => { if (error.code === 'ENOENT')
118
+ return false; throw error; }))
119
+ throw new Error('workspace_skills_builtin_conflict');
120
+ }
121
+ missing.push(name);
122
+ }
123
+ const imported = [];
124
+ if (missing.length) {
125
+ const resolved = selected ?? await selectPlatformSkillSnapshot(missing, access);
126
+ await cacheArtifacts(cache, resolved);
127
+ for (const name of missing) {
128
+ const entry = resolved.declaration.entries.find(item => item.entryKey === name);
129
+ const locked = resolved.lock.entries.find(item => item.entryKey === name);
130
+ if (!entry || !locked)
131
+ throw new Error('workspace_skills_invalid');
132
+ next.declaration.entries.push({ ...entry, enabled });
133
+ next.lock.entries.push(locked);
134
+ imported.push(name);
135
+ }
136
+ next.lock.declarationHash = declarationDigest(next.declaration);
137
+ validateProjectSkillsSnapshot(next.declaration, next.lock);
138
+ await commitSnapshot(directory, next, originalDeclaration, staged);
139
+ }
140
+ return { ...next, imported, unchanged, lockHash: hash(next.lock) };
141
+ }
142
+ finally {
143
+ for (const path of staged)
144
+ await rm(path, { force: true });
145
+ await rm(lease, { force: true });
146
+ }
147
+ }
148
+ /** Only original bundled sources qualify; changed or forked skills require a separate explicit user decision. */
149
+ export async function migrateVideoProjectSkills(directory, options, cache, access) {
150
+ const management = await safeDirectory(directory, '.playcraft');
151
+ const lease = join(management, 'skills-write.lock');
152
+ await writeFile(lease, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
153
+ const moved = [];
154
+ const staged = [];
155
+ let committed = false;
156
+ try {
157
+ const declarationPath = join(directory, PROJECT_SKILLS_DECLARATION_PATH);
158
+ const declarationStat = await lstat(declarationPath);
159
+ if (!declarationStat.isFile() || declarationStat.isSymbolicLink() || declarationStat.size > 1024 * 1024)
160
+ throw new Error('workspace_skills_invalid');
161
+ const originalDeclaration = await readFile(declarationPath);
162
+ const previous = validateProjectSkillsSnapshot(originalDeclaration.toString('utf8'), await readProjectSkillsLockFile(directory));
163
+ const oldLockHash = hash(previous.lock);
164
+ if (options.expectedDeclarationHash !== previous.lock.declarationHash || options.expectedLockHash !== oldLockHash)
165
+ throw new Error('skills_revision_conflict');
166
+ if (!options.entryKeys.length || options.entryKeys.length > 2 || new Set(options.entryKeys).size !== options.entryKeys.length)
167
+ throw new Error('workspace_skills_builtin_unknown');
168
+ const replacements = [];
169
+ for (const key of options.entryKeys) {
170
+ const entry = previous.declaration.entries.find(item => item.entryKey === key);
171
+ const locked = previous.lock.entries.find(item => item.entryKey === key);
172
+ if (!entry || !locked || entry.source.kind !== 'repository' || locked.revision.kind !== 'source'
173
+ || entry.surface !== 'sandbox-agent' || entry.projectFiles?.length || locked.dependencies.length
174
+ || previous.lock.entries.some(item => item.entryKey !== key && item.dependencies.includes(key)))
175
+ throw new Error('workspace_skills_builtin_conflict');
176
+ const name = basename(entry.source.root);
177
+ const known = LEGACY_VIDEO_SOURCE_HASHES[name];
178
+ if (!known || locked.sourceTreeHash !== known || (await readProjectSkillSource(directory, entry)).sourceTreeHash !== known
179
+ || previous.declaration.entries.some(item => item.entryKey !== key && (item.entryKey === name || item.source.kind === 'registry' && item.source.skillRef === platformSkillRef(name))))
180
+ throw new Error('workspace_skills_builtin_conflict');
181
+ replacements.push({ entry, name });
182
+ }
183
+ const selected = await selectPlatformSkillSnapshot(replacements.map(item => item.name), access);
184
+ await cacheArtifacts(cache, selected);
185
+ const next = structuredClone(previous);
186
+ for (const { entry, name } of replacements) {
187
+ const replacement = selected.declaration.entries.find(item => item.entryKey === name);
188
+ const locked = selected.lock.entries.find(item => item.entryKey === name);
189
+ next.declaration.entries[next.declaration.entries.findIndex(item => item.entryKey === entry.entryKey)] = { ...replacement, entryKey: entry.entryKey, enabled: entry.enabled };
190
+ next.lock.entries[next.lock.entries.findIndex(item => item.entryKey === entry.entryKey)] = { ...locked, entryKey: entry.entryKey };
191
+ }
192
+ for (const locked of next.lock.entries)
193
+ locked.dependencyLockHash = dependencyDigest(next.lock.entries, locked.dependencies);
194
+ next.lock.declarationHash = declarationDigest(next.declaration);
195
+ validateProjectSkillsSnapshot(next.declaration, next.lock);
196
+ const history = await safeDirectory(directory, `.playcraft/skill-migrations/${oldLockHash}`);
197
+ const receipt = { schemaVersion: 'playcraft.skill-migration.v1', previous, next, entryKeys: options.entryKeys };
198
+ const historyFile = join(history, 'migration.json');
199
+ try {
200
+ await writeFile(historyFile, `${canonicalProjectSkillsJson(receipt)}\n`, { flag: 'wx', mode: 0o644 });
201
+ }
202
+ catch (error) {
203
+ if (error.code !== 'EEXIST' || await readFile(historyFile, 'utf8') !== `${canonicalProjectSkillsJson(receipt)}\n`)
204
+ throw error;
205
+ }
206
+ for (const { entry } of replacements) {
207
+ if (entry.source.kind !== 'repository')
208
+ throw new Error('workspace_skills_invalid');
209
+ const from = join(directory, entry.source.root), to = join(history, entry.entryKey);
210
+ await rename(from, to);
211
+ moved.push({ from, to });
212
+ }
213
+ await commitSnapshot(directory, next, originalDeclaration, staged);
214
+ committed = true;
215
+ return { ...next, migrated: options.entryKeys, history: `.playcraft/skill-migrations/${oldLockHash}/migration.json`, lockHash: hash(next.lock) };
216
+ }
217
+ finally {
218
+ if (!committed)
219
+ for (const item of moved.reverse())
220
+ await rename(item.to, item.from).catch(() => undefined);
221
+ for (const path of staged)
222
+ await rm(path, { force: true });
223
+ await rm(lease, { force: true });
224
+ }
225
+ }
@@ -7,6 +7,7 @@ import { syncLocalProjectSkills, enableLocalProjectSkill, installFrozenProjectSk
7
7
  import { runLocalProjectSkills, localProjectSkillUsage } from './local-run.js';
8
8
  import { projectSkillsMessage as t } from './messages.js';
9
9
  import { reconcileLocalProjectSkills } from './reconcile.js';
10
+ import { importBuiltinProjectSkills } from './builtin-import.js';
10
11
  export async function inspectLocalProjectSkills(directory) {
11
12
  const inventory = await inspectProjectSkills(directory);
12
13
  // Report metadata only; never print complete skill bytes or project test data.
@@ -21,7 +22,7 @@ export function registerProjectSkillsCommands(skills) {
21
22
  process.stdout.write(`${JSON.stringify(await action(), null, 2)}\n`);
22
23
  }
23
24
  catch (error) {
24
- process.stderr.write(`${t('failed')}: ${error instanceof Error ? error.message : String(error)}\n`);
25
+ process.stderr.write(`${t('failed')}: ${error instanceof Error && error.message === 'workspace_skills_platform_required' ? t('platformRequired') : error instanceof Error ? error.message : String(error)}\n`);
25
26
  process.exitCode = 1;
26
27
  }
27
28
  };
@@ -36,6 +37,10 @@ export function registerProjectSkillsCommands(skills) {
36
37
  .action((options) => output(() => localProjectSkillUsage(resolve(options.dir))));
37
38
  project.command('sync').description(t('sync')).option('--dir <path>', t('directory'), '.')
38
39
  .action((options) => output(() => syncLocalProjectSkills(resolve(options.dir))));
40
+ project.command('import-builtins <names...>').description(t('importBuiltins')).option('--dir <path>', t('directory'), '.')
41
+ .requiredOption('--expected-declaration-hash <hash>', t('expectedDeclarationHash'))
42
+ .requiredOption('--expected-lock-hash <hash>', t('expectedLockHash'))
43
+ .action((names, options) => output(() => importBuiltinProjectSkills(resolve(options.dir), { ...options, names })));
39
44
  project.command('reconcile').description(t('reconcile')).option('--dir <path>', t('directory'), '.')
40
45
  .action((options) => output(() => reconcileLocalProjectSkills(resolve(options.dir), { write: true })));
41
46
  project.command('install').description(t('install')).requiredOption('--frozen', t('frozen')).option('--dir <path>', t('directory'), '.')
@@ -3,7 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile, lstat } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { projectSkillsMessage as t } from './messages.js';
6
- import { buildLocalProjectSkillsSnapshot, canonicalProjectSkillsJson, extractBundleArchive, createCanonicalBundleArchive, readProjectSkillSource, sourceTreeDigest, readProjectSkillsLockFile, inspectProjectSkills, setProjectSkillEnabled, validateProjectSkillsSnapshot, effectiveProjectSkills, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
6
+ import { buildLocalProjectSkillsSnapshot, canonicalProjectSkillsJson, extractBundleArchive, createCanonicalBundleArchive, readProjectSkillSource, sourceTreeDigest, readProjectSkillsLockFile, inspectProjectSkills, setProjectSkillEnabled, validateProjectSkillsSnapshot, declarationDigest, effectiveProjectSkills, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
7
7
  export const projectSkillsCache = () => process.env.PLAYCRAFT_SKILLS_CACHE_DIR
8
8
  ? resolve(process.env.PLAYCRAFT_SKILLS_CACHE_DIR) : join(homedir(), '.playcraft', 'skill-artifacts');
9
9
  export async function readLocalProjectSkillSnapshot(directory) {
@@ -35,6 +35,78 @@ async function writeSnapshot(directory, snapshot) {
35
35
  await rename(temporary, target);
36
36
  }
37
37
  }
38
+ /** Initializes an unmanaged checkout with one complete source and release snapshot under the existing writer lease. */
39
+ export async function initializeLocalProjectSkills(directory, cache, options) {
40
+ return change(directory, async () => {
41
+ if (await readProjectSkillsLockFile(directory) !== null)
42
+ throw new Error('skills_revision_conflict');
43
+ const declarationPath = join(directory, PROJECT_SKILLS_DECLARATION_PATH);
44
+ let originalDeclaration;
45
+ try {
46
+ const stat = await lstat(declarationPath);
47
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
48
+ throw new Error('workspace_skills_invalid');
49
+ originalDeclaration = await readFile(declarationPath);
50
+ }
51
+ catch (error) {
52
+ if (error.code !== 'ENOENT')
53
+ throw error;
54
+ }
55
+ let configurationWriteStarted = false;
56
+ try {
57
+ await options.prepareSources?.();
58
+ const snapshot = buildLocalProjectSkillsSnapshot(await inspectProjectSkills(directory));
59
+ for (const entry of snapshot.declaration.entries)
60
+ if (entry.source.kind === 'repository' && options.enabledRoots.has(entry.source.root))
61
+ entry.enabled = true;
62
+ if (options.platform) {
63
+ const platform = validateProjectSkillsSnapshot(options.platform.declaration, options.platform.lock);
64
+ if (platform.declaration.entries.some(entry => entry.source.kind !== 'registry'))
65
+ throw new Error('workspace_skills_invalid');
66
+ snapshot.declaration.entries.push(...platform.declaration.entries);
67
+ snapshot.lock.entries.push(...platform.lock.entries);
68
+ }
69
+ snapshot.lock.declarationHash = declarationDigest(snapshot.declaration);
70
+ validateProjectSkillsSnapshot(snapshot.declaration, snapshot.lock);
71
+ await mkdir(cache, { recursive: true, mode: 0o700 });
72
+ // Every artifact is ready before the first configuration write; an input/output failure cannot leave a valid subset lock.
73
+ for (const artifact of [...snapshot.artifacts, ...(options.platform?.artifacts ?? [])]) {
74
+ if (createHash('sha256').update(artifact.archive).digest('hex') !== artifact.artifactHash)
75
+ throw new Error('workspace_skills_digest_mismatch');
76
+ const target = join(cache, `${artifact.artifactHash}.tar.gz`), temporary = `${target}.${randomUUID()}.tmp`;
77
+ try {
78
+ await writeFile(temporary, artifact.archive, { flag: 'wx', mode: 0o600 });
79
+ await rename(temporary, target);
80
+ }
81
+ finally {
82
+ await rm(temporary, { force: true });
83
+ }
84
+ }
85
+ configurationWriteStarted = true;
86
+ await writeSnapshot(directory, snapshot);
87
+ return { declaration: snapshot.declaration, lock: snapshot.lock };
88
+ }
89
+ catch (error) {
90
+ if (configurationWriteStarted) {
91
+ if (originalDeclaration === undefined)
92
+ await rm(declarationPath, { force: true });
93
+ else {
94
+ const temporary = `${declarationPath}.${randomUUID()}.tmp`;
95
+ try {
96
+ await writeFile(temporary, originalDeclaration, { flag: 'wx', mode: 0o644 });
97
+ await rename(temporary, declarationPath);
98
+ }
99
+ finally {
100
+ await rm(temporary, { force: true });
101
+ }
102
+ }
103
+ await rm(join(directory, PROJECT_SKILLS_LOCK_PATH), { force: true });
104
+ }
105
+ await options.rollbackSources?.();
106
+ throw error;
107
+ }
108
+ });
109
+ }
38
110
  export async function syncLocalProjectSkills(directory, cache = projectSkillsCache()) {
39
111
  return change(directory, async () => {
40
112
  let previous;
@@ -1,5 +1,9 @@
1
1
  const messages = {
2
2
  en: {
3
+ importBuiltins: 'Import published platform skills in Cloud Workspace; preserve exact existing revisions',
4
+ platformRequired: 'Use Cloud Workspace’s Skills panel to import published platform skills. Local template import is unavailable.',
5
+ expectedDeclarationHash: 'Expected declaration digest from the current skill lock',
6
+ expectedLockHash: 'Expected digest of the canonical current skill lock',
3
7
  reconcile: 'Register the pushed commit and adopt verified platform skill identities',
4
8
  reconcileChanged: 'The branch, source bytes, or settings changed. Sync, commit and push the current project before reconciling.',
5
9
  reconcilePending: 'Indexing continues on the server. Retry reconcile later. Operation:',
@@ -33,6 +37,10 @@ const messages = {
33
37
  changed: 'Source files have changed since the saved revision',
34
38
  },
35
39
  zh: {
40
+ importBuiltins: '在云工作区导入已发布平台技能,保留已有精确修订',
41
+ platformRequired: '请在云工作区的技能面板导入已发布平台技能;本地模板导入已停用。',
42
+ expectedDeclarationHash: '当前技能锁中的声明摘要',
43
+ expectedLockHash: '当前技能锁规范化内容的摘要',
36
44
  reconcile: '登记已推送的提交,并采用平台核验后的技能身份',
37
45
  reconcileChanged: '分支、源文件或配置发生变化,请同步、提交并推送当前项目后再对账。',
38
46
  reconcilePending: '服务端仍在登记,请稍后重新运行 reconcile。操作编号:',
@@ -0,0 +1,40 @@
1
+ const messages = {
2
+ en: {
3
+ topic: 'Record a playable with real browser input', record: 'Record a scenario and fresh-context interaction controls',
4
+ url: 'Running playable URL on this workspace loopback interface', scenario: 'Validated scenario JSON file',
5
+ out: 'Empty artifact directory', browser: 'Absolute Chromium executable path', timeout: 'Maximum recording time in seconds',
6
+ invalidArguments: 'Invalid recording arguments or scenario.', invalidUrl: 'The playable URL must use HTTP on the workspace loopback interface without credentials.',
7
+ browserUnavailable: 'Chromium or the Playwright video encoder is unavailable. Check the workspace image.',
8
+ navigationFailed: 'The playable did not navigate successfully or left its authorized origin.',
9
+ projectChanged: 'Project inputs changed while recording. Record the current project again.',
10
+ projectUnavailable: 'The project input digest could not be completed safely.',
11
+ recordingFailed: 'Recording failed. Inspect the partial recording and runtime logs.',
12
+ outputConflict: 'The recording output directory must be empty.',
13
+ recording: 'Recording the playable and interaction controls',
14
+ timedOut: 'Recording timed out. Partial evidence was preserved.', interrupted: 'Recording was interrupted. Partial evidence was preserved.',
15
+ },
16
+ zh: {
17
+ topic: '使用真实浏览器输入录制可玩作品', record: '录制操作场景和使用独立空上下文的交互对照',
18
+ url: '当前工作区回环地址上的作品服务', scenario: '经过验证的操作场景文件', out: '空的证据输出目录',
19
+ browser: 'Chromium 可执行文件的绝对路径', timeout: '录制最长秒数', invalidArguments: '录制参数或场景无效。',
20
+ invalidUrl: '作品地址必须使用工作区回环接口的 HTTP 协议,不能包含凭据。',
21
+ browserUnavailable: 'Chromium 或 Playwright 视频编码器不可用,请检查工作区镜像。',
22
+ navigationFailed: '作品导航失败,或跳转离开了授权来源。', projectChanged: '录制时工程输入发生了变化,请基于当前工程重新录制。',
23
+ projectUnavailable: '无法安全完成工程输入摘要。', recordingFailed: '录制失败,请检查保留的局部录像和运行日志。',
24
+ outputConflict: '录制输出目录必须为空。', recording: '正在录制作品和交互对照',
25
+ timedOut: '录制超时,已保留局部证据。', interrupted: '录制已中断,已保留局部证据。',
26
+ },
27
+ };
28
+ export function recordingMessage(key) {
29
+ const language = process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en';
30
+ return messages[language.startsWith('zh') ? 'zh' : 'en'][key];
31
+ }
32
+ export class RecordingError extends Error {
33
+ code;
34
+ exitCode;
35
+ constructor(code, exitCode = 4) {
36
+ super(recordingMessage(code));
37
+ this.code = code;
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
4
+ import { basename, dirname, relative, resolve, sep } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { RecordingError } from './messages.js';
7
+ const execute = promisify(execFile);
8
+ const excludedNames = new Set(['.git', 'node_modules', '.DS_Store']);
9
+ const excludedRoots = new Set(['.agents', '.claude', '.codex', '.next', '.vite', '.cache', '.tmp', 'tmp', 'temp', 'dist', 'build', 'coverage', '.playcraft-preview-out', '.playcraft-temp']);
10
+ const excludedInternalPaths = ['.playcraft/video-replication', '.playcraft/skills', '.playcraft/skill-artifacts', '.playcraft/skills.json', '.playcraft/skills.lock.json', '.playcraft/workspace-context.json'];
11
+ const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
12
+ const canonicalPath = async (path) => {
13
+ const absolute = resolve(path);
14
+ try {
15
+ return await realpath(absolute);
16
+ }
17
+ catch {
18
+ const parent = dirname(absolute);
19
+ if (parent === absolute)
20
+ throw new RecordingError('projectUnavailable', 64);
21
+ return resolve(await canonicalPath(parent), basename(absolute));
22
+ }
23
+ };
24
+ /** Hash source, resources, configuration and dependency manifests, including untracked inputs. */
25
+ export async function computeProjectInputDigest(root, excludedOutputDirectories = []) {
26
+ try {
27
+ const actualRoot = await realpath(root);
28
+ const excluded = await Promise.all(excludedOutputDirectories.map(canonicalPath));
29
+ if (excluded.some(target => inside(target, actualRoot)))
30
+ throw new RecordingError('projectUnavailable', 64);
31
+ const hash = createHash('sha256');
32
+ let fileCount = 0;
33
+ let totalBytes = 0;
34
+ const walk = async (directory) => {
35
+ const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'));
36
+ for (const entry of entries) {
37
+ if (excludedNames.has(entry.name) || directory === actualRoot && excludedRoots.has(entry.name))
38
+ continue;
39
+ const path = resolve(directory, entry.name);
40
+ if (excluded.some(target => inside(target, path)))
41
+ continue;
42
+ const rel = relative(actualRoot, path).split(sep).join('/');
43
+ if (excludedInternalPaths.some(target => rel === target || rel.startsWith(`${target}/`)))
44
+ continue;
45
+ const before = await lstat(path);
46
+ if (before.isDirectory()) {
47
+ await walk(path);
48
+ continue;
49
+ }
50
+ if (before.isSymbolicLink()) {
51
+ const target = await realpath(path);
52
+ if (!inside(actualRoot, target) || (await lstat(target)).isDirectory())
53
+ throw new RecordingError('projectUnavailable', 64);
54
+ }
55
+ else if (!before.isFile())
56
+ throw new RecordingError('projectUnavailable', 64);
57
+ const file = await open(path, 'r');
58
+ try {
59
+ const initial = await file.stat();
60
+ totalBytes += initial.size;
61
+ if (++fileCount > 50_000 || totalBytes > 1024 * 1024 * 1024)
62
+ throw new RecordingError('projectUnavailable', 64);
63
+ const content = createHash('sha256');
64
+ for await (const bytes of file.createReadStream({ autoClose: false }))
65
+ content.update(bytes);
66
+ const after = await file.stat();
67
+ if (initial.size !== after.size || initial.mtimeMs !== after.mtimeMs || initial.ino !== after.ino)
68
+ throw new RecordingError('projectChanged', 64);
69
+ hash.update(`${JSON.stringify(rel)}:${initial.mode & 0o777}:${initial.size}:${content.digest('hex')}\n`);
70
+ }
71
+ finally {
72
+ await file.close();
73
+ }
74
+ }
75
+ };
76
+ await walk(actualRoot);
77
+ const gitCommit = await execute('git', ['-C', actualRoot, 'rev-parse', 'HEAD'], { timeout: 5000, maxBuffer: 1024, encoding: 'utf8' }).then(value => value.stdout.trim()).catch(() => null);
78
+ return { root: actualRoot, gitCommit: gitCommit && /^[a-f0-9]{40,64}$/.test(gitCommit) ? gitCommit : null, sha256: hash.digest('hex'), fileCount };
79
+ }
80
+ catch (error) {
81
+ if (error instanceof RecordingError)
82
+ throw error;
83
+ throw new RecordingError('projectUnavailable', 64);
84
+ }
85
+ }