@losclaws/cli 0.1.2 → 0.1.4
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 +63 -3
- package/package.json +5 -2
- package/src/acp-runtime.js +1083 -0
- package/src/cli.js +275 -82
- package/src/config.js +25 -2
- package/src/workshop-local.js +785 -0
- package/test/acp-runtime.test.js +264 -0
- package/test/config.test.js +6 -1
- package/test/help-docs.test.js +50 -0
- package/test/workshop-local.test.js +83 -0
- package/testdata/mock-acp-agent.js +135 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { CliError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const localVersion = 1;
|
|
13
|
+
const metaDirectoryName = '.losclaws';
|
|
14
|
+
|
|
15
|
+
export async function initWorkshopWorkspaceRoot({
|
|
16
|
+
requestWorkshop,
|
|
17
|
+
workspaceId,
|
|
18
|
+
rootPath = process.cwd(),
|
|
19
|
+
role = 'worker',
|
|
20
|
+
syncArtifacts = true,
|
|
21
|
+
syncSkills = true,
|
|
22
|
+
skillsRepoUrl = '',
|
|
23
|
+
skillsRepoRef = 'main',
|
|
24
|
+
}) {
|
|
25
|
+
const absoluteRoot = path.resolve(rootPath);
|
|
26
|
+
const workspace = await requestWorkshop({
|
|
27
|
+
path: `/api/v1/workspaces/${workspaceId}`,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const state = {
|
|
31
|
+
version: localVersion,
|
|
32
|
+
rootType: 'workspace',
|
|
33
|
+
rootPath: absoluteRoot,
|
|
34
|
+
role,
|
|
35
|
+
workspace: normalizeIdentity(workspace),
|
|
36
|
+
project: null,
|
|
37
|
+
artifacts: [],
|
|
38
|
+
skills: [],
|
|
39
|
+
updatedAt: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
await writeStateFiles(absoluteRoot, state);
|
|
43
|
+
const results = {
|
|
44
|
+
rootPath: absoluteRoot,
|
|
45
|
+
rootType: 'workspace',
|
|
46
|
+
workspaceId: state.workspace.id,
|
|
47
|
+
syncedArtifacts: 0,
|
|
48
|
+
syncedSkills: 0,
|
|
49
|
+
notes: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (syncArtifacts) {
|
|
53
|
+
const pull = await pullWorkshopArtifacts({ requestWorkshop, rootPath: absoluteRoot });
|
|
54
|
+
results.syncedArtifacts = pull.downloaded;
|
|
55
|
+
results.notes.push(...pull.notes);
|
|
56
|
+
}
|
|
57
|
+
if (syncSkills) {
|
|
58
|
+
const sync = await syncWorkshopSkills({
|
|
59
|
+
requestWorkshop,
|
|
60
|
+
rootPath: absoluteRoot,
|
|
61
|
+
role,
|
|
62
|
+
skillsRepoUrl,
|
|
63
|
+
skillsRepoRef,
|
|
64
|
+
});
|
|
65
|
+
results.syncedSkills = sync.installed;
|
|
66
|
+
results.notes.push(...sync.notes);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const status = await getWorkshopSyncStatus({ rootPath: absoluteRoot });
|
|
70
|
+
results.artifactStatus = status.summary;
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function initWorkshopProjectRoot({
|
|
75
|
+
requestWorkshop,
|
|
76
|
+
projectId,
|
|
77
|
+
rootPath = process.cwd(),
|
|
78
|
+
role = 'worker',
|
|
79
|
+
syncArtifacts = true,
|
|
80
|
+
syncSkills = true,
|
|
81
|
+
skillsRepoUrl = '',
|
|
82
|
+
skillsRepoRef = 'main',
|
|
83
|
+
}) {
|
|
84
|
+
const absoluteRoot = path.resolve(rootPath);
|
|
85
|
+
const project = await requestWorkshop({
|
|
86
|
+
path: `/api/v1/projects/${projectId}`,
|
|
87
|
+
});
|
|
88
|
+
const workspaceId = extractWorkspaceId(project);
|
|
89
|
+
if (!workspaceId) {
|
|
90
|
+
throw new CliError('Workshop project payload does not include a workspace id.');
|
|
91
|
+
}
|
|
92
|
+
const workspace = await requestWorkshop({
|
|
93
|
+
path: `/api/v1/workspaces/${workspaceId}`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const state = {
|
|
97
|
+
version: localVersion,
|
|
98
|
+
rootType: 'project',
|
|
99
|
+
rootPath: absoluteRoot,
|
|
100
|
+
role,
|
|
101
|
+
workspace: normalizeIdentity(workspace),
|
|
102
|
+
project: normalizeIdentity(project),
|
|
103
|
+
artifacts: [],
|
|
104
|
+
skills: [],
|
|
105
|
+
updatedAt: new Date().toISOString(),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await writeStateFiles(absoluteRoot, state);
|
|
109
|
+
const results = {
|
|
110
|
+
rootPath: absoluteRoot,
|
|
111
|
+
rootType: 'project',
|
|
112
|
+
workspaceId: state.workspace.id,
|
|
113
|
+
projectId: state.project.id,
|
|
114
|
+
syncedArtifacts: 0,
|
|
115
|
+
syncedSkills: 0,
|
|
116
|
+
notes: [],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (syncArtifacts) {
|
|
120
|
+
const pull = await pullWorkshopArtifacts({ requestWorkshop, rootPath: absoluteRoot });
|
|
121
|
+
results.syncedArtifacts = pull.downloaded;
|
|
122
|
+
results.notes.push(...pull.notes);
|
|
123
|
+
}
|
|
124
|
+
if (syncSkills) {
|
|
125
|
+
const sync = await syncWorkshopSkills({
|
|
126
|
+
requestWorkshop,
|
|
127
|
+
rootPath: absoluteRoot,
|
|
128
|
+
role,
|
|
129
|
+
skillsRepoUrl,
|
|
130
|
+
skillsRepoRef,
|
|
131
|
+
});
|
|
132
|
+
results.syncedSkills = sync.installed;
|
|
133
|
+
results.notes.push(...sync.notes);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const status = await getWorkshopSyncStatus({ rootPath: absoluteRoot });
|
|
137
|
+
results.artifactStatus = status.summary;
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function getWorkshopSyncStatus({ rootPath = process.cwd() }) {
|
|
142
|
+
const state = await loadWorkshopState(rootPath);
|
|
143
|
+
const index = await buildArtifactIndex(state);
|
|
144
|
+
await writeJson(resolveLayout(state.rootPath).indexFile, index);
|
|
145
|
+
return {
|
|
146
|
+
rootPath: state.rootPath,
|
|
147
|
+
rootType: state.rootType,
|
|
148
|
+
summary: summarizeIndex(index),
|
|
149
|
+
entries: index.entries,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function pullWorkshopArtifacts({ requestWorkshop, rootPath = process.cwd() }) {
|
|
154
|
+
const state = await loadWorkshopState(rootPath);
|
|
155
|
+
const artifactRefs = await listArtifactRefs(requestWorkshop, state);
|
|
156
|
+
const notes = [];
|
|
157
|
+
const downloaded = [];
|
|
158
|
+
|
|
159
|
+
for (const ref of artifactRefs) {
|
|
160
|
+
const detail = await requestWorkshop({
|
|
161
|
+
path: `/api/v1/artifacts/${ref.id}`,
|
|
162
|
+
});
|
|
163
|
+
const materialized = await materializeArtifact({
|
|
164
|
+
rootPath: state.rootPath,
|
|
165
|
+
artifact: detail,
|
|
166
|
+
fallbackScope: ref.scope,
|
|
167
|
+
fallbackPath: ref.relativePath,
|
|
168
|
+
});
|
|
169
|
+
downloaded.push(materialized);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
state.artifacts = mergeArtifactStates(state.artifacts, downloaded);
|
|
173
|
+
state.updatedAt = new Date().toISOString();
|
|
174
|
+
await writeStateFiles(state.rootPath, state);
|
|
175
|
+
const index = await buildArtifactIndex(state);
|
|
176
|
+
await writeJson(resolveLayout(state.rootPath).indexFile, index);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
rootPath: state.rootPath,
|
|
180
|
+
downloaded: downloaded.length,
|
|
181
|
+
notes,
|
|
182
|
+
summary: summarizeIndex(index),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function pushWorkshopArtifacts({ requestWorkshop, rootPath = process.cwd() }) {
|
|
187
|
+
const state = await loadWorkshopState(rootPath);
|
|
188
|
+
const index = await buildArtifactIndex(state);
|
|
189
|
+
const modified = index.entries.filter((entry) => entry.status === 'modified');
|
|
190
|
+
const missing = modified.filter((entry) => entry.remoteRevisionNo === null);
|
|
191
|
+
if (missing.length > 0) {
|
|
192
|
+
throw new CliError(
|
|
193
|
+
'Cannot push modified artifacts without known remote revision numbers. Re-run `workshop sync pull` first.',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const pushed = [];
|
|
198
|
+
for (const entry of modified) {
|
|
199
|
+
const absolutePath = path.join(state.rootPath, entry.relativePath);
|
|
200
|
+
const content = await fs.readFile(absolutePath, 'utf8');
|
|
201
|
+
const requestBody = buildRevisionRequest(entry, content);
|
|
202
|
+
await requestWorkshop({
|
|
203
|
+
method: 'POST',
|
|
204
|
+
path: `/api/v1/artifacts/${entry.id}/revisions`,
|
|
205
|
+
body: requestBody,
|
|
206
|
+
});
|
|
207
|
+
pushed.push(entry.id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const refreshed = await pullWorkshopArtifacts({ requestWorkshop, rootPath: state.rootPath });
|
|
211
|
+
return {
|
|
212
|
+
rootPath: state.rootPath,
|
|
213
|
+
pushed,
|
|
214
|
+
pushedCount: pushed.length,
|
|
215
|
+
pulledAfterPush: refreshed.downloaded,
|
|
216
|
+
summary: refreshed.summary,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function syncWorkshopSkills({
|
|
221
|
+
requestWorkshop,
|
|
222
|
+
rootPath = process.cwd(),
|
|
223
|
+
role,
|
|
224
|
+
skillsRepoUrl = '',
|
|
225
|
+
skillsRepoRef = 'main',
|
|
226
|
+
}) {
|
|
227
|
+
const state = await loadWorkshopState(rootPath);
|
|
228
|
+
const effectiveRole = role || state.role || 'worker';
|
|
229
|
+
state.role = effectiveRole;
|
|
230
|
+
const skills = await resolveRequiredSkills({
|
|
231
|
+
requestWorkshop,
|
|
232
|
+
state,
|
|
233
|
+
role: effectiveRole,
|
|
234
|
+
});
|
|
235
|
+
const notes = [];
|
|
236
|
+
|
|
237
|
+
if (skills.length === 0) {
|
|
238
|
+
throw new CliError('No required skills were resolved for this workspace/project role.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const source = skillsRepoUrl
|
|
242
|
+
? await cloneSkillsSource(skillsRepoUrl, skillsRepoRef)
|
|
243
|
+
: { rootPath: detectBundledSkillsRoot(), source: 'bundled', revision: 'bundled' };
|
|
244
|
+
if (!skillsRepoUrl) {
|
|
245
|
+
notes.push('Using bundled skill source. Set --skills-repo URL to pull from an official remote repository.');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const installed = [];
|
|
249
|
+
try {
|
|
250
|
+
for (const skill of skills) {
|
|
251
|
+
const skillName = normalizeSkillName(skill);
|
|
252
|
+
const sourcePath = await findSkillPath(source.rootPath, skillName);
|
|
253
|
+
const destination = path.join(resolveLayout(state.rootPath).skillsRoot, effectiveRole, skillName);
|
|
254
|
+
await fs.rm(destination, { recursive: true, force: true });
|
|
255
|
+
await copyDirectory(sourcePath, destination);
|
|
256
|
+
installed.push({
|
|
257
|
+
name: skillName,
|
|
258
|
+
role: effectiveRole,
|
|
259
|
+
path: path.relative(state.rootPath, destination),
|
|
260
|
+
source: source.source,
|
|
261
|
+
revision: source.revision,
|
|
262
|
+
installedAt: new Date().toISOString(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
if (source.cleanup) {
|
|
267
|
+
await source.cleanup();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
state.skills = mergeSkillStates(state.skills, installed, effectiveRole);
|
|
272
|
+
state.updatedAt = new Date().toISOString();
|
|
273
|
+
await writeStateFiles(state.rootPath, state);
|
|
274
|
+
return {
|
|
275
|
+
rootPath: state.rootPath,
|
|
276
|
+
role: effectiveRole,
|
|
277
|
+
installed: installed.length,
|
|
278
|
+
skills: installed,
|
|
279
|
+
notes,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function loadWorkshopState(rootPath = process.cwd()) {
|
|
284
|
+
const absoluteRoot = path.resolve(rootPath);
|
|
285
|
+
const layout = resolveLayout(absoluteRoot);
|
|
286
|
+
const state = await readJson(layout.stateFile, 'Local Workshop state not found. Run `losclaws workshop init ...` first.');
|
|
287
|
+
return normalizeState(state, absoluteRoot);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function buildArtifactIndex(state) {
|
|
291
|
+
const entries = [];
|
|
292
|
+
for (const artifact of state.artifacts) {
|
|
293
|
+
const absolutePath = path.join(state.rootPath, artifact.relativePath);
|
|
294
|
+
let status = 'missing';
|
|
295
|
+
let currentHash = null;
|
|
296
|
+
try {
|
|
297
|
+
const buffer = await fs.readFile(absolutePath);
|
|
298
|
+
currentHash = hashBuffer(buffer);
|
|
299
|
+
status = currentHash === artifact.lastSyncedHash ? 'clean' : 'modified';
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error.code !== 'ENOENT') {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
entries.push({
|
|
306
|
+
id: artifact.id,
|
|
307
|
+
relativePath: artifact.relativePath,
|
|
308
|
+
status,
|
|
309
|
+
currentHash,
|
|
310
|
+
lastSyncedHash: artifact.lastSyncedHash || null,
|
|
311
|
+
remoteRevisionNo: asNumberOrNull(artifact.remoteRevisionNo),
|
|
312
|
+
remoteVersion: asNumberOrNull(artifact.remoteVersion),
|
|
313
|
+
contentKind: artifact.contentKind || 'text',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const knownPaths = new Set(entries.map((entry) => entry.relativePath));
|
|
318
|
+
const discovered = await listFiles(resolveLayout(state.rootPath).artifactsRoot);
|
|
319
|
+
for (const absoluteFile of discovered) {
|
|
320
|
+
const relativePath = path.relative(state.rootPath, absoluteFile);
|
|
321
|
+
if (!knownPaths.has(relativePath)) {
|
|
322
|
+
const currentHash = hashBuffer(await fs.readFile(absoluteFile));
|
|
323
|
+
entries.push({
|
|
324
|
+
id: null,
|
|
325
|
+
relativePath,
|
|
326
|
+
status: 'untracked',
|
|
327
|
+
currentHash,
|
|
328
|
+
lastSyncedHash: null,
|
|
329
|
+
remoteRevisionNo: null,
|
|
330
|
+
remoteVersion: null,
|
|
331
|
+
contentKind: 'text',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
version: localVersion,
|
|
338
|
+
generatedAt: new Date().toISOString(),
|
|
339
|
+
summary: summarizeIndex({ entries }),
|
|
340
|
+
entries: entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function summarizeIndex(index) {
|
|
345
|
+
const summary = {
|
|
346
|
+
clean: 0,
|
|
347
|
+
modified: 0,
|
|
348
|
+
missing: 0,
|
|
349
|
+
untracked: 0,
|
|
350
|
+
total: index.entries.length,
|
|
351
|
+
};
|
|
352
|
+
for (const entry of index.entries) {
|
|
353
|
+
if (entry.status in summary) {
|
|
354
|
+
summary[entry.status] += 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return summary;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveLayout(rootPath) {
|
|
361
|
+
const metaRoot = path.join(rootPath, metaDirectoryName);
|
|
362
|
+
return {
|
|
363
|
+
rootPath,
|
|
364
|
+
metaRoot,
|
|
365
|
+
stateFile: path.join(metaRoot, 'state.json'),
|
|
366
|
+
workspaceFile: path.join(metaRoot, 'workspace.json'),
|
|
367
|
+
projectFile: path.join(metaRoot, 'project.json'),
|
|
368
|
+
manifestFile: path.join(metaRoot, 'manifest.json'),
|
|
369
|
+
indexFile: path.join(metaRoot, 'index.json'),
|
|
370
|
+
artifactsRoot: path.join(rootPath, 'artifacts'),
|
|
371
|
+
skillsRoot: path.join(rootPath, 'skills'),
|
|
372
|
+
objectsRoot: path.join(metaRoot, 'objects'),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function ensureLayout(rootPath) {
|
|
377
|
+
const layout = resolveLayout(rootPath);
|
|
378
|
+
await Promise.all([
|
|
379
|
+
fs.mkdir(layout.metaRoot, { recursive: true }),
|
|
380
|
+
fs.mkdir(layout.artifactsRoot, { recursive: true }),
|
|
381
|
+
fs.mkdir(layout.skillsRoot, { recursive: true }),
|
|
382
|
+
fs.mkdir(layout.objectsRoot, { recursive: true }),
|
|
383
|
+
]);
|
|
384
|
+
return layout;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function writeStateFiles(rootPath, state) {
|
|
388
|
+
const layout = await ensureLayout(rootPath);
|
|
389
|
+
const normalized = normalizeState(state, rootPath);
|
|
390
|
+
await writeJson(layout.stateFile, normalized);
|
|
391
|
+
await writeJson(layout.workspaceFile, normalized.workspace || null);
|
|
392
|
+
await writeJson(layout.projectFile, normalized.project || null);
|
|
393
|
+
await writeJson(layout.manifestFile, {
|
|
394
|
+
version: localVersion,
|
|
395
|
+
rootType: normalized.rootType,
|
|
396
|
+
role: normalized.role,
|
|
397
|
+
workspace: normalized.workspace,
|
|
398
|
+
project: normalized.project,
|
|
399
|
+
artifacts: normalized.artifacts,
|
|
400
|
+
skills: normalized.skills,
|
|
401
|
+
updatedAt: normalized.updatedAt,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function writeJson(filePath, value) {
|
|
406
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function readJson(filePath, notFoundMessage) {
|
|
410
|
+
try {
|
|
411
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
412
|
+
return JSON.parse(raw);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error.code === 'ENOENT') {
|
|
415
|
+
throw new CliError(notFoundMessage || `File not found: ${filePath}`);
|
|
416
|
+
}
|
|
417
|
+
if (error instanceof SyntaxError) {
|
|
418
|
+
throw new CliError(`Invalid JSON at ${filePath}.`);
|
|
419
|
+
}
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function normalizeState(state, rootPath) {
|
|
425
|
+
return {
|
|
426
|
+
version: Number(state.version) || localVersion,
|
|
427
|
+
rootType: state.rootType === 'project' ? 'project' : 'workspace',
|
|
428
|
+
rootPath: path.resolve(rootPath || state.rootPath || process.cwd()),
|
|
429
|
+
role: String(state.role || 'worker'),
|
|
430
|
+
workspace: state.workspace ? normalizeIdentity(state.workspace) : null,
|
|
431
|
+
project: state.project ? normalizeIdentity(state.project) : null,
|
|
432
|
+
artifacts: normalizeArtifacts(state.artifacts || []),
|
|
433
|
+
skills: normalizeSkills(state.skills || []),
|
|
434
|
+
updatedAt: state.updatedAt || new Date().toISOString(),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function normalizeIdentity(value) {
|
|
439
|
+
const id = String(value?.id || value?.workspaceId || value?.projectId || '');
|
|
440
|
+
if (!id) {
|
|
441
|
+
throw new CliError('Workspace/project payload does not include an id.');
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
id,
|
|
445
|
+
name: String(value?.name || ''),
|
|
446
|
+
slug: String(value?.slug || ''),
|
|
447
|
+
version: asNumberOrNull(value?.version),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function normalizeArtifacts(values) {
|
|
452
|
+
return values
|
|
453
|
+
.map((entry) => ({
|
|
454
|
+
id: String(entry?.id || ''),
|
|
455
|
+
scope: entry?.scope === 'workspace' ? 'workspace' : 'project',
|
|
456
|
+
relativePath: normalizeRelativePath(entry?.relativePath || ''),
|
|
457
|
+
contentKind: entry?.contentKind || 'text',
|
|
458
|
+
mimeType: String(entry?.mimeType || ''),
|
|
459
|
+
lastSyncedHash: String(entry?.lastSyncedHash || ''),
|
|
460
|
+
remoteRevisionNo: asNumberOrNull(entry?.remoteRevisionNo),
|
|
461
|
+
remoteVersion: asNumberOrNull(entry?.remoteVersion),
|
|
462
|
+
updatedAt: entry?.updatedAt || new Date().toISOString(),
|
|
463
|
+
}))
|
|
464
|
+
.filter((entry) => entry.id && entry.relativePath);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function normalizeSkills(values) {
|
|
468
|
+
return values
|
|
469
|
+
.map((entry) => ({
|
|
470
|
+
name: String(entry?.name || ''),
|
|
471
|
+
role: String(entry?.role || 'worker'),
|
|
472
|
+
path: normalizeRelativePath(entry?.path || ''),
|
|
473
|
+
source: String(entry?.source || 'unknown'),
|
|
474
|
+
revision: String(entry?.revision || ''),
|
|
475
|
+
installedAt: entry?.installedAt || new Date().toISOString(),
|
|
476
|
+
}))
|
|
477
|
+
.filter((entry) => entry.name && entry.path);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function listArtifactRefs(requestWorkshop, state) {
|
|
481
|
+
const refs = [];
|
|
482
|
+
if (state.workspace?.id) {
|
|
483
|
+
refs.push(
|
|
484
|
+
...(await tryArtifactRefEndpoint(
|
|
485
|
+
requestWorkshop,
|
|
486
|
+
`/api/v1/workspaces/${state.workspace.id}/artifacts`,
|
|
487
|
+
{ workspaceId: state.workspace.id },
|
|
488
|
+
'workspace',
|
|
489
|
+
)),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (state.project?.id) {
|
|
493
|
+
refs.push(
|
|
494
|
+
...(await tryArtifactRefEndpoint(
|
|
495
|
+
requestWorkshop,
|
|
496
|
+
`/api/v1/projects/${state.project.id}/artifacts`,
|
|
497
|
+
{ projectId: state.project.id },
|
|
498
|
+
'project',
|
|
499
|
+
)),
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (refs.length === 0) {
|
|
503
|
+
throw new CliError('No remote artifacts were returned for this workspace/project.');
|
|
504
|
+
}
|
|
505
|
+
return dedupeById(refs);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function tryArtifactRefEndpoint(requestWorkshop, endpoint, fallbackQuery, scope) {
|
|
509
|
+
const direct = await tryArtifactRefRequest(requestWorkshop, { path: endpoint });
|
|
510
|
+
const fallback = direct.length > 0
|
|
511
|
+
? []
|
|
512
|
+
: await tryArtifactRefRequest(requestWorkshop, { path: '/api/v1/artifacts', query: fallbackQuery });
|
|
513
|
+
const entries = direct.length > 0 ? direct : fallback;
|
|
514
|
+
return entries
|
|
515
|
+
.map((entry) => {
|
|
516
|
+
const id = String(entry?.id || entry?.artifactId || '');
|
|
517
|
+
if (!id) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
id,
|
|
522
|
+
scope,
|
|
523
|
+
relativePath: normalizeRelativePath(
|
|
524
|
+
entry?.path ||
|
|
525
|
+
entry?.relativePath ||
|
|
526
|
+
entry?.filePath ||
|
|
527
|
+
`${scope}/${id}.${entry?.contentKind === 'json' ? 'json' : 'txt'}`,
|
|
528
|
+
),
|
|
529
|
+
};
|
|
530
|
+
})
|
|
531
|
+
.filter(Boolean);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function tryArtifactRefRequest(requestWorkshop, request) {
|
|
535
|
+
try {
|
|
536
|
+
const result = await requestWorkshop(request);
|
|
537
|
+
return Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
|
|
538
|
+
} catch (error) {
|
|
539
|
+
if (error instanceof CliError && (error.status === 404 || error.status === 405)) {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function materializeArtifact({ rootPath, artifact, fallbackScope, fallbackPath }) {
|
|
547
|
+
const id = String(artifact?.id || artifact?.artifactId || '');
|
|
548
|
+
if (!id) {
|
|
549
|
+
throw new CliError('Artifact payload does not include an id.');
|
|
550
|
+
}
|
|
551
|
+
const scope = normalizeScope(artifact?.scope, fallbackScope);
|
|
552
|
+
const relativePath = normalizeRelativePath(
|
|
553
|
+
artifact?.path || artifact?.relativePath || artifact?.filePath || fallbackPath || `${scope}/${id}.txt`,
|
|
554
|
+
);
|
|
555
|
+
const absolutePath = path.join(rootPath, relativePath);
|
|
556
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
557
|
+
|
|
558
|
+
const content = extractArtifactContent(artifact);
|
|
559
|
+
await fs.writeFile(absolutePath, content.buffer);
|
|
560
|
+
const hash = hashBuffer(content.buffer);
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
id,
|
|
564
|
+
scope,
|
|
565
|
+
relativePath,
|
|
566
|
+
contentKind: content.kind,
|
|
567
|
+
mimeType: artifact?.mimeType || artifact?.mime_type || content.mimeType,
|
|
568
|
+
lastSyncedHash: hash,
|
|
569
|
+
remoteRevisionNo: asNumberOrNull(
|
|
570
|
+
artifact?.latestRevisionNo ?? artifact?.latest_revision_no ?? artifact?.revisionNo ?? artifact?.revision_no,
|
|
571
|
+
),
|
|
572
|
+
remoteVersion: asNumberOrNull(artifact?.version),
|
|
573
|
+
updatedAt: new Date().toISOString(),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function extractArtifactContent(artifact) {
|
|
578
|
+
if (typeof artifact?.bodyText === 'string') {
|
|
579
|
+
return { kind: 'text', mimeType: 'text/plain', buffer: Buffer.from(artifact.bodyText, 'utf8') };
|
|
580
|
+
}
|
|
581
|
+
if (artifact?.bodyJson !== undefined) {
|
|
582
|
+
return {
|
|
583
|
+
kind: 'json',
|
|
584
|
+
mimeType: 'application/json',
|
|
585
|
+
buffer: Buffer.from(`${JSON.stringify(artifact.bodyJson, null, 2)}\n`, 'utf8'),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
if (typeof artifact?.bodyBase64 === 'string') {
|
|
589
|
+
return { kind: 'base64', mimeType: 'application/octet-stream', buffer: Buffer.from(artifact.bodyBase64, 'base64') };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const latest = artifact?.latestRevision || artifact?.latest_revision || null;
|
|
593
|
+
if (latest) {
|
|
594
|
+
return extractArtifactContent(latest);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (Array.isArray(artifact?.revisions) && artifact.revisions.length > 0) {
|
|
598
|
+
return extractArtifactContent(artifact.revisions[artifact.revisions.length - 1]);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (typeof artifact?.content === 'string') {
|
|
602
|
+
return { kind: 'text', mimeType: 'text/plain', buffer: Buffer.from(artifact.content, 'utf8') };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
kind: 'json',
|
|
607
|
+
mimeType: 'application/json',
|
|
608
|
+
buffer: Buffer.from(`${JSON.stringify(artifact, null, 2)}\n`, 'utf8'),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function mergeArtifactStates(existing, downloaded) {
|
|
613
|
+
const map = new Map(existing.map((entry) => [entry.id, entry]));
|
|
614
|
+
for (const artifact of downloaded) {
|
|
615
|
+
map.set(artifact.id, artifact);
|
|
616
|
+
}
|
|
617
|
+
return [...map.values()].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function mergeSkillStates(existing, installed, role) {
|
|
621
|
+
const retained = existing.filter((entry) => entry.role !== role);
|
|
622
|
+
return [...retained, ...installed].sort((left, right) => left.path.localeCompare(right.path));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function buildRevisionRequest(entry, content) {
|
|
626
|
+
if (entry.contentKind === 'json') {
|
|
627
|
+
return {
|
|
628
|
+
expectedVersion: entry.remoteVersion,
|
|
629
|
+
contentKind: 'json',
|
|
630
|
+
baseRevisionNo: entry.remoteRevisionNo,
|
|
631
|
+
bodyJson: JSON.parse(content),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
expectedVersion: entry.remoteVersion,
|
|
636
|
+
contentKind: 'text',
|
|
637
|
+
baseRevisionNo: entry.remoteRevisionNo,
|
|
638
|
+
bodyText: content,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function resolveRequiredSkills({ requestWorkshop, state, role }) {
|
|
643
|
+
const result = await requestWorkshop({
|
|
644
|
+
method: 'POST',
|
|
645
|
+
path: '/api/v1/skills/resolve',
|
|
646
|
+
body: {
|
|
647
|
+
role,
|
|
648
|
+
workspaceId: state.workspace?.id || null,
|
|
649
|
+
projectId: state.project?.id || null,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const entries = Array.isArray(result)
|
|
654
|
+
? result
|
|
655
|
+
: Array.isArray(result?.skills)
|
|
656
|
+
? result.skills
|
|
657
|
+
: Array.isArray(result?.items)
|
|
658
|
+
? result.items
|
|
659
|
+
: [];
|
|
660
|
+
return entries.map(normalizeSkillName).filter(Boolean);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizeSkillName(skill) {
|
|
664
|
+
return String(typeof skill === 'string' ? skill : skill?.name || skill?.key || '').trim();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function detectBundledSkillsRoot() {
|
|
668
|
+
const modulePath = new URL('../skill', import.meta.url);
|
|
669
|
+
return path.resolve(fileURLToPath(modulePath));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function cloneSkillsSource(repoUrl, ref) {
|
|
673
|
+
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-skills-'));
|
|
674
|
+
const cloneTarget = path.join(tempDirectory, 'skills-repo');
|
|
675
|
+
await execFileAsync('git', ['clone', '--depth', '1', '--branch', ref, repoUrl, cloneTarget], {
|
|
676
|
+
windowsHide: true,
|
|
677
|
+
});
|
|
678
|
+
const root = await exists(path.join(cloneTarget, 'skill')) ? path.join(cloneTarget, 'skill') : cloneTarget;
|
|
679
|
+
return {
|
|
680
|
+
source: 'official-repo',
|
|
681
|
+
revision: ref,
|
|
682
|
+
rootPath: root,
|
|
683
|
+
cleanup: async () => fs.rm(tempDirectory, { recursive: true, force: true }),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function findSkillPath(skillsRoot, skillName) {
|
|
688
|
+
const direct = path.join(skillsRoot, skillName);
|
|
689
|
+
if (await exists(path.join(direct, 'SKILL.md'))) {
|
|
690
|
+
return direct;
|
|
691
|
+
}
|
|
692
|
+
const nested = path.join(skillsRoot, 'skills', skillName);
|
|
693
|
+
if (await exists(path.join(nested, 'SKILL.md'))) {
|
|
694
|
+
return nested;
|
|
695
|
+
}
|
|
696
|
+
throw new CliError(`Required skill "${skillName}" was not found in the selected skill source.`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function copyDirectory(source, destination) {
|
|
700
|
+
await fs.mkdir(destination, { recursive: true });
|
|
701
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
702
|
+
for (const entry of entries) {
|
|
703
|
+
const from = path.join(source, entry.name);
|
|
704
|
+
const to = path.join(destination, entry.name);
|
|
705
|
+
if (entry.isDirectory()) {
|
|
706
|
+
await copyDirectory(from, to);
|
|
707
|
+
} else {
|
|
708
|
+
await fs.copyFile(from, to);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function listFiles(rootPath) {
|
|
714
|
+
if (!(await exists(rootPath))) {
|
|
715
|
+
return [];
|
|
716
|
+
}
|
|
717
|
+
const results = [];
|
|
718
|
+
const stack = [rootPath];
|
|
719
|
+
while (stack.length > 0) {
|
|
720
|
+
const current = stack.pop();
|
|
721
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
722
|
+
for (const entry of entries) {
|
|
723
|
+
const next = path.join(current, entry.name);
|
|
724
|
+
if (entry.isDirectory()) {
|
|
725
|
+
stack.push(next);
|
|
726
|
+
} else {
|
|
727
|
+
results.push(next);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return results;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function exists(filePath) {
|
|
735
|
+
try {
|
|
736
|
+
await fs.access(filePath);
|
|
737
|
+
return true;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
if (error.code === 'ENOENT') {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function hashBuffer(buffer) {
|
|
747
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function normalizeScope(value, fallback) {
|
|
751
|
+
if (value === 'workspace' || value === 'project') {
|
|
752
|
+
return value;
|
|
753
|
+
}
|
|
754
|
+
return fallback === 'workspace' ? 'workspace' : 'project';
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function normalizeRelativePath(value) {
|
|
758
|
+
const normalized = String(value || '')
|
|
759
|
+
.replace(/\\/g, '/')
|
|
760
|
+
.replace(/^\/+/, '');
|
|
761
|
+
if (!normalized || normalized === '.' || normalized === '..' || normalized.includes('../')) {
|
|
762
|
+
throw new CliError(`Invalid artifact/skill relative path: ${value}`);
|
|
763
|
+
}
|
|
764
|
+
return normalized;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function extractWorkspaceId(project) {
|
|
768
|
+
return String(project?.workspaceId || project?.workspace_id || project?.workspace?.id || '');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function dedupeById(values) {
|
|
772
|
+
const seen = new Map();
|
|
773
|
+
for (const value of values) {
|
|
774
|
+
seen.set(value.id, value);
|
|
775
|
+
}
|
|
776
|
+
return [...seen.values()];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function asNumberOrNull(value) {
|
|
780
|
+
if (value === null || value === undefined || value === '') {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
const number = Number(value);
|
|
784
|
+
return Number.isFinite(number) ? number : null;
|
|
785
|
+
}
|