@playcraft/cli 0.1.53-beta → 0.1.55-beta

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 (73) hide show
  1. package/README.md +21 -12
  2. package/dist/assets/client.js +157 -0
  3. package/dist/assets/messages.js +48 -0
  4. package/dist/cli-root-help.js +2 -0
  5. package/dist/commands/assets.js +123 -0
  6. package/dist/commands/image-layer-messages.js +37 -0
  7. package/dist/commands/image-layer-tools.js +185 -0
  8. package/dist/commands/image.js +2 -73
  9. package/dist/commands/library-messages.js +46 -0
  10. package/dist/commands/library.js +130 -0
  11. package/dist/index.js +6 -1
  12. package/dist/utils/agent-api-client.js +35 -0
  13. package/dist/workspace-runtime/server/http.js +26 -2
  14. package/dist/workspace-runtime/workspaces/directories.js +5 -2
  15. package/dist/workspace-runtime/workspaces/empty-template.js +1 -0
  16. package/dist/workspace-runtime/workspaces/prepare.js +6 -0
  17. package/dist/workspace-runtime/workspaces/worktree-attachments.js +74 -0
  18. package/dist/workspace-runtime/workspaces/worktree-checkout.js +67 -0
  19. package/dist/workspace-runtime/workspaces/worktrees.js +73 -0
  20. package/package.json +5 -4
  21. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
  22. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
  23. package/project-template/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
  24. package/project-template/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
  25. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
  26. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
  27. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
  28. package/project-template/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
  29. package/project-template/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
  30. package/project-template/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
  31. package/project-template/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
  32. package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
  33. package/project-template/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
  34. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
  35. package/project-template-v2/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
  36. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
  37. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
  38. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
  39. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
  40. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
  41. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
  42. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
  43. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
  44. package/project-template-v2/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
  45. package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
  46. package/project-template-v2/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
  47. package/project-templates/empty/AGENTS.md +12 -0
  48. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
  49. package/project-templates/phaser/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
  50. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
  51. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
  52. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
  53. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
  54. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
  55. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
  56. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
  57. package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
  58. package/project-templates/phaser/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
  59. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
  60. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
  61. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
  62. package/project-templates/threejs/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
  63. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
  64. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
  65. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
  66. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
  67. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
  68. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
  69. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
  70. package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
  71. package/project-templates/threejs/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
  72. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
  73. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'url';
4
4
  import sharp from 'sharp';
5
5
  import yaml from 'js-yaml';
6
6
  import { AgentApiClient } from '../utils/agent-api-client.js';
7
+ import { registerImageLayerTools } from './image-layer-tools.js';
7
8
  import { resolveSandboxPath } from '../utils/sandbox-path.js';
8
9
  /** 单文件输入上限(字节),防止误处理巨型文件导致 OOM */
9
10
  const DEFAULT_MAX_INPUT_BYTES = 512 * 1024 * 1024;
@@ -1853,79 +1854,7 @@ export function registerImageCommands(program) {
1853
1854
  handleError(e);
1854
1855
  }
1855
1856
  });
1856
- // ─── decompose-layers ──────────────────────────────────────────────────────
1857
- img
1858
- .command('decompose-layers')
1859
- .description([
1860
- '302 Qwen-Image-Layered 整图语义分层:将合成图拆成 N 个 RGBA 图层',
1861
- '',
1862
- ' 示例:',
1863
- ' playcraft image decompose-layers --input composite.png --output-dir ./layers',
1864
- ' playcraft image decompose-layers --input https://example.com/a.png --num-layers 4',
1865
- '',
1866
- ' 需 Admin 配置 provider=302、modelKind=image 的 API Key;耗时约 1–2 分钟。',
1867
- ' 本地文件需 storage 公网 URL,或 --input 使用 HTTPS 直链。',
1868
- ].join('\n'))
1869
- .requiredOption('--input <path>', '输入图片路径或 HTTPS URL')
1870
- .option('--output-dir <dir>', '输出目录', './decompose-layers-output')
1871
- .option('--num-layers <n>', '分层数量 2–10', cliParseInt, 4)
1872
- .option('--prompt <text>', '可选分层语义引导', '')
1873
- .option('--no-safety-checker', '关闭安全检测')
1874
- .option('--output-format <fmt>', '输出格式', 'png')
1875
- .action(async (opts) => {
1876
- try {
1877
- const payload = {
1878
- numLayers: opts.numLayers,
1879
- prompt: opts.prompt || '',
1880
- enableSafetyChecker: opts.safetyChecker !== false,
1881
- outputFormat: opts.outputFormat,
1882
- };
1883
- const input = opts.input;
1884
- const isUrl = input.startsWith('http://') || input.startsWith('https://');
1885
- if (isUrl) {
1886
- payload.imageUrl = input;
1887
- console.log(`Using image URL: ${input}`);
1888
- }
1889
- else {
1890
- const resolvedDecomposeInput = assertInputWithinLimit(input);
1891
- payload.imageBase64 = readFileSync(resolvedDecomposeInput).toString('base64');
1892
- console.log(`Uploading local image: ${input}`);
1893
- }
1894
- console.log(`Decomposing into ${payload.numLayers} layer(s) via 302 Qwen-Image-Layered (may take 1–2 min)…`);
1895
- const t0 = Date.now();
1896
- const client = new AgentApiClient();
1897
- const result = await client.post('/image-layered/decompose', payload);
1898
- const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1899
- const outputDir = opts.outputDir;
1900
- mkdirSync(outputDir, { recursive: true });
1901
- for (const layer of result.layers) {
1902
- const ext = layer.mimeType.includes('png') ? 'png' : 'png';
1903
- const filename = `layer_${String(layer.index).padStart(2, '0')}.${ext}`;
1904
- writeFileSync(join(outputDir, filename), Buffer.from(layer.imageBase64, 'base64'));
1905
- const dim = layer.width && layer.height ? ` ${layer.width}×${layer.height}` : '';
1906
- console.log(` ${filename}${dim}`);
1907
- }
1908
- const meta = {
1909
- source: input,
1910
- requestId: result.requestId,
1911
- numLayers: result.numLayers,
1912
- elapsed: `${elapsed}s`,
1913
- hasNsfwConcepts: result.hasNsfwConcepts,
1914
- layers: result.layers.map((l) => ({
1915
- index: l.index,
1916
- url: l.url,
1917
- width: l.width,
1918
- height: l.height,
1919
- seed: l.seed,
1920
- })),
1921
- };
1922
- writeFileSync(join(outputDir, 'decompose-layers-meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
1923
- console.log(`\nDone in ${elapsed}s — ${result.layers.length} layer(s) → ${outputDir}/`);
1924
- }
1925
- catch (e) {
1926
- handleError(e);
1927
- }
1928
- });
1857
+ registerImageLayerTools(img);
1929
1858
  // ─── segment ─────────────────────────────────────────────────────────────
1930
1859
  img
1931
1860
  .command('segment')
@@ -0,0 +1,46 @@
1
+ const messages = {
2
+ en: {
3
+ topic: 'Find and download authorized shared asset library content',
4
+ list: 'List library assets with filters and pagination', search: 'Search library names and content metadata',
5
+ get: 'Read asset metadata', files: 'List every file in an asset or bundle', facets: 'Inspect available categories and tags',
6
+ download: 'Download an asset or a complete bundle with relative dependencies',
7
+ query: 'Search text', id: 'Library asset identifier', type: 'Asset type', path: 'Library path prefix',
8
+ category: 'Category', subcategory: 'Subcategory', tags: 'Comma-separated tags',
9
+ visibility: 'public | team', team: 'Authorized team identifier', page: 'Page number, starting at 1',
10
+ limit: 'Results per page (1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size', order: 'asc | desc',
11
+ expand: 'Return individual files instead of collapsed bundles',
12
+ output: 'New file path, or a new directory for a bundle', outputDir: 'New directory for the downloaded content',
13
+ json: 'Print machine-readable JSON (JavaScript Object Notation, 数据交换格式; always enabled)',
14
+ invalidNumber: 'Expected a positive integer.', invalidId: 'Invalid library asset identifier.',
15
+ invalidManifest: 'The library returned an invalid or unsafe file manifest.',
16
+ outputRequired: 'Specify exactly one of --output or --output-dir.',
17
+ outputExists: 'The output already exists. Choose a new path to preserve existing files.',
18
+ failed: 'Asset library command failed.',
19
+ },
20
+ zh: {
21
+ topic: '检索和下载有权访问的共享素材库内容',
22
+ list: '按条件分页列出素材库内容', search: '检索素材名称和内容元数据',
23
+ get: '读取素材详情', files: '列出素材或素材包内的全部文件', facets: '查看可用分类与标签',
24
+ download: '下载素材或完整素材包,保留依赖文件的相对路径',
25
+ query: '检索文本', id: '素材库中的素材编号', type: '素材类型', path: '素材库路径前缀',
26
+ category: '分类', subcategory: '子分类', tags: '标签,逗号分隔',
27
+ visibility: 'public(公共)或 team(团队)', team: '有权访问的团队编号', page: '页码,从 1 开始',
28
+ limit: '每页数量(1–100)', sort: 'modifiedAt | createdAt | name | referenceCount | size', order: 'asc | desc',
29
+ expand: '返回单独文件,不折叠素材包', output: '新的文件路径;素材包则为新的目录', outputDir: '下载内容的新目录',
30
+ json: '输出机器可读的 JSON (JavaScript Object Notation, 数据交换格式),始终启用',
31
+ invalidNumber: '请输入正整数。', invalidId: '素材库编号无效。',
32
+ invalidManifest: '素材库返回的文件清单无效或包含不安全路径。',
33
+ outputRequired: '请指定 --output 或 --output-dir,且只指定一项。',
34
+ outputExists: '输出路径已存在,请选择新路径以保留已有文件。', failed: '素材库命令执行失败。',
35
+ },
36
+ };
37
+ export function libraryMessage(key) {
38
+ return messages[(process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en').startsWith('zh') ? 'zh' : 'en'][key];
39
+ }
40
+ export class LibraryError extends Error {
41
+ code;
42
+ constructor(code) {
43
+ super(libraryMessage(code));
44
+ this.code = code;
45
+ }
46
+ }
@@ -0,0 +1,130 @@
1
+ import { InvalidArgumentError, Option } from 'commander';
2
+ import { link, lstat, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join, posix, resolve } from 'node:path';
4
+ import { AgentApiClient } from '../utils/agent-api-client.js';
5
+ import { resolveSandboxPath } from '../utils/sandbox-path.js';
6
+ import { libraryMessage as t, LibraryError } from './library-messages.js';
7
+ function assetId(value) {
8
+ if (!/^(?:\d{1,20}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(value))
9
+ throw new InvalidArgumentError(t('invalidId'));
10
+ return value;
11
+ }
12
+ function positiveInteger(value) {
13
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
14
+ throw new InvalidArgumentError(t('invalidNumber'));
15
+ return value;
16
+ }
17
+ function filters(command) {
18
+ return command.option('--search <text>', t('query')).option('--type <type>', t('type'))
19
+ .option('--path <path>', t('path')).option('--category <category>', t('category'))
20
+ .option('--subcategory <category>', t('subcategory')).option('--tags <tags>', t('tags'))
21
+ .addOption(new Option('--visibility <scope>', t('visibility')).choices(['public', 'team']))
22
+ .option('--team-id <id>', t('team')).option('--page <number>', t('page'), positiveInteger, '1')
23
+ .option('--limit <number>', t('limit'), value => {
24
+ positiveInteger(value);
25
+ if (Number(value) > 100)
26
+ throw new InvalidArgumentError(t('limit'));
27
+ return value;
28
+ }, '30')
29
+ .addOption(new Option('--sort-by <field>', t('sort')).choices(['modifiedAt', 'createdAt', 'name', 'referenceCount', 'size']))
30
+ .addOption(new Option('--sort-order <order>', t('order')).choices(['asc', 'desc']))
31
+ .option('--expand-prefabs', t('expand')).option('--json', t('json'));
32
+ }
33
+ function query(options) {
34
+ return Object.fromEntries(Object.entries(options)
35
+ .filter(([key, value]) => key !== 'json' && value !== undefined && value !== false)
36
+ .map(([key, value]) => [key, String(value)]));
37
+ }
38
+ async function action(run) {
39
+ try {
40
+ console.log(JSON.stringify({ ok: true, result: await run() }, null, 2));
41
+ }
42
+ catch (error) {
43
+ const code = error instanceof LibraryError ? error.code : 'failed';
44
+ console.error(`${t('failed')} ${error instanceof Error ? error.message : String(error)}`);
45
+ console.log(JSON.stringify({ ok: false, error: { code, message: error instanceof Error ? error.message : String(error) } }));
46
+ process.exitCode = 1;
47
+ }
48
+ }
49
+ function validatedFiles(manifest) {
50
+ if (!manifest || !['file', 'bundle'].includes(manifest.kind) || !Array.isArray(manifest.files) ||
51
+ !manifest.files.length || (manifest.kind === 'file' && manifest.files.length !== 1))
52
+ throw new LibraryError('invalidManifest');
53
+ const seen = new Set();
54
+ for (const file of manifest.files) {
55
+ try {
56
+ assetId(file.id);
57
+ }
58
+ catch {
59
+ throw new LibraryError('invalidManifest');
60
+ }
61
+ const path = file.path;
62
+ if (typeof path !== 'string' || !path || path.startsWith('/') || /[\\:\x00-\x1f]/.test(path) ||
63
+ path.split('/').some(part => !part || part === '.' || part === '..') || posix.normalize(path) !== path)
64
+ throw new LibraryError('invalidManifest');
65
+ const key = path.toLowerCase();
66
+ if (seen.has(key))
67
+ throw new LibraryError('invalidManifest');
68
+ seen.add(key);
69
+ }
70
+ for (const key of seen) {
71
+ for (let parent = posix.dirname(key); parent !== '.'; parent = posix.dirname(parent)) {
72
+ if (seen.has(parent))
73
+ throw new LibraryError('invalidManifest');
74
+ }
75
+ }
76
+ return manifest.files;
77
+ }
78
+ export async function downloadLibraryAsset(client, id, options) {
79
+ if (Boolean(options.output) === Boolean(options.outputDir))
80
+ throw new LibraryError('outputRequired');
81
+ assetId(id);
82
+ const manifest = await client.get(`/asset-library/${encodeURIComponent(id)}/files`);
83
+ const files = validatedFiles(manifest);
84
+ const destination = resolve(resolveSandboxPath(options.output ?? options.outputDir));
85
+ const existing = await lstat(destination).catch(error => { if (error.code === 'ENOENT')
86
+ return undefined; throw error; });
87
+ if (existing)
88
+ throw new LibraryError('outputExists');
89
+ await mkdir(dirname(destination), { recursive: true });
90
+ const staging = await mkdtemp(join(dirname(destination), '.playcraft-library-'));
91
+ const directory = manifest.kind === 'bundle' || Boolean(options.outputDir);
92
+ const downloaded = [];
93
+ try {
94
+ for (const file of files) {
95
+ const target = directory ? join(staging, file.path) : join(staging, 'content');
96
+ const { bytes } = await client.download(`/asset-library/${encodeURIComponent(file.id)}/file`, target);
97
+ downloaded.push({ id: file.id, path: directory ? join(destination, file.path) : destination, bytes });
98
+ }
99
+ // Recheck before publishing the complete bundle; failures never expose a partial download.
100
+ if (await lstat(destination).catch(error => { if (error.code === 'ENOENT')
101
+ return undefined; throw error; }))
102
+ throw new LibraryError('outputExists');
103
+ if (directory)
104
+ await rename(staging, destination);
105
+ else
106
+ await link(join(staging, 'content'), destination);
107
+ return { assetId: manifest.assetId, kind: manifest.kind, output: destination, files: downloaded };
108
+ }
109
+ finally {
110
+ await rm(staging, { recursive: true, force: true });
111
+ }
112
+ }
113
+ export function registerLibraryCommands(program) {
114
+ const library = program.command('library').description(t('topic'));
115
+ filters(library.command('list').description(t('list')))
116
+ .action((options) => action(() => new AgentApiClient().get('/asset-library', query(options))));
117
+ filters(library.command('search').description(t('search')).argument('<query>', t('query')))
118
+ .action((search, options) => action(() => new AgentApiClient().get('/asset-library', query({ ...options, search }))));
119
+ library.command('facets').description(t('facets')).option('--path <path>', t('path'))
120
+ .addOption(new Option('--visibility <scope>', t('visibility')).choices(['public', 'team']))
121
+ .option('--team-id <id>', t('team')).option('--json', t('json'))
122
+ .action((options) => action(() => new AgentApiClient().get('/asset-library/facets', query(options))));
123
+ for (const command of ['get', 'files']) {
124
+ library.command(command).description(t(command)).argument('<assetId>', t('id'), assetId).option('--json', t('json'))
125
+ .action((id) => action(() => new AgentApiClient().get(`/asset-library/${encodeURIComponent(id)}${command === 'files' ? '/files' : ''}`)));
126
+ }
127
+ library.command('download').description(t('download')).argument('<assetId>', t('id'), assetId)
128
+ .option('--output <path>', t('output')).option('--output-dir <directory>', t('outputDir')).option('--json', t('json'))
129
+ .action((id, options) => action(() => downloadLibraryAsset(new AgentApiClient(), id, options)));
130
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ import { checkForUpdates } from './utils/version-checker.js';
18
18
  import { syncCommand } from './commands/sync.js';
19
19
  import { fixIdsCommand } from './commands/fix-ids.js';
20
20
  import { registerToolsCommands } from './commands/tools.js';
21
+ import { registerLibraryCommands } from './commands/library.js';
22
+ import { registerWorkspaceAssetCommands } from './commands/assets.js';
21
23
  import { registerImageCommands } from './commands/image.js';
22
24
  import { registerAudioCommands } from './commands/audio.js';
23
25
  import { registerPrefabCommands } from './commands/prefab.js';
@@ -40,6 +42,7 @@ const __dirname = dirname(__filename);
40
42
  const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
41
43
  const program = new Command();
42
44
  const structuredVideoTopic = ['video', 'playable'].includes(process.argv[2] ?? '');
45
+ const assetTopic = ['assets', 'library'].includes(process.argv[2] ?? '');
43
46
  const registryTopic = process.argv[2] === 'skills' && process.argv[3] === 'registry';
44
47
  if (structuredVideoTopic)
45
48
  program.exitOverride();
@@ -50,7 +53,7 @@ if (registryTopic)
50
53
  registerRootProgramHelp(program);
51
54
  // 版本检查(非阻塞,后台执行)
52
55
  // 只在非 upgrade 命令时检查,避免重复提示
53
- if (!structuredVideoTopic && !registryTopic && !process.argv.includes('upgrade') && !process.argv.includes('workspace-runtime')) {
56
+ if (!structuredVideoTopic && !assetTopic && !registryTopic && !process.argv.includes('upgrade') && !process.argv.includes('workspace-runtime')) {
54
57
  checkForUpdates(packageJson);
55
58
  }
56
59
  // create 命令
@@ -366,6 +369,8 @@ program
366
369
  });
367
370
  // Agent domain tools (playcraft tools <command>)
368
371
  registerToolsCommands(program);
372
+ registerLibraryCommands(program);
373
+ registerWorkspaceAssetCommands(program);
369
374
  // Local image processing (playcraft image <command>)
370
375
  registerImageCommands(program);
371
376
  // Local audio processing (playcraft audio <command>)
@@ -1,4 +1,9 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { link, mkdir, stat, unlink } from 'node:fs/promises';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
2
7
  import { dirname, join } from 'path';
3
8
  import { homedir } from 'os';
4
9
  import { loadGlobalConfig } from '../config.js';
@@ -216,6 +221,36 @@ export class AgentApiClient {
216
221
  }
217
222
  return res.json();
218
223
  }
224
+ /** Stream authenticated tool content to a new file; credentials never follow redirects. */
225
+ async download(path, destination, options) {
226
+ const url = this.toolsRequestUrl(path);
227
+ if (!url.pathname.startsWith(this.toolsRequestUrl('/').pathname))
228
+ throw new Error('invalid_tool_download_target');
229
+ const signal = options?.signal ?? AbortSignal.timeout(10 * 60_000);
230
+ const response = await fetch(url, { headers: this.authHeaders(), redirect: 'error', signal });
231
+ if (!response.ok) {
232
+ const body = await response.text().catch(() => response.statusText);
233
+ const pending = pendingAccountActivationError(response.status, parseErrorPayload(body));
234
+ if (pending)
235
+ throw pending;
236
+ throw new Error(`tool_download_failed:${response.status}: ${responseErrorReason(body)}`);
237
+ }
238
+ if (!response.body)
239
+ throw new Error('tool_download_empty_response');
240
+ await mkdir(dirname(destination), { recursive: true });
241
+ const temporary = join(dirname(destination), `.playcraft-download-${randomUUID()}.tmp`);
242
+ try {
243
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary, { flags: 'wx', mode: 0o644 }), { signal });
244
+ const bytes = (await stat(temporary)).size;
245
+ // A hard link publishes the completed file without replacing existing user content.
246
+ await link(temporary, destination);
247
+ return { bytes };
248
+ }
249
+ finally {
250
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
251
+ throw error; });
252
+ }
253
+ }
219
254
  /** Stream only to the authenticated, same-service video upload endpoint. */
220
255
  async putVideoMedia(path, body, headers, signal) {
221
256
  if (!/^\/video-media\/[a-f0-9-]{36}\/content\?/.test(path))
@@ -3,7 +3,7 @@ import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.j
3
3
  import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
4
4
  import { constants } from 'node:fs';
5
5
  import { access as accessDirectory, stat } from 'node:fs/promises';
6
- import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, deliveryValidatePayloadSchema, deliveryBuildListQuerySchema, deliveryBuildListSchema, deliveryBuildDetailSchema, deliveryBuildLogSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, deliveryValidatePayloadSchema, deliveryBuildListQuerySchema, deliveryBuildListSchema, deliveryBuildDetailSchema, deliveryBuildLogSchema, workspaceDirectoryNameSchema, attachmentIdsSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { controlTokenMatches, extractControlToken } from './auth.js';
8
8
  import { applyRuntimeEndpointPatch } from './endpoints.js';
9
9
  import { wakeSourceEventUploader } from './source-uploader.js';
@@ -43,6 +43,7 @@ import { handleWorkspaceTransfer } from './workspace-transfer.js';
43
43
  import { serveSessionImage } from '../workspaces/session-image.js';
44
44
  import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
45
45
  import { handleAssetTransferRequest } from './asset-transfers.js';
46
+ import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktrees.js';
46
47
  export async function handleRuntimeRequest(state, request, response) {
47
48
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
48
49
  const directoryName = url.searchParams.get('directoryName') ?? 'basic';
@@ -202,7 +203,30 @@ export async function handleRuntimeRequest(state, request, response) {
202
203
  writeJson(response, 409, { error: 'invalid_workspace_registration' });
203
204
  return;
204
205
  }
205
- writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
206
+ if (body.worktree === true) {
207
+ const source = workspaceDirectoryNameSchema.safeParse(body.sourceDirectoryName);
208
+ const attachments = attachmentIdsSchema.safeParse(body.attachmentIds ?? []);
209
+ if (!source.success || !attachments.success || typeof body.sourceBranchName !== 'string') {
210
+ writeJson(response, 400, { error: 'invalid_workspace_registration' });
211
+ return;
212
+ }
213
+ writeJson(response, 200, await registerChatWorktree(state, body.directory, Number(body.leaseFence), source.data, body.sourceBranchName, attachments.data));
214
+ }
215
+ else
216
+ writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
217
+ return;
218
+ }
219
+ if (request.method === 'POST' && url.pathname === '/runtime/workspaces/directories/worktree-source') {
220
+ if (writeDestroyed(state, response))
221
+ return;
222
+ const body = asRecord(await readJson(request));
223
+ const source = workspaceDirectoryNameSchema.safeParse(body.sourceDirectoryName);
224
+ if (body.generation !== state.config.generation || body.runtimeEpoch !== state.runtimeEpoch || !Number.isSafeInteger(body.leaseFence)
225
+ || !source.success || typeof body.workspaceId !== 'string' || typeof body.sourceBranchName !== 'string') {
226
+ writeJson(response, 409, { error: 'invalid_workspace_registration' });
227
+ return;
228
+ }
229
+ writeJson(response, 200, await readWorktreeSource(state, body.workspaceId, source.data, Number(body.leaseFence), body.sourceBranchName));
206
230
  return;
207
231
  }
208
232
  const maintenance = /^\/runtime\/workspaces\/([a-f0-9-]{36})\/directories\/(cleanup|sync|stats)$/.exec(url.pathname);
@@ -13,9 +13,10 @@ import { ensureWorkspaceSkills } from './skills.js';
13
13
  import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
14
14
  import { ensureWorkspaceArtifactInstructions } from './instructions.js';
15
15
  import { shareWorkspaceRepository } from './git.js';
16
+ import { checkoutChatWorktree } from './worktree-checkout.js';
16
17
  const execute = promisify(execFile);
17
18
  /** Trusted Runtime registration restores immutable input; it never follows basic's current branch head. */
18
- export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
19
+ export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName, worktree = false) {
19
20
  const directory = workspaceDirectoryRegistrationSchema.parse(input);
20
21
  if (directory.directoryName === 'basic')
21
22
  throw new WorkspaceContextError('workspace_directory_binding_conflict');
@@ -51,7 +52,9 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
51
52
  try {
52
53
  const source = await workspaceRoot(state, directory.workspaceId, sourceDirectoryName ?? 'basic');
53
54
  await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
54
- if (sourceDirectoryName !== undefined) {
55
+ if (worktree)
56
+ await checkoutChatWorktree(source, staging, directory.sourceCommit, state.config.stateDir);
57
+ else if (sourceDirectoryName !== undefined) {
55
58
  const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
56
59
  commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
57
60
  await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
@@ -11,6 +11,7 @@ export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
11
11
  'playcraft-image-generation',
12
12
  'playcraft-audio-generation',
13
13
  'playcraft-image-processing',
14
+ 'playcraft-workspace-assets',
14
15
  'playcraft-build-optimizer',
15
16
  ];
16
17
  export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
@@ -4,6 +4,7 @@ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs
4
4
  import { join } from 'node:path';
5
5
  import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, workspaceDirectoryNameSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { WorkspaceContextError } from './context-error.js';
7
+ import { openDirectoryToDevelopment } from './directory-access.js';
7
8
  import { workspacePlatformSkillAccess } from './skill-artifact-loader.js';
8
9
  import { scaffoldEmptyWorkspace } from './empty-template.js';
9
10
  const SKILL_PREPARATION_ERRORS = new Set([
@@ -164,6 +165,11 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
164
165
  }
165
166
  await mkdir(workspace, { recursive: true, mode: 0o700 });
166
167
  await rename(staging, target);
168
+ // The terminal and Codex run as the development user, so a checkout only the control user can
169
+ // traverse makes every command fail with EACCES on its own cwd. The backend's root repair is
170
+ // skipped when it reuses a container whose binding is unchanged, so a re-prepared tree (fresh
171
+ // volume, reclaimed checkout) has to open itself to the development group here.
172
+ await openDirectoryToDevelopment(workspace, (await lstat(parent)).gid);
167
173
  receipt.status = 'published';
168
174
  await writeReceipt(receiptPath, receipt);
169
175
  }
@@ -0,0 +1,74 @@
1
+ import { constants } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { lstat, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, relative } from 'node:path';
5
+ import { attachmentIdsSchema, WORKSPACE_USER_UPLOADS_DIRECTORY } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { resolveWorkspaceAttachments } from './attachments.js';
7
+ import { excludeFromGit } from './dependencies.js';
8
+ async function safeParents(root, path, create = false) {
9
+ let current = root;
10
+ for (const segment of path.split('/')) {
11
+ if (!segment || segment === '.' || segment === '..')
12
+ throw new Error('workspace_worktree_attachment_unavailable');
13
+ current = join(current, segment);
14
+ if (create)
15
+ await mkdir(current, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
16
+ throw error; });
17
+ const stat = await lstat(current);
18
+ if (!stat.isDirectory() || stat.isSymbolicLink())
19
+ throw new Error('workspace_worktree_attachment_unavailable');
20
+ }
21
+ }
22
+ async function readSafe(root, path, max) {
23
+ await safeParents(root, dirname(path));
24
+ const handle = await open(join(root, path), constants.O_RDONLY | constants.O_NOFOLLOW);
25
+ try {
26
+ const stat = await handle.stat();
27
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > max)
28
+ throw new Error('workspace_worktree_attachment_unavailable');
29
+ return await handle.readFile();
30
+ }
31
+ finally {
32
+ await handle.close();
33
+ }
34
+ }
35
+ async function copyExact(source, target, path, limit) {
36
+ const body = await readSafe(source, path, limit);
37
+ await safeParents(target, dirname(path), true);
38
+ const existing = await lstat(join(target, path)).catch(() => undefined);
39
+ if (existing) {
40
+ const previous = await readSafe(target, path, limit);
41
+ if (!createHash('sha256').update(previous).digest().equals(createHash('sha256').update(body).digest()))
42
+ throw new Error('workspace_worktree_attachment_conflict');
43
+ return;
44
+ }
45
+ const temporary = `${join(target, path)}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(temporary, body, { flag: 'wx', mode: 0o644 });
48
+ await rename(temporary, join(target, path));
49
+ }
50
+ finally {
51
+ await rm(temporary, { force: true });
52
+ }
53
+ }
54
+ /** Copy only the selected, fully validated attachment files; retries never replace different content. */
55
+ export async function copyWorktreeAttachments(source, target, input) {
56
+ const ids = [...new Set(attachmentIdsSchema.parse(input ?? []))];
57
+ if (!ids.length)
58
+ return;
59
+ const attachments = await resolveWorkspaceAttachments(source, ids);
60
+ await excludeFromGit(target, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
61
+ for (const attachment of attachments) {
62
+ const prefix = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${attachment.id}/`;
63
+ const files = attachment.files
64
+ ? attachment.files.map(file => ({ path: `${prefix}${attachment.kind === 'directory' ? `${attachment.name}/` : ''}${file.path}`, size: file.size }))
65
+ : [{ path: relative(source, attachment.path), size: attachment.size }];
66
+ for (const file of files) {
67
+ if (!file.path.startsWith(prefix) || file.path.split('/').some(segment => segment === '..' || !segment))
68
+ throw new Error('workspace_worktree_attachment_unavailable');
69
+ await copyExact(source, target, file.path, file.size);
70
+ }
71
+ await copyExact(source, target, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata/${attachment.id}.json`, 1024 * 1024);
72
+ }
73
+ await resolveWorkspaceAttachments(target, ids);
74
+ }
@@ -0,0 +1,67 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, mkdir, open, realpath, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
8
+ import { readWorkspaceSkills } from './skills.js';
9
+ const execute = promisify(execFile);
10
+ async function archiveDirectory(root) {
11
+ let directory = root;
12
+ for (const segment of ['.playcraft', 'skill-artifacts']) {
13
+ directory = join(directory, segment);
14
+ await mkdir(directory, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
15
+ throw error; });
16
+ const stat = await lstat(directory);
17
+ if (!stat.isDirectory() || stat.isSymbolicLink())
18
+ throw new Error('workspace_worktree_skill_archive_unavailable');
19
+ }
20
+ }
21
+ /** Clone the saved commit and its exact skill archives, without copying unsaved files or credentials. */
22
+ export async function checkoutChatWorktree(source, destination, commit, stateDir) {
23
+ const options = { timeout: 90_000, maxBuffer: 8 * 1024 * 1024,
24
+ env: { PATH: process.env.PATH, HOME: stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } };
25
+ const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], options)).stdout;
26
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, destination], options);
27
+ await git(destination, 'checkout', '--detach', commit);
28
+ const management = await lstat(join(destination, '.playcraft')).catch(() => undefined);
29
+ if (management && (!management.isDirectory() || management.isSymbolicLink()))
30
+ throw new Error('workspace_worktree_skill_archive_unavailable');
31
+ const lock = await lstat(join(destination, '.playcraft/skills.lock.json')).catch(() => undefined);
32
+ if (!lock)
33
+ return;
34
+ const snapshot = validateProjectSkillsSnapshot(await git(destination, 'show', `${commit}:.playcraft/skills.json`), await git(destination, 'show', `${commit}:.playcraft/skills.lock.json`));
35
+ const copied = new Set();
36
+ for (const entry of snapshot.lock.entries) {
37
+ const path = `.playcraft/skill-artifacts/${entry.revision.artifactHash}.tar.gz`;
38
+ if (copied.has(path))
39
+ continue;
40
+ copied.add(path);
41
+ const saved = join(destination, path);
42
+ if (await lstat(saved).catch(() => undefined))
43
+ throw new Error('workspace_worktree_skill_archive_tracked');
44
+ const original = join(source, path);
45
+ if (!await lstat(original).catch(() => undefined))
46
+ continue;
47
+ if (await realpath(dirname(original)).catch(() => '') !== join(source, '.playcraft/skill-artifacts'))
48
+ throw new Error('workspace_worktree_skill_archive_unavailable');
49
+ const handle = await open(original, constants.O_RDONLY | constants.O_NOFOLLOW);
50
+ let archive;
51
+ try {
52
+ const stat = await handle.stat();
53
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 80 * 1024 * 1024)
54
+ throw new Error('workspace_worktree_skill_archive_unavailable');
55
+ archive = await handle.readFile();
56
+ }
57
+ finally {
58
+ await handle.close();
59
+ }
60
+ if (createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
61
+ throw new Error('workspace_worktree_skill_archive_unavailable');
62
+ await archiveDirectory(destination);
63
+ await writeFile(saved, archive, { flag: 'wx', mode: 0o644 });
64
+ }
65
+ // Existing locked repository bytes and the exact runtime cache can fill missing archives.
66
+ await readWorkspaceSkills(destination, join(stateDir, 'skill-artifacts'));
67
+ }