@metrio-ai/cli 1.1.0 → 1.2.0
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 +19 -0
- package/dist/commands/sync.js +488 -0
- package/dist/index.js +8 -0
- package/dist/manage/output.js +6 -1
- package/dist/manage/run.js +33 -1
- package/dist/sync/push.js +329 -0
- package/dist/sync/remote.js +79 -0
- package/dist/sync/render.js +181 -0
- package/dist/sync/status.js +85 -0
- package/dist/sync/workspace.js +419 -0
- package/package.json +3 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.versionContent = versionContent;
|
|
4
|
+
exports.hasPendingVersion = hasPendingVersion;
|
|
5
|
+
exports.pushEntry = pushEntry;
|
|
6
|
+
exports.deleteEntry = deleteEntry;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const errors_1 = require("../errors");
|
|
9
|
+
const remote_1 = require("./remote");
|
|
10
|
+
const render_1 = require("./render");
|
|
11
|
+
const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
12
|
+
async function call(ctx, tool, args) {
|
|
13
|
+
return ((await ctx.client.callTool(tool, defined({ project_slug: ctx.project, ...args }))) ?? {});
|
|
14
|
+
}
|
|
15
|
+
const sha = (text) => (0, crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
16
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
17
|
+
/** Best-effort removal of a resource this push just created, so a failed push leaves no orphan. */
|
|
18
|
+
async function undoCreate(ctx, tool, args, what, error) {
|
|
19
|
+
try {
|
|
20
|
+
await call(ctx, tool, args);
|
|
21
|
+
return new errors_1.CliError(`${message(error)} (the half-created ${what.split(' ')[0]} was removed)`);
|
|
22
|
+
}
|
|
23
|
+
catch (cleanup) {
|
|
24
|
+
return new errors_1.CliError(`${message(error)} (cleanup failed: ${message(cleanup)}; ${what} exists in the console)`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/*
|
|
28
|
+
* When does a push create a version?
|
|
29
|
+
*
|
|
30
|
+
* The console normalizes what it stores: create_version fills in output_format and
|
|
31
|
+
* model_settings defaults and stores an empty model_fallbacks as null; creating or
|
|
32
|
+
* publishing a workflow version pins its prompt_ref tags. So right after a push the
|
|
33
|
+
* re-read remote can differ from the local files although nobody edited anything, and
|
|
34
|
+
* comparing local with remote alone would create a new version on every later push and
|
|
35
|
+
* every --activate.
|
|
36
|
+
*
|
|
37
|
+
* So every push records two hashes of the version content (a prompt's versionPart, a
|
|
38
|
+
* workflow's canonical definition) in state:
|
|
39
|
+
* pushed_content the local content of the last version this workspace pushed
|
|
40
|
+
* remote_content the remote live content re-read right after the last push
|
|
41
|
+
* While both still match ("untouched since the push"), neither side changed version
|
|
42
|
+
* content, and any remaining local/remote difference is console normalization: no new
|
|
43
|
+
* version. The one exception is a pending version: one this workspace pushed
|
|
44
|
+
* (pushed_version_id / pushed_workflow_version) that is not the live one (prompt head /
|
|
45
|
+
* active workflow version), still holding the local content. --activate (or --publish
|
|
46
|
+
* for a workflow) makes it live; a plain push only reports it.
|
|
47
|
+
*/
|
|
48
|
+
/** sha of the version content of rendered/local files; undefined for skills (unversioned). */
|
|
49
|
+
function versionContent(kind, files, where) {
|
|
50
|
+
if (kind === 'prompt')
|
|
51
|
+
return sha((0, render_1.versionPart)((0, render_1.parsePrompt)(files, where)));
|
|
52
|
+
if (kind === 'workflow')
|
|
53
|
+
return sha((0, render_1.canonical)((0, render_1.parseWorkflow)(files, where).definition));
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const untouchedSincePush = (e, localContent, remoteContent) => e.state?.pushed_content === localContent && e.state.remote_content === remoteContent;
|
|
57
|
+
/** The pushed version that is not live, if it still holds the local content. */
|
|
58
|
+
function pendingVersion(e, localContent) {
|
|
59
|
+
const s = e.state;
|
|
60
|
+
if (!s || !e.remote || s.pushed_content !== localContent)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (e.kind === 'prompt') {
|
|
63
|
+
const head = e.remote.data.version?.id;
|
|
64
|
+
return s.pushed_version_id !== undefined && s.pushed_version_id !== head ? s.pushed_version_id : undefined;
|
|
65
|
+
}
|
|
66
|
+
const active = e.remote.data.active_version;
|
|
67
|
+
return s.pushed_workflow_version !== undefined && s.pushed_workflow_version !== active ? s.pushed_workflow_version : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** An unchanged entry holding a pending version (see above) that these flags should make live. */
|
|
70
|
+
function hasPendingVersion(e, options) {
|
|
71
|
+
if (!e.local || !e.remote || !e.state)
|
|
72
|
+
return false;
|
|
73
|
+
const wanted = e.kind === 'prompt' ? options.activate : e.kind === 'workflow' && (options.activate || options.publish);
|
|
74
|
+
if (!wanted)
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
return pendingVersion(e, versionContent(e.kind, e.local, e.key)) !== undefined;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false; // unparsable files: a plain push reports the error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function skillId(ctx, name, where) {
|
|
84
|
+
if (!ctx.skillIds) {
|
|
85
|
+
const rows = await (0, remote_1.fetchAll)(ctx.client, 'list_skills', { project_slug: ctx.project });
|
|
86
|
+
ctx.skillIds = new Map(rows.map((r) => [String(r.name), Number(r.id)]));
|
|
87
|
+
}
|
|
88
|
+
const id = ctx.skillIds.get(name);
|
|
89
|
+
if (id === undefined)
|
|
90
|
+
throw new errors_1.CliError(`${where}/prompt.yaml: unknown skill "${name}"`);
|
|
91
|
+
return id;
|
|
92
|
+
}
|
|
93
|
+
function modelArgs(doc) {
|
|
94
|
+
if (doc.model?.tier)
|
|
95
|
+
return { tier_key: doc.model.tier };
|
|
96
|
+
return { model_provider: doc.model?.provider, model_name: doc.model?.name };
|
|
97
|
+
}
|
|
98
|
+
function assertVersionable(doc, key) {
|
|
99
|
+
const m = doc.model;
|
|
100
|
+
if (!m || !(m.tier || (m.provider && m.name))) {
|
|
101
|
+
throw new errors_1.CliError(`${key}/prompt.yaml: "model" must be { provider, name } or { tier } to create a version`);
|
|
102
|
+
}
|
|
103
|
+
if (!doc.system.trim())
|
|
104
|
+
throw new errors_1.CliError(`${key}/system.md is empty`);
|
|
105
|
+
}
|
|
106
|
+
/** A new version is built from files only: refuse to silently drop what files cannot carry. */
|
|
107
|
+
function assertNoDroppedHeadFields(e) {
|
|
108
|
+
const head = e.remote.data.version;
|
|
109
|
+
const messages = head?.messages;
|
|
110
|
+
const agentDescription = head?.agent_description;
|
|
111
|
+
if ((Array.isArray(messages) && messages.length > 0) || (typeof agentDescription === 'string' && agentDescription !== '')) {
|
|
112
|
+
throw new errors_1.CliError(`${e.key}: the live version has messages/agent_description that files cannot carry; a new version would drop them (use --force to proceed)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function pushPrompt(ctx, e) {
|
|
116
|
+
const local = (0, render_1.parsePrompt)(e.local, e.key);
|
|
117
|
+
const remote = e.remote ? (0, render_1.parsePrompt)(e.remote.files, e.key) : undefined;
|
|
118
|
+
const { activate, message: description } = ctx.options;
|
|
119
|
+
const content = sha((0, render_1.versionPart)(local));
|
|
120
|
+
// See "When does a push create a version?" above.
|
|
121
|
+
let createVersion = !remote;
|
|
122
|
+
let pending;
|
|
123
|
+
if (remote && (0, render_1.versionPart)(local) !== (0, render_1.versionPart)(remote)) {
|
|
124
|
+
if (untouchedSincePush(e, content, sha((0, render_1.versionPart)(remote)))) {
|
|
125
|
+
pending = pendingVersion(e, content);
|
|
126
|
+
createVersion = pending !== undefined && activate;
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
createVersion = true;
|
|
130
|
+
}
|
|
131
|
+
// Everything that can fail locally is checked before any write.
|
|
132
|
+
const ids = new Map();
|
|
133
|
+
for (const name of new Set([...local.skills, ...(remote?.skills ?? [])]))
|
|
134
|
+
ids.set(name, await skillId(ctx, name, e.key));
|
|
135
|
+
if (createVersion)
|
|
136
|
+
assertVersionable(local, e.key);
|
|
137
|
+
if (createVersion && remote && !ctx.options.force)
|
|
138
|
+
assertNoDroppedHeadFields(e);
|
|
139
|
+
let id = e.remote?.id ?? e.state?.id;
|
|
140
|
+
let created = false;
|
|
141
|
+
if (!id) {
|
|
142
|
+
const row = await call(ctx, 'create_prompt', { name: local.name, description: local.description });
|
|
143
|
+
id = String(row.external_id);
|
|
144
|
+
created = true;
|
|
145
|
+
}
|
|
146
|
+
else if (remote && (local.name !== remote.name || (local.description ?? '') !== (remote.description ?? ''))) {
|
|
147
|
+
await call(ctx, 'update_prompt', { prompt_external_id: Number(id), name: local.name, description: local.description ?? '' });
|
|
148
|
+
}
|
|
149
|
+
const promptId = Number(id);
|
|
150
|
+
const result = { id, summary: 'no new version' };
|
|
151
|
+
try {
|
|
152
|
+
if (createVersion) {
|
|
153
|
+
const head = activate || !remote;
|
|
154
|
+
const v = await call(ctx, 'create_version', {
|
|
155
|
+
prompt_external_id: promptId,
|
|
156
|
+
system_prompt: local.system,
|
|
157
|
+
...modelArgs(local),
|
|
158
|
+
output_format: local.output_format,
|
|
159
|
+
model_settings: local.model_settings,
|
|
160
|
+
model_fallbacks: local.model_fallbacks,
|
|
161
|
+
output_schema: local.output_schema,
|
|
162
|
+
description,
|
|
163
|
+
set_as_head: head,
|
|
164
|
+
});
|
|
165
|
+
result.pushed_version_id = Number(v.id);
|
|
166
|
+
result.pushed_serial = Number(v.serial);
|
|
167
|
+
result.pushed_content = content;
|
|
168
|
+
result.versioned = true;
|
|
169
|
+
result.summary = `version ${v.serial}${head ? ' (head)' : ''}`;
|
|
170
|
+
}
|
|
171
|
+
else if (pending !== undefined) {
|
|
172
|
+
result.summary = `already pushed as version ${e.state?.pushed_serial} (not live; use --activate)`;
|
|
173
|
+
}
|
|
174
|
+
const before = remote?.skills ?? [];
|
|
175
|
+
for (const name of local.skills.filter((n) => !before.includes(n))) {
|
|
176
|
+
await call(ctx, 'add_prompt_skill', { prompt_external_id: promptId, skill_id: ids.get(name) });
|
|
177
|
+
}
|
|
178
|
+
for (const name of before.filter((n) => !local.skills.includes(n))) {
|
|
179
|
+
await call(ctx, 'remove_prompt_skill', { prompt_external_id: promptId, skill_id: ids.get(name) });
|
|
180
|
+
}
|
|
181
|
+
let remoteMcp = remote?.mcp ?? [];
|
|
182
|
+
const localIds = local.mcp.map((m) => m.instance_id);
|
|
183
|
+
const replaced = (0, render_1.canonical)(localIds) !== (0, render_1.canonical)(remoteMcp.map((m) => m.instance_id));
|
|
184
|
+
if (replaced) {
|
|
185
|
+
await call(ctx, 'update_prompt', { prompt_external_id: promptId, mcp_instance_ids: localIds });
|
|
186
|
+
// Replacing the list re-creates every binding with enabled_tools = null.
|
|
187
|
+
remoteMcp = local.mcp.map((m) => ({ instance_id: m.instance_id, enabled_tools: null }));
|
|
188
|
+
}
|
|
189
|
+
// Set every allowlist even if one fails: after a replace, each one not set exposes ALL tools.
|
|
190
|
+
const failedIds = [];
|
|
191
|
+
const failures = [];
|
|
192
|
+
for (const m of local.mcp) {
|
|
193
|
+
const was = remoteMcp.find((r) => r.instance_id === m.instance_id);
|
|
194
|
+
const differs = was ? (0, render_1.canonical)(was.enabled_tools) !== (0, render_1.canonical)(m.enabled_tools) : m.enabled_tools !== null;
|
|
195
|
+
if (!differs)
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
await call(ctx, 'set_prompt_mcp_tools', { prompt_external_id: promptId, mcp_instance_id: m.instance_id, enabled_tools: m.enabled_tools });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
failedIds.push(m.instance_id);
|
|
202
|
+
failures.push(`instance ${m.instance_id}: ${message(err)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (failedIds.length) {
|
|
206
|
+
const now = replaced ? 'now expose ALL tools' : 'keep their previous tool allowlist';
|
|
207
|
+
throw new errors_1.CliError(`${e.key}: MCP instance(s) ${failedIds.join(', ')} ${now} (${failures.join('; ')}); ` +
|
|
208
|
+
`fix the cause, then run "metrio push --force ${e.key}"`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (created)
|
|
213
|
+
throw await undoCreate(ctx, 'delete_prompt', { prompt_external_id: promptId }, `prompt ${id}`, error);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
async function pushSkill(ctx, e) {
|
|
219
|
+
const doc = (0, render_1.parseSkill)(e.local['skill.md'], e.key);
|
|
220
|
+
const existing = e.remote?.id ?? e.state?.id;
|
|
221
|
+
const fields = { name: doc.name, description: doc.description, content: doc.content, references: doc.references };
|
|
222
|
+
if (existing) {
|
|
223
|
+
// Fields missing locally are cleared: the console stores '' as null, which renders as absent.
|
|
224
|
+
await call(ctx, 'update_skill', {
|
|
225
|
+
skill_id: Number(existing),
|
|
226
|
+
...fields,
|
|
227
|
+
description: doc.description ?? '',
|
|
228
|
+
references: doc.references ?? [],
|
|
229
|
+
enabled: doc.enabled ?? true,
|
|
230
|
+
});
|
|
231
|
+
return { id: existing, summary: 'updated' };
|
|
232
|
+
}
|
|
233
|
+
const created = await call(ctx, 'create_skill', fields);
|
|
234
|
+
const id = String(created.id);
|
|
235
|
+
ctx.skillIds?.set(doc.name, Number(id));
|
|
236
|
+
if (doc.enabled === false) {
|
|
237
|
+
try {
|
|
238
|
+
await call(ctx, 'update_skill', { skill_id: Number(id), enabled: false });
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
throw await undoCreate(ctx, 'delete_skill', { skill_id: Number(id) }, `skill ${id}`, error);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { id, summary: 'created' };
|
|
245
|
+
}
|
|
246
|
+
function assertValid(result, key) {
|
|
247
|
+
if (result.valid === false) {
|
|
248
|
+
const errors = Array.isArray(result.errors) ? result.errors.map(String).join('; ') : 'invalid definition';
|
|
249
|
+
throw new errors_1.CliError(`${key}: the console rejected the definition: ${errors}`, 2);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function pushWorkflow(ctx, e) {
|
|
253
|
+
// Tracked workflows keep their console id even when the directory name had to be sanitized.
|
|
254
|
+
const wid = e.state?.id ?? e.remote?.id ?? e.key.slice('workflows/'.length);
|
|
255
|
+
const doc = (0, render_1.parseWorkflow)(e.local, e.key);
|
|
256
|
+
if (doc.definition.workflow_id !== wid) {
|
|
257
|
+
const why = e.state || e.remote ? 'the console workflow id' : 'the directory name';
|
|
258
|
+
throw new errors_1.CliError(`${e.key}/definition.json: workflow_id must be "${wid}" (${why})`);
|
|
259
|
+
}
|
|
260
|
+
const remote = e.remote ? (0, render_1.parseWorkflow)(e.remote.files, e.key) : undefined;
|
|
261
|
+
const { activate, publish, message: description } = ctx.options;
|
|
262
|
+
const result = { id: wid, summary: 'no new version' };
|
|
263
|
+
const content = sha((0, render_1.canonical)(doc.definition));
|
|
264
|
+
let created = false;
|
|
265
|
+
if (!remote) {
|
|
266
|
+
assertValid(await call(ctx, 'create_workflow', { definition: doc.definition }), e.key);
|
|
267
|
+
created = true;
|
|
268
|
+
result.pushed_content = content;
|
|
269
|
+
result.versioned = true;
|
|
270
|
+
result.summary = 'created';
|
|
271
|
+
}
|
|
272
|
+
else if ((0, render_1.canonical)(doc.definition) !== (0, render_1.canonical)(remote.definition)) {
|
|
273
|
+
// See "When does a push create a version?" above.
|
|
274
|
+
if (untouchedSincePush(e, content, sha((0, render_1.canonical)(remote.definition)))) {
|
|
275
|
+
const pending = pendingVersion(e, content);
|
|
276
|
+
// Already pushed as a version: only make it live, never create a duplicate.
|
|
277
|
+
if (pending !== undefined && (activate || publish)) {
|
|
278
|
+
await call(ctx, 'publish_workflow_version', { workflow_id: wid, version: pending, activate });
|
|
279
|
+
result.versioned = true;
|
|
280
|
+
result.summary = `version ${pending}${activate ? ' (active)' : ' (published)'}`;
|
|
281
|
+
}
|
|
282
|
+
else if (pending !== undefined)
|
|
283
|
+
result.summary = `already pushed as version ${pending} (draft)`;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const row = await call(ctx, 'create_workflow_version', { workflow_id: wid, definition: doc.definition, description, activate });
|
|
287
|
+
assertValid(row, e.key);
|
|
288
|
+
// The tool answers { valid, version: { version, status, … }, warnings }.
|
|
289
|
+
const version = row.version?.version;
|
|
290
|
+
if (typeof version !== 'number' || !Number.isInteger(version)) {
|
|
291
|
+
throw new errors_1.CliError(`${e.key}: create_workflow_version answered without a version number (${JSON.stringify(row).slice(0, 200)}); ` +
|
|
292
|
+
`a version may have been created: check "metrio workflow version list ${wid}"`);
|
|
293
|
+
}
|
|
294
|
+
if (publish && !activate)
|
|
295
|
+
await call(ctx, 'publish_workflow_version', { workflow_id: wid, version });
|
|
296
|
+
result.pushed_workflow_version = version;
|
|
297
|
+
result.pushed_content = content;
|
|
298
|
+
result.versioned = true;
|
|
299
|
+
result.summary = `version ${version}${activate ? ' (active)' : publish ? ' (published)' : ' (draft)'}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (remote ? doc.enabled !== remote.enabled : doc.enabled === false) {
|
|
303
|
+
try {
|
|
304
|
+
await call(ctx, 'update_workflow', { workflow_id: wid, enabled: doc.enabled });
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (created)
|
|
308
|
+
throw await undoCreate(ctx, 'delete_workflow', { workflow_id: wid }, `workflow ${wid}`, error);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
function pushEntry(ctx, e) {
|
|
315
|
+
if (e.kind === 'prompt')
|
|
316
|
+
return pushPrompt(ctx, e);
|
|
317
|
+
if (e.kind === 'skill')
|
|
318
|
+
return pushSkill(ctx, e);
|
|
319
|
+
return pushWorkflow(ctx, e);
|
|
320
|
+
}
|
|
321
|
+
async function deleteEntry(ctx, e) {
|
|
322
|
+
const id = e.state.id;
|
|
323
|
+
if (e.kind === 'prompt')
|
|
324
|
+
await call(ctx, 'delete_prompt', { prompt_external_id: Number(id) });
|
|
325
|
+
else if (e.kind === 'skill')
|
|
326
|
+
await call(ctx, 'delete_skill', { skill_id: Number(id) });
|
|
327
|
+
else
|
|
328
|
+
await call(ctx, 'delete_workflow', { workflow_id: id });
|
|
329
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchAll = fetchAll;
|
|
4
|
+
exports.fetchRemote = fetchRemote;
|
|
5
|
+
const shared_1 = require("../commands/shared");
|
|
6
|
+
const errors_1 = require("../errors");
|
|
7
|
+
const render_1 = require("./render");
|
|
8
|
+
const CONCURRENCY = 4;
|
|
9
|
+
async function read(client, tool, args) {
|
|
10
|
+
return client.callTool(tool, args, { retry: true });
|
|
11
|
+
}
|
|
12
|
+
/** Every row of a paginated list tool (limit 100 per page). */
|
|
13
|
+
async function fetchAll(client, tool, args) {
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (let pageNo = 1;; pageNo += 1) {
|
|
16
|
+
const data = (await read(client, tool, { ...args, page: pageNo, limit: 100 }));
|
|
17
|
+
if (Array.isArray(data))
|
|
18
|
+
return data;
|
|
19
|
+
if (typeof data !== 'object' || data === null)
|
|
20
|
+
throw new errors_1.CliError(`Unexpected response from ${tool}`);
|
|
21
|
+
const record = data;
|
|
22
|
+
const key = Array.isArray(record.items) ? 'items' : Object.keys(record).find((k) => Array.isArray(record[k]));
|
|
23
|
+
rows.push(...(key ? record[key] : []));
|
|
24
|
+
const pagination = (record.pagination ?? {});
|
|
25
|
+
if (!pagination.total_pages || pageNo >= pagination.total_pages)
|
|
26
|
+
return rows;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function fetchPrompts(client, project) {
|
|
30
|
+
const rows = await fetchAll(client, 'list_prompts', { project_slug: project });
|
|
31
|
+
return (0, shared_1.mapWithConcurrency)(rows, CONCURRENCY, async (row) => {
|
|
32
|
+
const externalId = Number(row.external_id);
|
|
33
|
+
const [version, skills, mcps] = await Promise.all([
|
|
34
|
+
row.head_version_id === null || row.head_version_id === undefined
|
|
35
|
+
? Promise.resolve(null)
|
|
36
|
+
: read(client, 'get_version', { version_id: row.head_version_id }),
|
|
37
|
+
read(client, 'list_prompt_skills', { project_slug: project, prompt_external_id: externalId }),
|
|
38
|
+
read(client, 'list_prompt_mcps', { project_slug: project, prompt_external_id: externalId }),
|
|
39
|
+
]);
|
|
40
|
+
const data = {
|
|
41
|
+
external_id: String(row.external_id),
|
|
42
|
+
name: String(row.name),
|
|
43
|
+
description: row.description ?? null,
|
|
44
|
+
version: version,
|
|
45
|
+
skills: skills.map((s) => String(s.name)),
|
|
46
|
+
mcp: mcps.map((m) => ({ instance_id: Number(m.id), enabled_tools: (m.enabled_tools ?? null) })),
|
|
47
|
+
};
|
|
48
|
+
return { kind: 'prompt', id: data.external_id, name: data.name, files: (0, render_1.renderPrompt)(data), data };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function fetchSkills(client, project) {
|
|
52
|
+
const rows = await fetchAll(client, 'list_skills', { project_slug: project });
|
|
53
|
+
return (0, shared_1.mapWithConcurrency)(rows, CONCURRENCY, async (row) => {
|
|
54
|
+
const data = (await read(client, 'get_skill', { project_slug: project, skill_id: row.id }));
|
|
55
|
+
return { kind: 'skill', id: String(data.id), name: data.name, files: (0, render_1.renderSkill)(data), data };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function fetchWorkflows(client, project) {
|
|
59
|
+
const rows = await fetchAll(client, 'list_workflows', { project_slug: project });
|
|
60
|
+
return (0, shared_1.mapWithConcurrency)(rows, CONCURRENCY, async (row) => {
|
|
61
|
+
const w = (await read(client, 'get_workflow', { project_slug: project, workflow_id: row.workflow_id }));
|
|
62
|
+
const active = [w.active_version, w.version].find((v) => typeof v === 'number');
|
|
63
|
+
const data = {
|
|
64
|
+
workflow_id: String(w.workflow_id), enabled: w.enabled !== false, definition: w.definition, active_version: active ?? null,
|
|
65
|
+
};
|
|
66
|
+
return { kind: 'workflow', id: data.workflow_id, name: data.workflow_id, files: (0, render_1.renderWorkflow)(data), data };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Rendered remote resources of the given kinds, in kind order. */
|
|
70
|
+
async function fetchRemote(client, project, kinds) {
|
|
71
|
+
const out = [];
|
|
72
|
+
if (kinds.includes('prompt'))
|
|
73
|
+
out.push(...(await fetchPrompts(client, project)));
|
|
74
|
+
if (kinds.includes('skill'))
|
|
75
|
+
out.push(...(await fetchSkills(client, project)));
|
|
76
|
+
if (kinds.includes('workflow'))
|
|
77
|
+
out.push(...(await fetchWorkflows(client, project)));
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonical = canonical;
|
|
4
|
+
exports.renderPrompt = renderPrompt;
|
|
5
|
+
exports.parsePrompt = parsePrompt;
|
|
6
|
+
exports.versionPart = versionPart;
|
|
7
|
+
exports.renderSkill = renderSkill;
|
|
8
|
+
exports.parseSkill = parseSkill;
|
|
9
|
+
exports.renderWorkflow = renderWorkflow;
|
|
10
|
+
exports.parseWorkflow = parseWorkflow;
|
|
11
|
+
const yaml_1 = require("yaml");
|
|
12
|
+
const errors_1 = require("../errors");
|
|
13
|
+
const VERSION_FIELDS = ['output_format', 'model_settings', 'model_fallbacks', 'output_schema'];
|
|
14
|
+
const present = (v) => v !== null && v !== undefined;
|
|
15
|
+
/** JSON with object keys sorted at every level, for order-insensitive comparison. */
|
|
16
|
+
function canonical(value) {
|
|
17
|
+
return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
|
|
18
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]))
|
|
19
|
+
: v);
|
|
20
|
+
}
|
|
21
|
+
const sortedMcp = (mcp) => [...mcp].sort((a, b) => a.instance_id - b.instance_id).map((m) => ({ instance_id: m.instance_id, enabled_tools: m.enabled_tools }));
|
|
22
|
+
function renderPrompt(p) {
|
|
23
|
+
const doc = { name: p.name };
|
|
24
|
+
if (p.description)
|
|
25
|
+
doc.description = p.description;
|
|
26
|
+
const v = p.version;
|
|
27
|
+
if (v) {
|
|
28
|
+
const tier = v.model_tier?.tier_key;
|
|
29
|
+
doc.model = tier ? { tier } : { provider: v.model_provider, name: v.model_name };
|
|
30
|
+
for (const field of VERSION_FIELDS)
|
|
31
|
+
if (present(v[field]))
|
|
32
|
+
doc[field] = v[field];
|
|
33
|
+
}
|
|
34
|
+
if (p.skills.length)
|
|
35
|
+
doc.skills = [...p.skills].sort();
|
|
36
|
+
if (p.mcp.length)
|
|
37
|
+
doc.mcp = sortedMcp(p.mcp);
|
|
38
|
+
return { 'prompt.yaml': (0, yaml_1.stringify)(doc), 'system.md': v ? String(v.system_prompt ?? '') : '' };
|
|
39
|
+
}
|
|
40
|
+
function yamlObject(text, where) {
|
|
41
|
+
let doc;
|
|
42
|
+
try {
|
|
43
|
+
doc = (0, yaml_1.parse)(text);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new errors_1.CliError(`${where} is not valid YAML: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
if (doc === null || doc === undefined)
|
|
49
|
+
return {};
|
|
50
|
+
if (typeof doc !== 'object' || Array.isArray(doc))
|
|
51
|
+
throw new errors_1.CliError(`${where} must be a YAML mapping`);
|
|
52
|
+
return doc;
|
|
53
|
+
}
|
|
54
|
+
function parsePrompt(files, where) {
|
|
55
|
+
const file = `${where}/prompt.yaml`;
|
|
56
|
+
const raw = yamlObject(files['prompt.yaml'] ?? '', file);
|
|
57
|
+
if (typeof raw.name !== 'string' || !raw.name)
|
|
58
|
+
throw new errors_1.CliError(`${file}: "name" is required`);
|
|
59
|
+
if (raw.skills !== undefined && !Array.isArray(raw.skills))
|
|
60
|
+
throw new errors_1.CliError(`${file}: "skills" must be a list`);
|
|
61
|
+
if (raw.mcp !== undefined && !Array.isArray(raw.mcp))
|
|
62
|
+
throw new errors_1.CliError(`${file}: "mcp" must be a list`);
|
|
63
|
+
if (raw.description !== undefined && raw.description !== null && typeof raw.description !== 'string') {
|
|
64
|
+
throw new errors_1.CliError(`${file}: "description" must be a string`);
|
|
65
|
+
}
|
|
66
|
+
(raw.skills ?? []).forEach((s, i) => {
|
|
67
|
+
if (typeof s !== 'string')
|
|
68
|
+
throw new errors_1.CliError(`${file}: skills[${i}] must be a string`);
|
|
69
|
+
});
|
|
70
|
+
if (raw.model !== undefined && raw.model !== null) {
|
|
71
|
+
const m = raw.model;
|
|
72
|
+
const ok = typeof m === 'object' &&
|
|
73
|
+
!Array.isArray(m) &&
|
|
74
|
+
Object.entries(m).every(([k, v]) => ['provider', 'name', 'tier'].includes(k) && typeof v === 'string');
|
|
75
|
+
if (!ok)
|
|
76
|
+
throw new errors_1.CliError(`${file}: "model" must be { provider, name } or { tier }`);
|
|
77
|
+
}
|
|
78
|
+
(raw.mcp ?? []).forEach((m, i) => {
|
|
79
|
+
const e = m;
|
|
80
|
+
const tools = e?.enabled_tools;
|
|
81
|
+
const ok = e !== null &&
|
|
82
|
+
typeof e === 'object' &&
|
|
83
|
+
!Array.isArray(e) &&
|
|
84
|
+
Number.isInteger(e.instance_id) &&
|
|
85
|
+
(tools === undefined || tools === null || (Array.isArray(tools) && tools.every((t) => typeof t === 'string')));
|
|
86
|
+
if (!ok) {
|
|
87
|
+
throw new errors_1.CliError(`${file}: mcp[${i}] must be { instance_id: <integer>, enabled_tools: <list of strings> | null }`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const doc = {
|
|
91
|
+
name: raw.name,
|
|
92
|
+
skills: [...(raw.skills ?? [])].sort(),
|
|
93
|
+
mcp: sortedMcp((raw.mcp ?? []).map((m) => ({
|
|
94
|
+
instance_id: Number(m.instance_id),
|
|
95
|
+
enabled_tools: (m.enabled_tools ?? null),
|
|
96
|
+
}))),
|
|
97
|
+
system: files['system.md'] ?? '',
|
|
98
|
+
};
|
|
99
|
+
if (typeof raw.description === 'string' && raw.description)
|
|
100
|
+
doc.description = raw.description;
|
|
101
|
+
if (raw.model)
|
|
102
|
+
doc.model = raw.model;
|
|
103
|
+
for (const field of VERSION_FIELDS)
|
|
104
|
+
if (present(raw[field]))
|
|
105
|
+
doc[field] = raw[field];
|
|
106
|
+
return doc;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Everything that defines a prompt version; equal strings mean no new version is needed.
|
|
110
|
+
* For comparison and hashing only: CRLF counts as LF, like the status hashes (what a push
|
|
111
|
+
* sends is the file content as is).
|
|
112
|
+
*/
|
|
113
|
+
function versionPart(doc) {
|
|
114
|
+
return canonical({
|
|
115
|
+
model: doc.model ?? null,
|
|
116
|
+
output_format: doc.output_format ?? null,
|
|
117
|
+
model_settings: doc.model_settings ?? null,
|
|
118
|
+
model_fallbacks: doc.model_fallbacks ?? null,
|
|
119
|
+
output_schema: doc.output_schema ?? null,
|
|
120
|
+
system: doc.system.replace(/\r\n/g, '\n'),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function renderSkill(s) {
|
|
124
|
+
const front = { name: s.name };
|
|
125
|
+
if (s.description !== null && s.description !== undefined)
|
|
126
|
+
front.description = s.description;
|
|
127
|
+
if (Array.isArray(s.references) && s.references.length)
|
|
128
|
+
front.references = s.references;
|
|
129
|
+
front.enabled = s.enabled;
|
|
130
|
+
return { 'skill.md': `---\n${(0, yaml_1.stringify)(front)}---\n\n${s.content}` };
|
|
131
|
+
}
|
|
132
|
+
function parseSkill(text, where) {
|
|
133
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n\r?\n?([\s\S]*)$/.exec(text.replace(/^\ufeff/, ''));
|
|
134
|
+
if (!match)
|
|
135
|
+
throw new errors_1.CliError(`${where}: needs YAML frontmatter with "name" (---\\nname: …\\n---)`);
|
|
136
|
+
const front = yamlObject(match[1], `${where} frontmatter`);
|
|
137
|
+
if (typeof front.name !== 'string' || !front.name)
|
|
138
|
+
throw new errors_1.CliError(`${where}: needs YAML frontmatter with "name"`);
|
|
139
|
+
const doc = { name: front.name, content: match[2] };
|
|
140
|
+
if (front.description !== undefined && front.description !== null) {
|
|
141
|
+
if (typeof front.description !== 'string')
|
|
142
|
+
throw new errors_1.CliError(`${where}: "description" must be a string`);
|
|
143
|
+
doc.description = front.description;
|
|
144
|
+
}
|
|
145
|
+
if (front.references !== undefined && front.references !== null) {
|
|
146
|
+
if (!Array.isArray(front.references))
|
|
147
|
+
throw new errors_1.CliError(`${where}: "references" must be a list`);
|
|
148
|
+
doc.references = front.references;
|
|
149
|
+
}
|
|
150
|
+
if (front.enabled !== undefined) {
|
|
151
|
+
if (typeof front.enabled !== 'boolean')
|
|
152
|
+
throw new errors_1.CliError(`${where}: "enabled" must be true or false`);
|
|
153
|
+
doc.enabled = front.enabled;
|
|
154
|
+
}
|
|
155
|
+
return doc;
|
|
156
|
+
}
|
|
157
|
+
function renderWorkflow(w) {
|
|
158
|
+
return {
|
|
159
|
+
'workflow.yaml': (0, yaml_1.stringify)({ enabled: w.enabled }),
|
|
160
|
+
'definition.json': `${JSON.stringify(w.definition, null, 2)}\n`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function parseWorkflow(files, where) {
|
|
164
|
+
if (files['definition.json'] === undefined)
|
|
165
|
+
throw new errors_1.CliError(`${where}/definition.json is missing`);
|
|
166
|
+
let definition;
|
|
167
|
+
try {
|
|
168
|
+
definition = JSON.parse(files['definition.json']);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
throw new errors_1.CliError(`${where}/definition.json is not valid JSON: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) {
|
|
174
|
+
throw new errors_1.CliError(`${where}/definition.json must be a JSON object`);
|
|
175
|
+
}
|
|
176
|
+
const meta = yamlObject(files['workflow.yaml'] ?? '', `${where}/workflow.yaml`);
|
|
177
|
+
if (meta.enabled !== undefined && typeof meta.enabled !== 'boolean') {
|
|
178
|
+
throw new errors_1.CliError(`${where}/workflow.yaml: "enabled" must be true or false`);
|
|
179
|
+
}
|
|
180
|
+
return { enabled: meta.enabled === undefined ? true : meta.enabled, definition: definition };
|
|
181
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeEntries = computeEntries;
|
|
4
|
+
const workspace_1 = require("./workspace");
|
|
5
|
+
function proposedKey(r) {
|
|
6
|
+
if (r.kind === 'workflow') {
|
|
7
|
+
const unsafe = /[/\\:\0]/.test(r.id) || r.id === '.' || r.id === '..';
|
|
8
|
+
return `${workspace_1.KIND_DIR.workflow}/${unsafe ? 'workflow-' + (0, workspace_1.slugify)(r.id, 'workflow') : r.id}`;
|
|
9
|
+
}
|
|
10
|
+
const slug = (0, workspace_1.slugify)(r.name, `${r.kind}-${r.id}`);
|
|
11
|
+
return r.kind === 'skill' ? `${workspace_1.KIND_DIR.skill}/${slug}.md` : `${workspace_1.KIND_DIR.prompt}/${slug}`;
|
|
12
|
+
}
|
|
13
|
+
function withSuffix(key, id) {
|
|
14
|
+
return key.endsWith('.md') ? `${key.slice(0, -3)}-${id}.md` : `${key}-${id}`;
|
|
15
|
+
}
|
|
16
|
+
function computeEntries(root, state, remote, kinds) {
|
|
17
|
+
const tracked = Object.entries(state.resources).filter(([, s]) => kinds.includes(s.kind));
|
|
18
|
+
const remoteAt = new Map();
|
|
19
|
+
const taken = new Set(tracked.map(([key]) => key));
|
|
20
|
+
for (const r of remote) {
|
|
21
|
+
const known = tracked.find(([, s]) => s.kind === r.kind && s.id === r.id);
|
|
22
|
+
let key = known ? known[0] : proposedKey(r);
|
|
23
|
+
if (!known && (taken.has(key) || remoteAt.has(key))) {
|
|
24
|
+
const base = key;
|
|
25
|
+
key = withSuffix(base, (0, workspace_1.slugify)(r.id, 'id'));
|
|
26
|
+
for (let n = 2; taken.has(key) || remoteAt.has(key); n += 1)
|
|
27
|
+
key = withSuffix(base, `${(0, workspace_1.slugify)(r.id, 'id')}-${n}`);
|
|
28
|
+
}
|
|
29
|
+
taken.add(key);
|
|
30
|
+
remoteAt.set(key, r);
|
|
31
|
+
}
|
|
32
|
+
const locals = (0, workspace_1.listLocal)(root, kinds);
|
|
33
|
+
const keys = new Set([...remoteAt.keys(), ...tracked.map(([k]) => k), ...locals.map((l) => l.key)]);
|
|
34
|
+
const entries = [];
|
|
35
|
+
for (const key of [...keys].sort()) {
|
|
36
|
+
const s = state.resources[key];
|
|
37
|
+
const r = remoteAt.get(key);
|
|
38
|
+
const kind = (s?.kind ?? r?.kind ?? locals.find((l) => l.key === key).kind);
|
|
39
|
+
if (!kinds.includes(kind))
|
|
40
|
+
continue;
|
|
41
|
+
const local = (0, workspace_1.readLocal)(root, key, kind);
|
|
42
|
+
let status;
|
|
43
|
+
let refresh = false;
|
|
44
|
+
if (local && r && (0, workspace_1.hashFiles)(local) === (0, workspace_1.hashFiles)(r.files)) {
|
|
45
|
+
// Converged (e.g. a pull interrupted after writing files, or the same edit made on
|
|
46
|
+
// both sides): nothing to sync whatever the recorded baseline says.
|
|
47
|
+
status = 'unchanged';
|
|
48
|
+
refresh = !s || s.base !== (0, workspace_1.hashFiles)(local) || s.remote !== (0, workspace_1.hashFiles)(r.files);
|
|
49
|
+
}
|
|
50
|
+
else if (local && s && r) {
|
|
51
|
+
const localChanged = (0, workspace_1.hashFiles)(local) !== s.base;
|
|
52
|
+
const remoteChanged = (0, workspace_1.hashFiles)(r.files) !== s.remote;
|
|
53
|
+
status = localChanged && remoteChanged ? 'conflict' : localChanged ? 'modified' : remoteChanged ? 'remote-changed' : 'unchanged';
|
|
54
|
+
}
|
|
55
|
+
else if (local && !s)
|
|
56
|
+
status = r ? 'conflict' : 'new';
|
|
57
|
+
else if (!local && s && r)
|
|
58
|
+
status = 'deleted';
|
|
59
|
+
else if (s && !r)
|
|
60
|
+
status = 'remote-deleted';
|
|
61
|
+
else
|
|
62
|
+
status = 'remote-new';
|
|
63
|
+
entries.push({ key, kind, status, local, remote: r, state: s, ...(refresh ? { refresh } : {}) });
|
|
64
|
+
}
|
|
65
|
+
return detectRenames(entries);
|
|
66
|
+
}
|
|
67
|
+
/** A new directory identical to exactly one deleted directory of the same kind is a rename. */
|
|
68
|
+
function detectRenames(entries) {
|
|
69
|
+
const dropped = new Set();
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
if (e.status === 'new') {
|
|
73
|
+
const h = (0, workspace_1.hashFiles)(e.local);
|
|
74
|
+
const matches = entries.filter((d) => d.status === 'deleted' && d.kind === e.kind && !dropped.has(d) && d.state.base === h);
|
|
75
|
+
const newMatches = entries.filter((n) => n.status === 'new' && n.kind === e.kind && (0, workspace_1.hashFiles)(n.local) === h);
|
|
76
|
+
if (matches.length === 1 && newMatches.length === 1) {
|
|
77
|
+
const d = matches[0];
|
|
78
|
+
dropped.add(d);
|
|
79
|
+
dropped.add(e);
|
|
80
|
+
out.push({ key: e.key, kind: e.kind, status: 'renamed', local: e.local, remote: d.remote, state: d.state, from: d.key });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...entries.filter((e) => !dropped.has(e)), ...out].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
85
|
+
}
|