@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
@@ -0,0 +1,87 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { remoteSkillMatchResultSchema } from '@playcraft/common/project-skills';
5
+ import { RemoteSkillClient } from './remote-client.js';
6
+ import { prepareRemoteSkillSelection } from './remote-prepare.js';
7
+ import { findManagedSkillsRoot, normalizeSkillEngine } from './search.js';
8
+ import { printSkillSearchError } from './search-command.js';
9
+ import { projectSkillsMessage as t } from './messages.js';
10
+ export async function matchRemoteSkills(options) {
11
+ const limit = Number(options.limit ?? 20);
12
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
13
+ throw new Error(t('searchInvalidLimit'));
14
+ const engine = options.engine ? normalizeSkillEngine(options.engine) : undefined;
15
+ if (engine !== undefined && engine !== 'phaser' && engine !== 'threejs')
16
+ throw new Error(t('remoteInvalidEngine'));
17
+ const result = await new RemoteSkillClient({ cwd: resolve(options.dir), url: options.url }).match({ query: options.intent, engine, limit });
18
+ if (options.json)
19
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
20
+ else {
21
+ console.log(t('remoteSource'));
22
+ console.log(result.total ? `${t('searchSummary')} ${result.total}` : t('remoteNoMatches'));
23
+ for (const skill of result.items) {
24
+ console.log(` ${skill.skillRef}@${skill.version} ${skill.name}`);
25
+ if (skill.description)
26
+ console.log(` ${skill.description}`);
27
+ if (skill.matchedTerms.length)
28
+ console.log(` ${skill.matchedTerms.join(', ')}`);
29
+ }
30
+ console.log(t('remotePrepareHint'));
31
+ }
32
+ return result;
33
+ }
34
+ async function readMatchReceipt(path) {
35
+ const file = await open(resolve(path), constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ try {
37
+ const stat = await file.stat();
38
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024)
39
+ throw new Error(t('remoteInvalidMatchFile'));
40
+ return remoteSkillMatchResultSchema.parse(JSON.parse(await file.readFile('utf8')));
41
+ }
42
+ finally {
43
+ await file.close();
44
+ }
45
+ }
46
+ export function registerRemoteSkillPrepareCommand(skills) {
47
+ skills.command('prepare').description(t('remotePrepare'))
48
+ .requiredOption('--from-match <path>', t('remoteMatchFile'))
49
+ .requiredOption('--skills <refs>', t('remoteSelectedRefs'))
50
+ .option('--dir <path>', t('directory'), '.')
51
+ .option('--url <url>', t('remoteUrl'))
52
+ .option('--prepare-only', t('remotePrepareOnly'))
53
+ .option('--json', t('json'))
54
+ .action(async (options) => {
55
+ try {
56
+ const match = await readMatchReceipt(options.fromMatch);
57
+ const refs = [...new Set(options.skills.split(',').map(ref => ref.trim()).filter(Boolean))];
58
+ if (!refs.length || refs.length > 50)
59
+ throw new Error(t('remoteInvalidSelection'));
60
+ const selected = refs.map(ref => {
61
+ const candidates = match.items.filter(skill => skill.skillRef === ref || `${skill.skillRef}@${skill.version}` === ref);
62
+ if (candidates.length !== 1)
63
+ throw new Error(`${t('remoteInvalidSelection')}: ${ref}`);
64
+ const skill = candidates[0];
65
+ return { skillRef: skill.skillRef, skillVersionId: skill.skillVersionId, version: skill.version, artifactHash: skill.artifactHash };
66
+ });
67
+ const engine = match.engine === 'phaser' || match.engine === 'threejs' ? match.engine : undefined;
68
+ const client = new RemoteSkillClient({ cwd: resolve(options.dir), url: options.url });
69
+ const selection = await client.select({ skills: selected, engine, catalogHash: match.catalogHash });
70
+ const root = client.hasExecutionBridge() ? await findManagedSkillsRoot(options.dir) ?? resolve(options.dir) : resolve(options.dir);
71
+ const prepared = await prepareRemoteSkillSelection(root, selection, entry => client.artifact(selection, entry));
72
+ const activation = options.prepareOnly ? undefined : await client.activate(selection.selectionId);
73
+ const result = activation ? { ...prepared, status: activation.status, activation } : prepared;
74
+ if (options.json)
75
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
76
+ else {
77
+ console.log(t('remotePrepared'));
78
+ console.log(result.directory);
79
+ console.log(t(activation?.status === 'activation_requested' ? 'remoteActivationRequested'
80
+ : activation?.status === 'already_active' ? 'remoteAlreadyActive' : 'remoteActivationRequired'));
81
+ }
82
+ }
83
+ catch (error) {
84
+ printSkillSearchError(error, options.json);
85
+ }
86
+ });
87
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { canonicalProjectSkillsJson, parseRemoteSkillSelectionResult, projectSkillsDigest, } from '@playcraft/common/project-skills';
6
+ import { ensureProjectSkillDirectory, readLockedProjectSkillCatalog } from './discovery.js';
7
+ import { excludeFromGit } from '../workspace-runtime/workspaces/dependencies.js';
8
+ const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
9
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
10
+ const receiptName = 'selection.json';
11
+ const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000);
12
+ async function readPreparationLease(path) {
13
+ let handle;
14
+ try {
15
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ throw error;
20
+ throw new Error('remote_skill_selection_lease_invalid');
21
+ }
22
+ try {
23
+ const stat = await handle.stat();
24
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 2048)
25
+ throw new Error('remote_skill_selection_lease_invalid');
26
+ const bytes = await handle.readFile();
27
+ let record;
28
+ try {
29
+ record = JSON.parse(bytes.toString('utf8'));
30
+ }
31
+ catch {
32
+ throw new Error('remote_skill_selection_lease_invalid');
33
+ }
34
+ if (!record || record.version !== 1 || !/^[a-f0-9]{64}$/.test(record.selectionId)
35
+ || !Number.isSafeInteger(record.pid) || record.pid < 1 || record.pid > 2_147_483_647
36
+ || !Number.isSafeInteger(record.processStartedAt) || record.processStartedAt < 1
37
+ || typeof record.nonce !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(record.nonce))
38
+ throw new Error('remote_skill_selection_lease_invalid');
39
+ return { bytes, ino: stat.ino, dev: stat.dev, record };
40
+ }
41
+ finally {
42
+ await handle.close();
43
+ }
44
+ }
45
+ function preparationOwnerExited(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return false;
49
+ }
50
+ catch (error) {
51
+ return error.code === 'ESRCH';
52
+ }
53
+ }
54
+ const samePreparationLease = (left, right) => left.ino === right.ino && left.dev === right.dev && left.bytes.equals(right.bytes);
55
+ async function ownsPreparationLease(path, expected) {
56
+ try {
57
+ return samePreparationLease(await readPreparationLease(path), expected);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ async function retirePreparationLease(path, expected, mayRemove = () => true) {
64
+ if (!await ownsPreparationLease(path, expected))
65
+ return false;
66
+ const retired = `${path}.${randomUUID()}.retired`;
67
+ try {
68
+ await rename(path, retired);
69
+ }
70
+ catch (error) {
71
+ if (error.code === 'ENOENT')
72
+ return false;
73
+ throw error;
74
+ }
75
+ if (await ownsPreparationLease(retired, expected) && mayRemove()) {
76
+ await rm(retired);
77
+ return true;
78
+ }
79
+ // Exclusive restoration never overwrites a concurrently created lease. If another
80
+ // owner claimed the original path, retain these unknown bytes under the retired name.
81
+ try {
82
+ await link(retired, path);
83
+ await rm(retired);
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'EEXIST')
87
+ throw error;
88
+ }
89
+ return false;
90
+ }
91
+ async function acquirePreparationLease(path, selectionId) {
92
+ const create = async () => {
93
+ const handle = await open(path, 'wx', 0o600);
94
+ const record = { version: 1, selectionId, pid: process.pid,
95
+ processStartedAt: PROCESS_STARTED_AT, nonce: randomUUID() };
96
+ const bytes = Buffer.from(JSON.stringify(record));
97
+ try {
98
+ await handle.writeFile(bytes);
99
+ const stat = await handle.stat();
100
+ return { handle, bytes, record, ino: stat.ino, dev: stat.dev };
101
+ }
102
+ catch (error) {
103
+ await handle.close();
104
+ // A partially written lease has no proof of ownership and remains fail-closed.
105
+ throw error;
106
+ }
107
+ };
108
+ try {
109
+ return await create();
110
+ }
111
+ catch (error) {
112
+ if (error.code !== 'EEXIST')
113
+ throw error;
114
+ }
115
+ const previous = await readPreparationLease(path);
116
+ if (previous.record.selectionId !== selectionId)
117
+ throw new Error('remote_skill_selection_lease_invalid');
118
+ if (!preparationOwnerExited(previous.record.pid))
119
+ throw new Error('remote_skill_selection_busy');
120
+ if (!await retirePreparationLease(path, previous, () => preparationOwnerExited(previous.record.pid)))
121
+ throw new Error('remote_skill_selection_lease_changed');
122
+ try {
123
+ return await create();
124
+ }
125
+ catch (error) {
126
+ if (error.code === 'EEXIST')
127
+ throw new Error('remote_skill_selection_busy');
128
+ throw error;
129
+ }
130
+ }
131
+ async function exists(path) {
132
+ return lstat(path).then(stat => stat, (error) => {
133
+ if (error.code === 'ENOENT')
134
+ return undefined;
135
+ throw error;
136
+ });
137
+ }
138
+ function identity(selection) {
139
+ return projectSkillsDigest({ declaration: selection.declaration, lock: selection.lock,
140
+ skills: selection.skills.map(({ skillRef, skillVersionId, version, artifactHash }) => ({ skillRef, skillVersionId, version, artifactHash })).sort((a, b) => a.skillRef.localeCompare(b.skillRef)) });
141
+ }
142
+ async function readReceipt(directory) {
143
+ const stat = await lstat(directory);
144
+ if (!stat.isDirectory() || stat.isSymbolicLink())
145
+ throw new Error('remote_skill_selection_path_invalid');
146
+ const receipt = join(directory, receiptName);
147
+ const file = await lstat(receipt);
148
+ if (!file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || file.size > MAX_RECEIPT_BYTES)
149
+ throw new Error('remote_skill_selection_path_invalid');
150
+ return parseRemoteSkillSelectionResult(JSON.parse(await readFile(receipt, 'utf8')));
151
+ }
152
+ async function prepared(directory, selection) {
153
+ const saved = await readReceipt(directory);
154
+ if (identity(saved) !== identity(selection))
155
+ throw new Error('remote_skill_selection_conflict');
156
+ const catalog = await readLockedProjectSkillCatalog(directory, join(directory, '.playcraft/skill-artifacts'));
157
+ if (catalog.lockHash !== selection.lockHash || catalog.skills.length !== selection.lock.entries.length)
158
+ throw new Error('remote_skill_selection_conflict');
159
+ return { status: 'prepared_requires_activation', executionReady: false,
160
+ selectionId: selection.selectionId, lockHash: selection.lockHash, directory,
161
+ skills: catalog.skills.map(({ entryKey, skillRef, revision, artifactHash, name, description, skillDir, path }) => ({ entryKey, skillRef, revision, artifactHash, name, description, skillDir, path })) };
162
+ }
163
+ /** Downloads a separate exact selection; never changes the project's active declaration or lock. */
164
+ export async function prepareRemoteSkillSelection(directory, input, loadArtifact) {
165
+ const selection = parseRemoteSkillSelectionResult(input);
166
+ if (!selection.lock.entries.length || selection.declaration.entries.some(entry => entry.source.kind !== 'registry' || !entry.enabled || entry.surface !== 'sandbox-agent'))
167
+ throw new Error('invalid_remote_skill_selection');
168
+ const selectedBytes = canonicalProjectSkillsJson(selection);
169
+ if (Buffer.byteLength(selectedBytes) > MAX_RECEIPT_BYTES)
170
+ throw new Error('remote_skill_selection_too_large');
171
+ const suppliedRoot = resolve(directory);
172
+ const stat = await lstat(suppliedRoot);
173
+ if (!stat.isDirectory() || stat.isSymbolicLink())
174
+ throw new Error('remote_skill_selection_path_invalid');
175
+ const root = await realpath(suppliedRoot);
176
+ await excludeFromGit(root, '/.playcraft/skill-selections/');
177
+ const parent = await ensureProjectSkillDirectory(root, '.playcraft/skill-selections', true);
178
+ const target = join(parent, selection.selectionId);
179
+ if (await exists(target))
180
+ return prepared(target, selection);
181
+ const leasePath = join(parent, `.${selection.selectionId}.prepare.lock`);
182
+ const lease = await acquirePreparationLease(leasePath, selection.selectionId);
183
+ const stageName = `.${selection.selectionId}.${randomUUID()}.tmp`;
184
+ const stage = join(parent, stageName);
185
+ let published = false;
186
+ try {
187
+ if (await exists(target))
188
+ return prepared(target, selection);
189
+ await ensureProjectSkillDirectory(parent, stageName);
190
+ const management = await ensureProjectSkillDirectory(stage, '.playcraft/skill-artifacts');
191
+ const snapshots = [
192
+ ['skills.json', selection.declaration], ['skills.lock.json', selection.lock],
193
+ ];
194
+ for (const [name, value] of snapshots) {
195
+ const bytes = canonicalProjectSkillsJson(value);
196
+ if (Buffer.byteLength(bytes) > 1024 * 1024)
197
+ throw new Error('remote_skill_selection_too_large');
198
+ await writeFile(join(stage, '.playcraft', name), bytes, { flag: 'wx', mode: 0o644 });
199
+ }
200
+ await readLockedProjectSkillCatalog(stage, management, async (entry) => {
201
+ const archive = await loadArtifact(entry);
202
+ if (!Buffer.isBuffer(archive) || archive.byteLength > MAX_ARTIFACT_BYTES
203
+ || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
204
+ throw new Error('remote_skill_artifact_digest_mismatch');
205
+ return archive;
206
+ });
207
+ await writeFile(join(stage, receiptName), selectedBytes, { flag: 'wx', mode: 0o644 });
208
+ // Nothing under the deterministic selection name is visible until every bundle is verified.
209
+ if (!await ownsPreparationLease(leasePath, lease))
210
+ throw new Error('remote_skill_selection_lease_changed');
211
+ if (await exists(target))
212
+ throw new Error('remote_skill_selection_conflict');
213
+ await rename(stage, target);
214
+ published = true;
215
+ return await prepared(target, selection);
216
+ }
217
+ finally {
218
+ if (!published)
219
+ await rm(stage, { recursive: true, force: true });
220
+ await lease.handle.close();
221
+ await retirePreparationLease(leasePath, lease);
222
+ }
223
+ }
@@ -0,0 +1,47 @@
1
+ import { resolve } from 'node:path';
2
+ import { projectSkillsMessage as t } from './messages.js';
3
+ import { searchProjectSkills } from './search.js';
4
+ export function printLockedSkillSearch(skills, total) {
5
+ console.log(t('searchLockedSource'));
6
+ console.log(total ? `${t('searchSummary')} ${total}` : t('searchNoMatches'));
7
+ for (const skill of skills) {
8
+ console.log(` ${skill.skillRef}@${skill.revision} ${skill.name}`);
9
+ if (skill.description)
10
+ console.log(` ${skill.description}`);
11
+ console.log(` ${skill.path}`);
12
+ }
13
+ if (skills.length)
14
+ console.log(t('searchReadPath'));
15
+ }
16
+ export function printSkillSearchError(error, json) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ const code = error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : 'skill_search_failed';
19
+ if (json)
20
+ process.stdout.write(`${JSON.stringify({ error: { code, message } })}\n`);
21
+ else
22
+ console.error(`${t('searchFailed')}: ${message}`);
23
+ process.exitCode = 1;
24
+ }
25
+ export function registerSkillSearchCommand(skills) {
26
+ skills.command('search').description(t('search'))
27
+ .option('--query <text>', t('searchQuery'), '')
28
+ .option('--engine <name>', t('searchEngine'))
29
+ .option('--dir <path>', t('directory'), '.')
30
+ .option('--limit <count>', t('searchLimit'), '20')
31
+ .option('--json', t('json'))
32
+ .action(async (options) => {
33
+ try {
34
+ const limit = Number(options.limit);
35
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
36
+ throw new Error(t('searchInvalidLimit'));
37
+ const result = await searchProjectSkills(resolve(options.dir), { ...options, limit });
38
+ if (options.json)
39
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
40
+ else
41
+ printLockedSkillSearch(result.skills, result.total);
42
+ }
43
+ catch (error) {
44
+ printSkillSearchError(error, options.json);
45
+ }
46
+ });
47
+ }
@@ -0,0 +1,96 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { readLockedProjectSkillCatalog } from './discovery.js';
4
+ import { projectSkillsMessage as t } from './messages.js';
5
+ /** A partial configuration is still managed and must fail validation, never fall through to scanning. */
6
+ export async function findManagedSkillsRoot(directory) {
7
+ let root = resolve(directory);
8
+ const exists = async (file) => lstat(file).then(() => true, (error) => {
9
+ if (error.code === 'ENOENT')
10
+ return false;
11
+ throw error;
12
+ });
13
+ while (true) {
14
+ if (await exists(join(root, '.playcraft/skills.json')) || await exists(join(root, '.playcraft/skills.lock.json')))
15
+ return root;
16
+ if (await exists(join(root, '.git')) || dirname(root) === root)
17
+ return null;
18
+ root = dirname(root);
19
+ }
20
+ }
21
+ const normalize = (value) => value.normalize('NFKC').toLowerCase();
22
+ export const normalizeSkillEngine = (value) => normalize(value).replace(/[.\s-]/g, '');
23
+ export function skillSearchTerms(query) {
24
+ const terms = new Set(normalize(query).split(/[\s,,、;;]+/u).filter(Boolean));
25
+ // Include words within natural-language Chinese queries and compound English tags.
26
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
27
+ for (const { segment, isWordLike } of segmenter.segment(normalize(query))) {
28
+ if (isWordLike && segment.length > 1)
29
+ terms.add(segment);
30
+ }
31
+ return [...terms];
32
+ }
33
+ export function filterLockedSkillsByEngine(skills, target) {
34
+ if (!target)
35
+ return skills;
36
+ const engine = normalizeSkillEngine(target);
37
+ const byAtom = new Map(skills.filter(skill => skill.manifest).map(skill => [skill.manifest.atomId, skill]));
38
+ const rootEngine = (id) => /^(phaser|threejs)(?:\.aicomponent|-runtime)?$/.exec(id)?.[1];
39
+ const compatible = (skill, visited = new Set()) => {
40
+ if (visited.has(skill.entryKey))
41
+ return true;
42
+ visited.add(skill.entryKey);
43
+ const manifest = skill.manifest;
44
+ if (!manifest)
45
+ return true;
46
+ const ownEngine = rootEngine(manifest.atomId);
47
+ if (ownEngine && ownEngine !== engine)
48
+ return false;
49
+ const metadata = manifest;
50
+ const engines = [...(metadata.renderBackend ?? []), ...Object.keys(metadata.engineVariants ?? {})].map(normalize);
51
+ if (engines.length && !engines.includes(engine))
52
+ return false;
53
+ const pins = metadata.engineVariants?.[engine]?.imports ?? (manifest.imports ?? [])
54
+ .filter(item => item.edgeKind === 'requires' || item.slot === 'engine' || item.bindAs === 'engine')
55
+ .flatMap(item => item.pinAtomId ? [item.pinAtomId] : []);
56
+ return pins.every(id => {
57
+ const requiredEngine = rootEngine(id);
58
+ if (requiredEngine && requiredEngine !== engine)
59
+ return false;
60
+ const dependency = byAtom.get(id);
61
+ return !dependency || compatible(dependency, visited);
62
+ });
63
+ };
64
+ return skills.filter(skill => compatible(skill));
65
+ }
66
+ export function rankLockedProjectSkills(skills, options = {}) {
67
+ const terms = skillSearchTerms(options.query ?? '');
68
+ const ranked = filterLockedSkillsByEngine(skills, options.engine).flatMap((skill) => {
69
+ const manifest = skill.manifest;
70
+ const fields = [
71
+ [skill.name, 5], [skill.skillRef, 5],
72
+ [skill.triggers.join(' '), 4], [skill.tags.join(' '), 4], [skill.keywords.join(' '), 3],
73
+ [manifest?.label?.zh ?? '', 4], [manifest?.label?.en ?? '', 4], [skill.description, 1],
74
+ ];
75
+ let score = 0;
76
+ const matchedTerms = terms.filter(term => {
77
+ const weight = Math.max(0, ...fields.map(([value, boost]) => normalize(value).includes(term) ? boost : 0));
78
+ score += weight;
79
+ return weight > 0;
80
+ });
81
+ if (terms.length && !matchedTerms.length)
82
+ return [];
83
+ const { manifest: _manifest, ...metadata } = skill;
84
+ return [{ ...metadata, atomId: manifest?.atomId, score, matchedTerms }];
85
+ }).sort((a, b) => b.score - a.score || a.skillRef.localeCompare(b.skillRef));
86
+ return { total: ranked.length, skills: ranked.slice(0, options.limit ?? 20) };
87
+ }
88
+ export async function searchProjectSkills(directory, options = {}) {
89
+ const root = await findManagedSkillsRoot(directory);
90
+ if (!root)
91
+ throw new Error(t('searchRequiresLock'));
92
+ const catalog = await readLockedProjectSkillCatalog(root);
93
+ return { source: 'project-lock', lockHash: catalog.lockHash,
94
+ query: options.query ?? '', engine: options.engine ?? null,
95
+ ...rankLockedProjectSkills(catalog.skills, options) };
96
+ }
@@ -7,7 +7,7 @@ import { RecordingError } from './messages.js';
7
7
  const execute = promisify(execFile);
8
8
  const excludedNames = new Set(['.git', 'node_modules', '.DS_Store']);
9
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'];
10
+ const excludedInternalPaths = ['.playcraft/video-replication', '.playcraft/skills', '.playcraft/skill-artifacts', '.playcraft/skill-selections', '.playcraft/skills.json', '.playcraft/skills.lock.json', '.playcraft/workspace-context.json'];
11
11
  const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
12
12
  const canonicalPath = async (path) => {
13
13
  const absolute = resolve(path);
@@ -3,6 +3,7 @@ const messages = {
3
3
  description: 'Discover and import Remix source references for the current cloud workspace',
4
4
  list: 'List verified local Remix references and their paths', search: 'Search accessible recommended or community Remixes',
5
5
  import: 'Import a fixed Remix source snapshot into the current engineering directory',
6
+ prepareStarterTemplate: 'Prepare a verified starter template in a temporary directory',
6
7
  query: 'Search names, branches, tags, creators or templates', source: 'recommended or community', project: 'Source project identifier', branch: 'Exact source branch',
7
8
  tag: 'Match a tag value; repeat to match any of the values', tagCategory: 'Tag category; defaults to Gameplay when --tag is used',
8
9
  workspace: 'Current cloud workspace identifier (must match this conversation)', directory: 'Current engineering directory (must match this conversation)',
@@ -22,6 +23,7 @@ const messages = {
22
23
  zh: {
23
24
  description: '为当前云工作区发现并导入 Remix 源码参考',
24
25
  list: '列出已验证的本地 Remix 参考及路径', search: '搜索可访问的推荐或社区 Remix', import: '把固定版本的 Remix 源码参考导入当前工程',
26
+ prepareStarterTemplate: '将经过校验的起始模板准备到临时目录',
25
27
  query: '搜索名称、分支、标签、创建者或模板', source: 'recommended(推荐)或 community(社区)', project: '来源项目标识', branch: '准确的来源分支名',
26
28
  tag: '匹配的标签值;可重复指定,匹配任意一个值', tagCategory: '标签分类;使用 --tag 时默认为 Gameplay',
27
29
  workspace: '当前云工作区标识(必须与本对话一致)', directory: '当前工程目录(必须与本对话一致)',
@@ -3,18 +3,38 @@ import { z } from 'zod';
3
3
  const scope = {
4
4
  cwd: z.string().min(1).max(4096),
5
5
  workspaceId: z.string().uuid().optional(),
6
- directoryName: z.string().regex(/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/).optional(),
6
+ directoryName: z
7
+ .string()
8
+ .regex(/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/)
9
+ .optional(),
7
10
  };
8
- export const remixReferenceToolRequestSchema = z.discriminatedUnion('action', [
11
+ export const remixReferenceToolRequestSchema = z
12
+ .discriminatedUnion('action', [
9
13
  z.object({ ...scope, action: z.literal('list') }).strict(),
10
- z.object({ ...scope, action: z.literal('search'), source: z.enum(['recommended', 'community']),
14
+ z.object({ ...scope, action: z.literal('prepare-starter-template') }).strict(),
15
+ z
16
+ .object({
17
+ ...scope,
18
+ action: z.literal('search'),
19
+ source: z.enum(['recommended', 'community']),
11
20
  tagCategory: z.string().trim().min(1).max(128).optional(),
12
21
  tagValues: z.array(z.string().trim().min(1).max(255)).min(1).max(20).optional(),
13
- q: z.string().max(200).optional(), page: z.number().int().min(1).max(10000).default(1),
14
- limit: z.number().int().min(1).max(50).default(12) }).strict(),
15
- z.object({ ...scope, action: z.literal('import'), projectUuid: z.string().uuid(),
16
- branchName: z.string().min(1).max(255) }).strict(),
17
- ]).refine(input => input.action !== 'search' || (input.tagCategory === undefined) === (input.tagValues === undefined));
22
+ q: z.string().max(200).optional(),
23
+ page: z.number().int().min(1).max(10000).default(1),
24
+ limit: z.number().int().min(1).max(50).default(12),
25
+ })
26
+ .strict(),
27
+ z
28
+ .object({
29
+ ...scope,
30
+ action: z.literal('import'),
31
+ projectUuid: z.string().uuid(),
32
+ branchName: z.string().min(1).max(255),
33
+ })
34
+ .strict(),
35
+ ])
36
+ .refine((input) => input.action !== 'search' ||
37
+ (input.tagCategory === undefined) === (input.tagValues === undefined));
18
38
  export class RemixReferenceToolError extends Error {
19
39
  code;
20
40
  constructor(code) {
@@ -25,8 +45,15 @@ export class RemixReferenceToolError extends Error {
25
45
  }
26
46
  export function localRemixReference(root, reference) {
27
47
  const { files, omittedFiles, ...metadata } = reference;
28
- return { ...metadata, source: { ...metadata.source,
29
- recommended: metadata.source.recommended ?? metadata.source.category === 'recommended' },
30
- resolvedPath: join(root, reference.path), fileCount: files.length,
31
- omittedFileCount: omittedFiles?.length ?? 0 };
48
+ return {
49
+ ...metadata,
50
+ source: {
51
+ ...metadata.source,
52
+ recommended: ('recommended' in metadata.source ? metadata.source.recommended : undefined) ??
53
+ metadata.source.category === 'recommended',
54
+ },
55
+ resolvedPath: join(root, reference.path),
56
+ fileCount: files.length,
57
+ omittedFileCount: omittedFiles?.length ?? 0,
58
+ };
32
59
  }