@metrio-ai/cli 1.0.1 → 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 +36 -5
- package/dist/commands/sync.js +488 -0
- package/dist/console/client.js +21 -3
- package/dist/index.js +36 -4
- package/dist/manage/input.js +137 -0
- package/dist/manage/output.js +127 -0
- package/dist/manage/project.js +74 -0
- package/dist/manage/registry.js +119 -0
- package/dist/manage/run.js +185 -0
- package/dist/manage/schema.js +152 -0
- 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 +6 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.propFromFlag = exports.kebab = void 0;
|
|
7
|
+
exports.propertyTypes = propertyTypes;
|
|
8
|
+
exports.coercePositional = coercePositional;
|
|
9
|
+
exports.resolveFlags = resolveFlags;
|
|
10
|
+
exports.dropUnaccepted = dropUnaccepted;
|
|
11
|
+
exports.validateArgs = validateArgs;
|
|
12
|
+
const _2020_1 = __importDefault(require("ajv/dist/2020"));
|
|
13
|
+
const errors_1 = require("../errors");
|
|
14
|
+
const input_1 = require("./input");
|
|
15
|
+
const kebab = (prop) => prop.replace(/_/g, '-');
|
|
16
|
+
exports.kebab = kebab;
|
|
17
|
+
const propFromFlag = (flag) => flag.replace(/-/g, '_');
|
|
18
|
+
exports.propFromFlag = propFromFlag;
|
|
19
|
+
/** The JSON types a property accepts, flattening `anyOf`. Empty = unknown (treated as string). */
|
|
20
|
+
function propertyTypes(schema) {
|
|
21
|
+
if (Array.isArray(schema.type))
|
|
22
|
+
return schema.type;
|
|
23
|
+
if (typeof schema.type === 'string')
|
|
24
|
+
return [schema.type];
|
|
25
|
+
return (schema.anyOf ?? []).flatMap(propertyTypes);
|
|
26
|
+
}
|
|
27
|
+
const isBooleanOnly = (types) => types.length > 0 && types.every((t) => t === 'boolean');
|
|
28
|
+
function coerce(name, raw, prop, readStdin) {
|
|
29
|
+
const types = propertyTypes(prop);
|
|
30
|
+
if (raw === 'null' && types.includes('null'))
|
|
31
|
+
return null;
|
|
32
|
+
if (types.includes('boolean') && (raw === 'true' || raw === 'false'))
|
|
33
|
+
return raw === 'true';
|
|
34
|
+
if (types.includes('number') || types.includes('integer')) {
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
if (raw.trim() === '' || Number.isNaN(n))
|
|
37
|
+
throw new errors_1.CliError(`${name} must be a number, got "${raw}"`);
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
if (types.includes('object') || types.includes('array')) {
|
|
41
|
+
if (raw.startsWith('@')) {
|
|
42
|
+
const loaded = (0, input_1.loadInputFile)(raw.slice(1), readStdin);
|
|
43
|
+
if (loaded.kind === 'text')
|
|
44
|
+
throw new errors_1.CliError(`${name}: ${raw.slice(1)} must be .json or .yaml`);
|
|
45
|
+
return loaded.value;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new errors_1.CliError(`${name} must be valid JSON (or @file): ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (types.includes('boolean'))
|
|
55
|
+
throw new errors_1.CliError(`${name} must be true or false, got "${raw}"`);
|
|
56
|
+
if (raw.startsWith('@@'))
|
|
57
|
+
return raw.slice(1);
|
|
58
|
+
return raw.startsWith('@') ? (0, input_1.readText)(raw.slice(1), readStdin) : raw;
|
|
59
|
+
}
|
|
60
|
+
function coercePositional(raw, prop, label) {
|
|
61
|
+
if (!prop || (raw.startsWith('@') && !propertyTypes(prop).some((t) => ['number', 'integer', 'object', 'array'].includes(t))))
|
|
62
|
+
return raw;
|
|
63
|
+
return coerce(label, raw, prop);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Turns `--field value` tokens into tool arguments typed by the schema. A bare boolean flag
|
|
67
|
+
* never consumes the next token unless it is literally true/false.
|
|
68
|
+
*/
|
|
69
|
+
function resolveFlags(tokens, schema, readStdin) {
|
|
70
|
+
const props = schema.properties ?? {};
|
|
71
|
+
const values = {};
|
|
72
|
+
const positionals = [];
|
|
73
|
+
const accepted = () => Object.keys(props).map((p) => `--${(0, exports.kebab)(p)}`).join(', ') || '(none)';
|
|
74
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
75
|
+
const token = tokens[i];
|
|
76
|
+
if (token.flag === undefined) {
|
|
77
|
+
positionals.push(token.positional);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let prop = (0, exports.propFromFlag)(token.flag);
|
|
81
|
+
let negated = false;
|
|
82
|
+
if (!(prop in props) && token.flag.startsWith('no-') && (0, exports.propFromFlag)(token.flag.slice(3)) in props) {
|
|
83
|
+
prop = (0, exports.propFromFlag)(token.flag.slice(3));
|
|
84
|
+
negated = true;
|
|
85
|
+
}
|
|
86
|
+
const propSchema = props[prop];
|
|
87
|
+
if (!propSchema)
|
|
88
|
+
throw new errors_1.CliError(`Unknown option --${token.flag}. Accepted fields: ${accepted()}`);
|
|
89
|
+
const name = `--${(0, exports.kebab)(prop)}`;
|
|
90
|
+
const types = propertyTypes(propSchema);
|
|
91
|
+
if (negated) {
|
|
92
|
+
if (!types.includes('boolean'))
|
|
93
|
+
throw new errors_1.CliError(`--${token.flag}: ${name} is not a boolean`);
|
|
94
|
+
values[prop] = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let raw = token.value;
|
|
98
|
+
if (raw === undefined) {
|
|
99
|
+
const next = tokens[i + 1];
|
|
100
|
+
const nextIsValue = next !== undefined && next.flag === undefined;
|
|
101
|
+
if (isBooleanOnly(types) && !(nextIsValue && (next.positional === 'true' || next.positional === 'false'))) {
|
|
102
|
+
values[prop] = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!nextIsValue)
|
|
106
|
+
throw new errors_1.CliError(`${name} needs a value`);
|
|
107
|
+
raw = next.positional;
|
|
108
|
+
i += 1;
|
|
109
|
+
}
|
|
110
|
+
values[prop] = coerce(name, raw, propSchema, readStdin);
|
|
111
|
+
}
|
|
112
|
+
return { values, positionals };
|
|
113
|
+
}
|
|
114
|
+
/** Removes keys the tool does not declare and null values for properties that do not accept null. */
|
|
115
|
+
function dropUnaccepted(args, schema) {
|
|
116
|
+
const props = schema.properties ?? {};
|
|
117
|
+
const kept = {};
|
|
118
|
+
const dropped = [];
|
|
119
|
+
for (const [key, value] of Object.entries(args)) {
|
|
120
|
+
const prop = props[key];
|
|
121
|
+
const types = prop ? propertyTypes(prop) : [];
|
|
122
|
+
if (!prop || (value === null && types.length > 0 && !types.includes('null')))
|
|
123
|
+
dropped.push(key);
|
|
124
|
+
else
|
|
125
|
+
kept[key] = value;
|
|
126
|
+
}
|
|
127
|
+
return { args: kept, dropped };
|
|
128
|
+
}
|
|
129
|
+
const ajv = new _2020_1.default({ allErrors: true, strict: false });
|
|
130
|
+
const validators = new Map();
|
|
131
|
+
function describeError(error, sources) {
|
|
132
|
+
if (error.keyword === 'required') {
|
|
133
|
+
return ` ${error.params.missingProperty} (missing): is required`;
|
|
134
|
+
}
|
|
135
|
+
const field = error.instancePath.replace(/^\//, '').replace(/\//g, '.') || '(root)';
|
|
136
|
+
const top = field.split('.')[0];
|
|
137
|
+
const source = sources[top] ?? 'input';
|
|
138
|
+
return ` ${field} (${source}): ${error.message ?? 'is invalid'}`;
|
|
139
|
+
}
|
|
140
|
+
/** Validates against the tool's JSON Schema; errors list every field with where it came from. */
|
|
141
|
+
function validateArgs(tool, args, schema, sources) {
|
|
142
|
+
const key = `${tool}:${JSON.stringify(schema)}`;
|
|
143
|
+
let validate = validators.get(key);
|
|
144
|
+
if (!validate) {
|
|
145
|
+
validate = ajv.compile(schema);
|
|
146
|
+
validators.set(key, validate);
|
|
147
|
+
}
|
|
148
|
+
if (validate(args))
|
|
149
|
+
return;
|
|
150
|
+
const lines = Array.from(new Set((validate.errors ?? []).map((e) => describeError(e, sources))));
|
|
151
|
+
throw new errors_1.CliError(`Invalid input for ${tool}:\n${lines.join('\n')}`, 2);
|
|
152
|
+
}
|
|
@@ -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
|
+
}
|