@playcraft/cli 0.1.69 → 0.1.71
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.
- package/README.md +20 -1
- package/dist/commands/skills.js +68 -4
- package/dist/project-skills/discovery.js +248 -0
- package/dist/project-skills/messages.js +80 -0
- package/dist/project-skills/remote-activation.js +500 -0
- package/dist/project-skills/remote-client.js +117 -0
- package/dist/project-skills/remote-commands.js +87 -0
- package/dist/project-skills/remote-prepare.js +223 -0
- package/dist/project-skills/search-command.js +47 -0
- package/dist/project-skills/search.js +96 -0
- package/dist/recording/project-digest.js +1 -1
- package/dist/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/source-capture.js +43 -4
- package/dist/workspace-runtime/codex/app-server.js +25 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +15 -0
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/verify-artifact.js +2 -1
- package/dist/workspace-runtime/workspaces/files.js +20 -14
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
- package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
- package/dist/workspace-runtime/workspaces/skills.js +13 -176
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/empty/gitignore +1 -0
- package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- 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);
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { WORKSPACE_REMIX_REFERENCES_DIRECTORY } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
2
|
import { gitRead } from './git.js';
|
|
2
3
|
const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
3
4
|
const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
|
|
4
5
|
const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
|
|
5
6
|
function contentPath(path) {
|
|
7
|
+
if (path.startsWith(`${WORKSPACE_REMIX_REFERENCES_DIRECTORY}/`))
|
|
8
|
+
return false;
|
|
6
9
|
const parts = path.split('/');
|
|
7
10
|
if (parts.some(part => part.startsWith('.') || excludedDirectories.has(part)))
|
|
8
11
|
return false;
|
|
@@ -100,7 +100,10 @@ export async function generateBatchMember(state, body) {
|
|
|
100
100
|
const policy = memberExecutionPolicy(root, tempDir);
|
|
101
101
|
await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
|
|
102
102
|
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
103
|
-
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {})
|
|
103
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}),
|
|
104
|
+
...(command.remixReferences?.length ? { context: { files: [], skills: [], remixReferences: command.remixReferences.map(reference => ({
|
|
105
|
+
...reference, directoryName: command.directoryName,
|
|
106
|
+
})) } } : {}) };
|
|
104
107
|
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
105
108
|
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
106
109
|
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
@@ -14,6 +14,7 @@ import { processHasExited, runProcessGroup } from './processes.js';
|
|
|
14
14
|
import { createInstallLog } from './install-log.js';
|
|
15
15
|
import { stopMemberWriters } from './writer-proof.js';
|
|
16
16
|
import { restoreBatchSkillArtifacts } from './skill-artifacts.js';
|
|
17
|
+
import { rebindBatchRemixReferences } from '../workspaces/remix-references.js';
|
|
17
18
|
import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
|
|
18
19
|
const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
|
|
19
20
|
function hashCommand(value) {
|
|
@@ -191,11 +192,14 @@ export async function prepareBatchMember(state, body, options) {
|
|
|
191
192
|
const commit = command.resumeFromCommit ?? snapshot.commitHash;
|
|
192
193
|
await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
|
|
193
194
|
await restoreBatchSkillArtifacts(sourceRoot, staging);
|
|
195
|
+
await rebindBatchRemixReferences(staging, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
194
196
|
const { rename } = await import('node:fs/promises');
|
|
195
197
|
await rename(staging, root);
|
|
196
198
|
}
|
|
197
|
-
else
|
|
199
|
+
else {
|
|
198
200
|
await restoreBatchSkillArtifacts(sourceRoot, root);
|
|
201
|
+
await rebindBatchRemixReferences(root, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
202
|
+
}
|
|
199
203
|
await registerWorkspaceDirectory(state, {
|
|
200
204
|
workspaceId: command.workspaceId,
|
|
201
205
|
directoryName: command.directoryName,
|
|
@@ -4,16 +4,20 @@ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'n
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
5
|
import { gzip, gunzip } from 'node:zlib';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
+
import { WORKSPACE_REMIX_REFERENCES_MANIFEST } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
8
|
import { excludeFromGit } from '../workspaces/dependencies.js';
|
|
9
|
+
import { batchReferenceEnvExamples, batchReferenceMetadata } from '../workspaces/remix-references.js';
|
|
8
10
|
import { gitRead, gitWrite } from './git.js';
|
|
9
11
|
const compress = promisify(gzip);
|
|
10
12
|
const decompress = promisify(gunzip);
|
|
11
13
|
const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
|
|
12
14
|
'.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
|
|
13
15
|
'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
|
|
14
|
-
function included(path) {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
function included(path, referenceExamples) {
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
return !parts.some((name, index) => privateNames.has(name) || (/^\.env(?:$|\.)/.test(name) &&
|
|
19
|
+
!(index === parts.length - 1 && referenceExamples.has(path))) || /\.(?:pem|key|p12|pfx)$/.test(name))
|
|
20
|
+
&& !path.startsWith('.playcraft/skills/') && !path.startsWith('.playcraft/skill-selections/');
|
|
17
21
|
}
|
|
18
22
|
/** Git keeps only the executable bit, so the digest must not depend on umask or group-write bits. */
|
|
19
23
|
function snapshotMode(mode) {
|
|
@@ -32,10 +36,12 @@ export function digestFiles(files) {
|
|
|
32
36
|
/** Files Git will accept. Credentials, caches and node_modules are left out. */
|
|
33
37
|
export async function readSourceFiles(root) {
|
|
34
38
|
const source = await realpath(root);
|
|
39
|
+
const referenceExamples = await batchReferenceEnvExamples(source);
|
|
40
|
+
const referenceRecords = await batchReferenceMetadata(source);
|
|
35
41
|
const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean));
|
|
36
42
|
const files = new Map();
|
|
37
43
|
for (const path of [...paths].sort()) {
|
|
38
|
-
if (!included(path))
|
|
44
|
+
if (!included(path, referenceExamples))
|
|
39
45
|
continue;
|
|
40
46
|
const full = join(source, path);
|
|
41
47
|
const stat = await lstat(full).catch(error => {
|
|
@@ -55,6 +61,33 @@ export async function readSourceFiles(root) {
|
|
|
55
61
|
await handle.close();
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
const manifestFile = files.get(WORKSPACE_REMIX_REFERENCES_MANIFEST);
|
|
65
|
+
if (manifestFile) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(manifestFile.body.toString('utf8'));
|
|
68
|
+
if (manifest?.version === 1 && Array.isArray(manifest.items)) {
|
|
69
|
+
const withheld = [];
|
|
70
|
+
for (const record of referenceRecords) {
|
|
71
|
+
for (const file of record.files) {
|
|
72
|
+
const path = `${record.path}/${file.path}`;
|
|
73
|
+
// A damaged reference may make an otherwise allowed example fail verification. That
|
|
74
|
+
// is a reference-integrity error, not evidence that a protected path was withheld.
|
|
75
|
+
const isExample = /^\.env\.(?:example|sample|template)$/i.test(path.split('/').pop() ?? '');
|
|
76
|
+
if (paths.has(path) && !included(path, referenceExamples) &&
|
|
77
|
+
(!isExample || !included(path, new Set([path]))) && (await lstat(join(source, path)).catch(() => undefined))?.isFile()) {
|
|
78
|
+
withheld.push(record.referenceId);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
delete manifest.batchPolicyWithheldReferenceIds;
|
|
84
|
+
if (withheld.length)
|
|
85
|
+
manifest.batchPolicyWithheldReferenceIds = withheld;
|
|
86
|
+
files.set(WORKSPACE_REMIX_REFERENCES_MANIFEST, { ...manifestFile, body: Buffer.from(JSON.stringify(manifest)) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { /* Preserve malformed legacy metadata without letting it block an unrelated batch. */ }
|
|
90
|
+
}
|
|
58
91
|
return files;
|
|
59
92
|
}
|
|
60
93
|
/** A changing source must not silently lose files from the captured input. */
|
|
@@ -99,6 +132,12 @@ export async function captureIndependentSource(source, destination) {
|
|
|
99
132
|
for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skills/', '/.playcraft-temp/'])
|
|
100
133
|
await excludeFromGit(staging, entry);
|
|
101
134
|
await gitWrite(staging, ['add', '-A', '.']);
|
|
135
|
+
// Imported reference files can be tracked in the source despite .gitignore. Preserve only
|
|
136
|
+
// their filtered staging paths, without changing ignore handling for other source files.
|
|
137
|
+
for (const path of ['.playcraft/remix-references.json', 'references/remixes']) {
|
|
138
|
+
if (await lstat(join(staging, path)).catch(() => undefined))
|
|
139
|
+
await gitWrite(staging, ['add', '--force', '-A', '--', path]);
|
|
140
|
+
}
|
|
102
141
|
await gitWrite(staging, ['commit', '--allow-empty', '-m', 'Freeze batch source']);
|
|
103
142
|
const commitHash = (await gitWrite(staging, ['rev-parse', 'HEAD'])).trim();
|
|
104
143
|
await rename(staging, destination);
|