@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
package/README.md
CHANGED
|
@@ -48,3 +48,22 @@ For CI set `METRIO_TOKEN` (and `METRIO_CONSOLE_URL` if not `https://app.metrio.a
|
|
|
48
48
|
Exit codes: `0` ok, `1` failure, `2` usage or validation error, `3` not logged in / not allowed. Codes 2 and 3 apply to the account and resource-management commands (`login`, `logout`, `whoami`, `tools`, `call`, and every `metrio <group> ...` command); the run commands (`chat`, `workflow run` / `run-batch`, `prompt run-batch` / `create-version`) keep their original behavior (errors exit 1). A workflow DAG the console rejects exits 2; a partially failed `version create-batch` exits 1.
|
|
49
49
|
|
|
50
50
|
Migrating from `@metrio-ai/client`'s bundled CLI: if the old CLI was installed globally, run `npm rm -g @metrio-ai/client` before `npm i -g @metrio-ai/cli` (both install a `metrio` bin). Every command and flag is unchanged. Bare `metrio --messages …` still works but is deprecated in favor of `metrio chat --messages …`.
|
|
51
|
+
|
|
52
|
+
## Sync a project as files
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
mkdir my-project && cd my-project
|
|
56
|
+
metrio pull --project my-project # metrio.yaml, prompts/, skills/, workflows/, .metrio/state.json
|
|
57
|
+
$EDITOR prompts/pick-products/system.md
|
|
58
|
+
metrio diff # modified / remote-changed / conflict / new … with unified diffs
|
|
59
|
+
metrio push -m "tighter wording" # new prompt version (not live)
|
|
60
|
+
metrio push --activate # …or make it live right away
|
|
61
|
+
metrio version list pick-products # prompt directories work wherever a prompt id is expected
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`push` creates new versions and never overwrites changes made in the console since your last `pull` (use `--force` deliberately: local wins, bindings and MCP lists are replaced, and resources changed in the console may be pruned). Workflow pushes create draft versions (`--publish`, `--activate`). Deleting files deletes nothing remotely unless you pass `--prune --yes`. Commit `.metrio/state.json` with the files. Run `metrio pull --help` for every option.
|
|
65
|
+
|
|
66
|
+
- A second plain `metrio push` does not create duplicates ("already pushed as version N"); `metrio push --activate` (prompts) or `--publish` / `--activate` (workflows) makes the pending pushed version live.
|
|
67
|
+
- `pull` adopts untracked local files that are identical to the console. `pull` and `push` report per-resource failures, exit 1, and still save state for the resources that succeeded.
|
|
68
|
+
- Renaming a prompt or skill directory is detected by content (status `renamed`); pull/push move the state entry. Workflow directories are named by workflow id and cannot be renamed locally; push refuses it.
|
|
69
|
+
- Known limitation: renaming AND editing a prompt/skill directory in one go looks like a delete plus a new resource (push would create a second one; `--prune --yes` would delete the old one). Rename first, sync, then edit.
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SYNC_HELP = void 0;
|
|
37
|
+
exports.parseSyncArgs = parseSyncArgs;
|
|
38
|
+
exports.openWorkspace = openWorkspace;
|
|
39
|
+
exports.filterEntries = filterEntries;
|
|
40
|
+
exports.loadEntries = loadEntries;
|
|
41
|
+
exports.runDiffCommand = runDiffCommand;
|
|
42
|
+
exports.runPullCommand = runPullCommand;
|
|
43
|
+
exports.runPushCommand = runPushCommand;
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const diff_1 = require("diff");
|
|
46
|
+
const client_1 = require("../console/client");
|
|
47
|
+
const errors_1 = require("../errors");
|
|
48
|
+
const push_1 = require("../sync/push");
|
|
49
|
+
const remote_1 = require("../sync/remote");
|
|
50
|
+
const status_1 = require("../sync/status");
|
|
51
|
+
const workspace_1 = require("../sync/workspace");
|
|
52
|
+
const ONLY = { prompts: 'prompt', skills: 'skill', workflows: 'workflow' };
|
|
53
|
+
function parseSyncArgs(argv) {
|
|
54
|
+
const o = {
|
|
55
|
+
paths: [], kinds: ['prompt', 'skill', 'workflow'], force: false, activate: false, publish: false,
|
|
56
|
+
prune: false, yes: false, dryRun: false, json: false,
|
|
57
|
+
};
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
const value = () => {
|
|
61
|
+
const v = argv[i + 1];
|
|
62
|
+
if (v === undefined)
|
|
63
|
+
throw new errors_1.CliError(`${arg} needs a value`);
|
|
64
|
+
i += 1;
|
|
65
|
+
return v;
|
|
66
|
+
};
|
|
67
|
+
if (arg === '--only') {
|
|
68
|
+
const kind = ONLY[value()];
|
|
69
|
+
if (!kind)
|
|
70
|
+
throw new errors_1.CliError('--only must be prompts, skills or workflows');
|
|
71
|
+
o.kinds = [kind];
|
|
72
|
+
}
|
|
73
|
+
else if (arg === '-m' || arg === '--message')
|
|
74
|
+
o.message = value();
|
|
75
|
+
else if (arg === '--project')
|
|
76
|
+
o.project = value();
|
|
77
|
+
else if (arg === '--force')
|
|
78
|
+
o.force = true;
|
|
79
|
+
else if (arg === '--activate')
|
|
80
|
+
o.activate = true;
|
|
81
|
+
else if (arg === '--publish')
|
|
82
|
+
o.publish = true;
|
|
83
|
+
else if (arg === '--prune')
|
|
84
|
+
o.prune = true;
|
|
85
|
+
else if (arg === '--yes')
|
|
86
|
+
o.yes = true;
|
|
87
|
+
else if (arg === '--dry-run')
|
|
88
|
+
o.dryRun = true;
|
|
89
|
+
else if (arg === '--json')
|
|
90
|
+
o.json = true;
|
|
91
|
+
else if (arg.startsWith('-'))
|
|
92
|
+
throw new errors_1.CliError(`Unknown option ${arg}`);
|
|
93
|
+
else
|
|
94
|
+
o.paths.push(arg);
|
|
95
|
+
}
|
|
96
|
+
return o;
|
|
97
|
+
}
|
|
98
|
+
async function openWorkspace() {
|
|
99
|
+
const root = (0, workspace_1.findRoot)();
|
|
100
|
+
if (!root)
|
|
101
|
+
throw new errors_1.CliError('Not in a metrio workspace (no metrio.yaml). Run "metrio pull --project <slug>" first.');
|
|
102
|
+
return { root, project: (0, workspace_1.readProject)(root), client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
|
|
103
|
+
}
|
|
104
|
+
/** Entries whose key equals or sits under one of the given paths (relative to the workspace root). */
|
|
105
|
+
function filterEntries(entries, root, paths) {
|
|
106
|
+
if (!paths.length)
|
|
107
|
+
return entries;
|
|
108
|
+
const wanted = paths.map((p) => {
|
|
109
|
+
const rel = path.relative(root, path.resolve(p));
|
|
110
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
111
|
+
throw new errors_1.CliError(`${p} is outside the metrio workspace`);
|
|
112
|
+
}
|
|
113
|
+
return rel.split(path.sep).join('/').replace(/\/+$/, '');
|
|
114
|
+
});
|
|
115
|
+
const matches = (e, w) => w === '' || e.key === w || e.key.startsWith(`${w}/`) || w.startsWith(`${e.key}/`);
|
|
116
|
+
wanted.forEach((w, i) => {
|
|
117
|
+
if (!entries.some((e) => matches(e, w)))
|
|
118
|
+
throw new errors_1.CliError(`${paths[i]} matches nothing in this workspace`);
|
|
119
|
+
});
|
|
120
|
+
return entries.filter((e) => wanted.some((w) => matches(e, w)));
|
|
121
|
+
}
|
|
122
|
+
async function loadEntries(ctx, opts) {
|
|
123
|
+
const state = (0, workspace_1.readState)(ctx.root, ctx.project);
|
|
124
|
+
const remote = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, opts.kinds);
|
|
125
|
+
return filterEntries((0, status_1.computeEntries)(ctx.root, state, remote, opts.kinds), ctx.root, opts.paths);
|
|
126
|
+
}
|
|
127
|
+
/** Console-sourced text must not carry terminal control sequences. */
|
|
128
|
+
function safe(text) {
|
|
129
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
|
|
130
|
+
}
|
|
131
|
+
exports.SYNC_HELP = `metrio pull | diff | push - sync prompts, skills and workflows with local files
|
|
132
|
+
|
|
133
|
+
metrio pull --project <slug> First pull: creates metrio.yaml here and writes:
|
|
134
|
+
prompts/<name>/prompt.yaml + system.md
|
|
135
|
+
skills/<name>.md
|
|
136
|
+
workflows/<id>/workflow.yaml + definition.json
|
|
137
|
+
metrio pull [paths…] Update files from the console (keeps local edits unless --force)
|
|
138
|
+
metrio diff [paths…] Show what differs: modified, remote-changed, conflict, new, …
|
|
139
|
+
metrio push [paths…] Send local edits: new prompt version (not live unless --activate),
|
|
140
|
+
skill update, workflow version (draft; --publish / --activate)
|
|
141
|
+
|
|
142
|
+
Options:
|
|
143
|
+
--only prompts|skills|workflows Limit to one kind
|
|
144
|
+
--force pull: overwrite local edits; push: push over console changes
|
|
145
|
+
(local wins — bindings and MCP list are replaced), allow
|
|
146
|
+
pruning resources changed in the console, and allow a prompt
|
|
147
|
+
version that drops the live messages/agent_description
|
|
148
|
+
-m, --message <text> push: description of the new versions
|
|
149
|
+
--activate push: make new prompt versions head / workflow versions live
|
|
150
|
+
--publish push: publish new workflow versions without activating
|
|
151
|
+
--prune --yes push: delete console resources whose local files were removed
|
|
152
|
+
--dry-run Print the plan without changing anything
|
|
153
|
+
--json diff: machine-readable statuses
|
|
154
|
+
|
|
155
|
+
State lives in .metrio/state.json (commit it with the files).
|
|
156
|
+
`;
|
|
157
|
+
const wantsHelp = (argv) => argv.includes('-h') || argv.includes('--help');
|
|
158
|
+
function patch(e) {
|
|
159
|
+
const local = e.local ?? {};
|
|
160
|
+
const remote = e.remote?.files ?? {};
|
|
161
|
+
return [...new Set([...Object.keys(local), ...Object.keys(remote)])]
|
|
162
|
+
.sort()
|
|
163
|
+
.filter((f) => local[f] !== remote[f])
|
|
164
|
+
.map((f) => (0, diff_1.createTwoFilesPatch)(`remote/${e.key}/${f}`, `local/${e.key}/${f}`, remote[f] ?? '', local[f] ?? ''))
|
|
165
|
+
.join('');
|
|
166
|
+
}
|
|
167
|
+
async function runDiffCommand(argv) {
|
|
168
|
+
if (wantsHelp(argv)) {
|
|
169
|
+
process.stdout.write(exports.SYNC_HELP);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const opts = parseSyncArgs(argv);
|
|
173
|
+
const ctx = await openWorkspace();
|
|
174
|
+
const entries = await loadEntries(ctx, opts);
|
|
175
|
+
if (opts.json) {
|
|
176
|
+
const rows = entries.map((e) => ({ key: e.key, kind: e.kind, status: e.status, ...(e.from ? { from: e.from } : {}) }));
|
|
177
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let unchanged = 0;
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
if (e.status === 'unchanged') {
|
|
183
|
+
unchanged += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
process.stdout.write(safe(`${e.status.padEnd(15)}${e.key}${e.from ? ` (from ${e.from})` : ''}\n`));
|
|
187
|
+
if (e.status === 'modified' || e.status === 'conflict' || e.status === 'remote-changed')
|
|
188
|
+
process.stdout.write(safe(patch(e)));
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(`${unchanged} unchanged\n`);
|
|
191
|
+
}
|
|
192
|
+
async function runPullCommand(argv) {
|
|
193
|
+
if (wantsHelp(argv)) {
|
|
194
|
+
process.stdout.write(exports.SYNC_HELP);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const opts = parseSyncArgs(argv);
|
|
198
|
+
const found = (0, workspace_1.findRoot)();
|
|
199
|
+
let root = found;
|
|
200
|
+
if (!root) {
|
|
201
|
+
if (!opts.project)
|
|
202
|
+
throw new errors_1.CliError('Not in a metrio workspace. Run "metrio pull --project <slug>" to start one here.');
|
|
203
|
+
root = process.cwd(); // metrio.yaml is only created once the console fetch succeeded
|
|
204
|
+
}
|
|
205
|
+
else if (opts.project && opts.project !== (0, workspace_1.readProject)(root)) {
|
|
206
|
+
(0, workspace_1.initRoot)(root, opts.project); // throws: metrio.yaml names another project
|
|
207
|
+
}
|
|
208
|
+
const ctx = { root, project: found ? (0, workspace_1.readProject)(root) : opts.project, client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
|
|
209
|
+
// A fresh workspace is locked once metrio.yaml exists (below).
|
|
210
|
+
let release = found && !opts.dryRun ? (0, workspace_1.acquireLock)(root) : undefined;
|
|
211
|
+
try {
|
|
212
|
+
const state = (0, workspace_1.readState)(ctx.root, ctx.project);
|
|
213
|
+
const entries = await loadEntries(ctx, opts);
|
|
214
|
+
if (!found && !opts.dryRun) {
|
|
215
|
+
(0, workspace_1.initRoot)(root, ctx.project);
|
|
216
|
+
release = (0, workspace_1.acquireLock)(root);
|
|
217
|
+
}
|
|
218
|
+
let unchanged = 0;
|
|
219
|
+
let failed = 0;
|
|
220
|
+
const record = (e, files) => {
|
|
221
|
+
const h = (0, workspace_1.hashFiles)(files);
|
|
222
|
+
state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
|
|
223
|
+
};
|
|
224
|
+
const take = (e, verb) => {
|
|
225
|
+
if (!opts.dryRun) {
|
|
226
|
+
(0, workspace_1.writeLocal)(ctx.root, e.key, e.kind, e.remote.files);
|
|
227
|
+
record(e, e.remote.files);
|
|
228
|
+
}
|
|
229
|
+
process.stdout.write(safe(`${opts.dryRun ? 'would pull' : verb} ${e.key}\n`));
|
|
230
|
+
};
|
|
231
|
+
for (const e of entries) {
|
|
232
|
+
try {
|
|
233
|
+
switch (e.status) {
|
|
234
|
+
case 'unchanged':
|
|
235
|
+
if (e.refresh && !opts.dryRun)
|
|
236
|
+
record(e, e.remote.files);
|
|
237
|
+
if (e.refresh && !e.state)
|
|
238
|
+
process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key}\n`));
|
|
239
|
+
else
|
|
240
|
+
unchanged += 1;
|
|
241
|
+
break;
|
|
242
|
+
case 'remote-new':
|
|
243
|
+
case 'remote-changed':
|
|
244
|
+
take(e, 'pulled');
|
|
245
|
+
break;
|
|
246
|
+
case 'deleted':
|
|
247
|
+
take(e, 'restored');
|
|
248
|
+
break;
|
|
249
|
+
case 'modified':
|
|
250
|
+
if (opts.force)
|
|
251
|
+
take(e, 'pulled');
|
|
252
|
+
else
|
|
253
|
+
process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
|
|
254
|
+
break;
|
|
255
|
+
case 'conflict':
|
|
256
|
+
if (!e.state && e.kind === 'prompt' && e.remote.data.version === null) {
|
|
257
|
+
// A console prompt without a version has nothing to pull: track it as if its empty render
|
|
258
|
+
// had been pulled, so the local files show as edits and push creates the first version.
|
|
259
|
+
if (!opts.dryRun) {
|
|
260
|
+
const h = (0, workspace_1.hashFiles)(e.remote.files);
|
|
261
|
+
state.resources[e.key] = { kind: e.kind, id: e.remote.id, base: h, remote: h };
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key} (the console prompt has no version yet; push to create one)\n`));
|
|
264
|
+
}
|
|
265
|
+
else if (opts.force)
|
|
266
|
+
take(e, 'pulled');
|
|
267
|
+
else
|
|
268
|
+
process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
|
|
269
|
+
break;
|
|
270
|
+
case 'renamed': {
|
|
271
|
+
const old = state.resources[e.from];
|
|
272
|
+
if (!opts.dryRun) {
|
|
273
|
+
state.resources[e.key] = old;
|
|
274
|
+
delete state.resources[e.from];
|
|
275
|
+
}
|
|
276
|
+
process.stdout.write(safe(`${opts.dryRun ? 'would rename' : 'renamed'} ${e.from} -> ${e.key}\n`));
|
|
277
|
+
if ((0, workspace_1.hashFiles)(e.remote.files) !== old.remote)
|
|
278
|
+
take(e, 'pulled');
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'remote-deleted':
|
|
282
|
+
if (!opts.dryRun)
|
|
283
|
+
delete state.resources[e.key];
|
|
284
|
+
process.stderr.write(safe(`${e.key}: deleted in the console; local files kept (push to recreate, or delete them)\n`));
|
|
285
|
+
break;
|
|
286
|
+
case 'new':
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
failed += 1;
|
|
292
|
+
process.stderr.write(safe(`failed ${e.key}: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!opts.dryRun)
|
|
296
|
+
(0, workspace_1.writeState)(ctx.root, state);
|
|
297
|
+
process.stdout.write(`${unchanged} unchanged\n`);
|
|
298
|
+
if (failed > 0)
|
|
299
|
+
throw new errors_1.CliError(`${failed} resources failed`, 1);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
release?.();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function remoteContent(r) {
|
|
306
|
+
try {
|
|
307
|
+
return (0, push_1.versionContent)(r.kind, r.files, r.kind);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const remoteMoved = (e) => (0, workspace_1.hashFiles)(e.remote.files) !== e.state.remote;
|
|
314
|
+
function planPush(entries, opts, options) {
|
|
315
|
+
const actions = [];
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
switch (entry.status) {
|
|
318
|
+
case 'unchanged':
|
|
319
|
+
if ((0, push_1.hasPendingVersion)(entry, options))
|
|
320
|
+
actions.push({ entry, op: 'update' });
|
|
321
|
+
break;
|
|
322
|
+
case 'modified':
|
|
323
|
+
actions.push({ entry, op: 'update' });
|
|
324
|
+
break;
|
|
325
|
+
case 'new':
|
|
326
|
+
actions.push({ entry, op: 'create' });
|
|
327
|
+
break;
|
|
328
|
+
case 'renamed':
|
|
329
|
+
if (entry.kind === 'workflow') {
|
|
330
|
+
actions.push({ entry, skip: 'workflow directories are named by workflow_id; rename it in the console instead', failure: true });
|
|
331
|
+
}
|
|
332
|
+
else if (remoteMoved(entry))
|
|
333
|
+
actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
|
|
334
|
+
else
|
|
335
|
+
actions.push({ entry, op: 'rename' });
|
|
336
|
+
break;
|
|
337
|
+
case 'conflict':
|
|
338
|
+
if (!entry.state) {
|
|
339
|
+
const reason = `exists in the console but is not tracked here. "metrio pull --force ${entry.key}" would OVERWRITE your local files; ` +
|
|
340
|
+
'to keep them, rename the local directory (it will be pushed as new) or delete the console copy first';
|
|
341
|
+
actions.push({ entry, skip: reason, failure: true });
|
|
342
|
+
}
|
|
343
|
+
else if (opts.force)
|
|
344
|
+
actions.push({ entry, op: 'update' });
|
|
345
|
+
else
|
|
346
|
+
actions.push({ entry, skip: 'conflict: changed locally and in the console (pull, merge, then push; or --force)', failure: true });
|
|
347
|
+
break;
|
|
348
|
+
case 'remote-changed':
|
|
349
|
+
actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
|
|
350
|
+
break;
|
|
351
|
+
case 'remote-deleted':
|
|
352
|
+
actions.push({ entry, skip: 'deleted in the console; run "metrio pull" to drop it, then push to recreate', failure: true });
|
|
353
|
+
break;
|
|
354
|
+
case 'deleted':
|
|
355
|
+
if (opts.prune && opts.yes) {
|
|
356
|
+
if (remoteMoved(entry) && !opts.force) {
|
|
357
|
+
actions.push({ entry, skip: 'changed in the console since your last pull; use --force to delete anyway', failure: true });
|
|
358
|
+
}
|
|
359
|
+
else
|
|
360
|
+
actions.push({ entry, op: 'delete' });
|
|
361
|
+
}
|
|
362
|
+
else if (opts.prune)
|
|
363
|
+
actions.push({ entry, skip: 'use --prune --yes to delete it in the console', failure: true });
|
|
364
|
+
else
|
|
365
|
+
actions.push({ entry, skip: 'deleted locally (use --prune --yes to delete it in the console)', failure: false });
|
|
366
|
+
break;
|
|
367
|
+
default:
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return sortForExecution(actions);
|
|
372
|
+
}
|
|
373
|
+
const KIND_RANK = { skill: 0, prompt: 1, workflow: 2 };
|
|
374
|
+
const DELETE_RANK = { prompt: 3, workflow: 3, skill: 4 };
|
|
375
|
+
/** Skills first (prompts may name new ones), then prompts, workflows; deletes last, skills after what uses them. */
|
|
376
|
+
function sortForExecution(actions) {
|
|
377
|
+
const rank = (a) => ('op' in a && a.op === 'delete' ? DELETE_RANK : KIND_RANK)[a.entry.kind];
|
|
378
|
+
return [...actions].sort((a, b) => rank(a) - rank(b));
|
|
379
|
+
}
|
|
380
|
+
const PAST = { create: 'created', update: 'updated', delete: 'deleted', rename: 'renamed' };
|
|
381
|
+
const describeOp = (op, e) => (op === 'rename' ? `${e.from} -> ${e.key}` : e.key);
|
|
382
|
+
async function runPushCommand(argv) {
|
|
383
|
+
if (wantsHelp(argv)) {
|
|
384
|
+
process.stdout.write(exports.SYNC_HELP);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const opts = parseSyncArgs(argv);
|
|
388
|
+
const ctx = await openWorkspace();
|
|
389
|
+
const release = opts.dryRun ? undefined : (0, workspace_1.acquireLock)(ctx.root);
|
|
390
|
+
try {
|
|
391
|
+
const state = (0, workspace_1.readState)(ctx.root, ctx.project);
|
|
392
|
+
const options = { activate: opts.activate, publish: opts.publish, message: opts.message, force: opts.force };
|
|
393
|
+
const entries = await loadEntries(ctx, opts);
|
|
394
|
+
const actions = planPush(entries, opts, options);
|
|
395
|
+
if (opts.dryRun) {
|
|
396
|
+
for (const a of actions) {
|
|
397
|
+
process.stdout.write(safe('op' in a ? `would ${a.op} ${describeOp(a.op, a.entry)}\n` : `skip ${a.entry.key}: ${a.skip}\n`));
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const pushCtx = { client: ctx.client, project: ctx.project, options };
|
|
402
|
+
const done = [];
|
|
403
|
+
let failed = 0;
|
|
404
|
+
for (const a of actions) {
|
|
405
|
+
if (!('op' in a)) {
|
|
406
|
+
if (a.failure) {
|
|
407
|
+
failed += 1;
|
|
408
|
+
process.stderr.write(safe(`skip ${a.entry.key}: ${a.skip}\n`));
|
|
409
|
+
}
|
|
410
|
+
else
|
|
411
|
+
process.stderr.write(safe(`${a.entry.key}: ${a.skip}\n`));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
let result;
|
|
416
|
+
if (a.op === 'delete')
|
|
417
|
+
await (0, push_1.deleteEntry)(pushCtx, a.entry);
|
|
418
|
+
else if (a.op !== 'rename')
|
|
419
|
+
result = await (0, push_1.pushEntry)(pushCtx, a.entry);
|
|
420
|
+
done.push({ entry: a.entry, op: a.op, result });
|
|
421
|
+
process.stdout.write(safe(`${PAST[a.op]} ${describeOp(a.op, a.entry)}${result ? ` (${result.summary})` : ''}\n`));
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
failed += 1;
|
|
425
|
+
process.stderr.write(safe(`failed ${a.entry.key}: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Converged resources (same content on both sides, stale state): record them as synced.
|
|
429
|
+
const converged = entries.filter((e) => e.refresh && !done.some((d) => d.entry === e));
|
|
430
|
+
for (const e of converged) {
|
|
431
|
+
const h = (0, workspace_1.hashFiles)(e.local);
|
|
432
|
+
state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
|
|
433
|
+
}
|
|
434
|
+
if (converged.length && !done.length)
|
|
435
|
+
(0, workspace_1.writeState)(ctx.root, state);
|
|
436
|
+
let refetchFailed = false;
|
|
437
|
+
if (done.length) {
|
|
438
|
+
const pushed = done.filter((d) => d.result);
|
|
439
|
+
let refreshed;
|
|
440
|
+
if (pushed.length) {
|
|
441
|
+
try {
|
|
442
|
+
refreshed = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, [...new Set(pushed.map((d) => d.entry.kind))]);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
refetchFailed = true;
|
|
446
|
+
process.stderr.write(safe(`could not re-read the console after pushing: ${error instanceof Error ? error.message : String(error)}; run "metrio diff" to check\n`));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const d of done) {
|
|
450
|
+
const key = d.entry.key;
|
|
451
|
+
if (d.op === 'delete') {
|
|
452
|
+
delete state.resources[key];
|
|
453
|
+
}
|
|
454
|
+
else if (d.op === 'rename') {
|
|
455
|
+
state.resources[key] = state.resources[d.entry.from];
|
|
456
|
+
delete state.resources[d.entry.from];
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const r = d.result;
|
|
460
|
+
const previous = state.resources[key];
|
|
461
|
+
const remote = refreshed?.find((x) => x.kind === d.entry.kind && x.id === r.id);
|
|
462
|
+
state.resources[key] = {
|
|
463
|
+
...(previous ?? {}),
|
|
464
|
+
kind: d.entry.kind,
|
|
465
|
+
id: r.id,
|
|
466
|
+
base: (0, workspace_1.hashFiles)(d.entry.local),
|
|
467
|
+
// Without a re-read, keep the last known remote hash: the next diff then shows what really changed.
|
|
468
|
+
remote: refreshed ? (remote ? (0, workspace_1.hashFiles)(remote.files) : '') : (previous?.remote ?? ''),
|
|
469
|
+
...(r.pushed_version_id !== undefined ? { pushed_version_id: r.pushed_version_id, pushed_serial: r.pushed_serial } : {}),
|
|
470
|
+
...(r.pushed_workflow_version !== undefined ? { pushed_workflow_version: r.pushed_workflow_version } : {}),
|
|
471
|
+
...(r.pushed_content !== undefined ? { pushed_content: r.pushed_content } : {}),
|
|
472
|
+
// What the console made of a new/activated version (defaults filled, prompt_refs pinned): see push.ts.
|
|
473
|
+
...(refreshed && r.versioned ? { remote_content: remote ? remoteContent(remote) : undefined } : {}),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
(0, workspace_1.writeState)(ctx.root, state);
|
|
478
|
+
}
|
|
479
|
+
process.stdout.write(`${done.length} pushed, ${failed} failed\n`);
|
|
480
|
+
if (failed)
|
|
481
|
+
throw new errors_1.CliError(`${failed} of ${done.length + failed} resources failed`, 1);
|
|
482
|
+
if (refetchFailed)
|
|
483
|
+
throw new errors_1.CliError('pushed, but could not re-read the console; run "metrio diff" to check', 1);
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
release?.();
|
|
487
|
+
}
|
|
488
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const prompt_1 = require("./commands/prompt");
|
|
|
19
19
|
const workflow_1 = require("./commands/workflow");
|
|
20
20
|
const tools_1 = require("./commands/tools");
|
|
21
21
|
const run_1 = require("./manage/run");
|
|
22
|
+
const sync_1 = require("./commands/sync");
|
|
22
23
|
const registry_1 = require("./manage/registry");
|
|
23
24
|
exports.HELP = `metrio - MetrioAI CLI
|
|
24
25
|
|
|
@@ -40,6 +41,7 @@ Manage (console OAuth; run "metrio <group> --help"):
|
|
|
40
41
|
project | prompt | version | skill | workflow | schedule | mcp | run | admin | model-tiers
|
|
41
42
|
e.g. metrio version create 12 -f system.md
|
|
42
43
|
metrio skill get 5 -o skill.md
|
|
44
|
+
pull | diff | push Sync prompts, skills and workflows with local files ("metrio pull --help")
|
|
43
45
|
tools List the console tools your login can call
|
|
44
46
|
call <tool> Call any console tool with JSON args (-f file | --args json)
|
|
45
47
|
|
|
@@ -81,6 +83,12 @@ async function main(argv) {
|
|
|
81
83
|
return runMixedGroup('workflow', argv);
|
|
82
84
|
case 'prompt':
|
|
83
85
|
return runMixedGroup('prompt', argv);
|
|
86
|
+
case 'pull':
|
|
87
|
+
return (0, sync_1.runPullCommand)(argv.slice(1));
|
|
88
|
+
case 'diff':
|
|
89
|
+
return (0, sync_1.runDiffCommand)(argv.slice(1));
|
|
90
|
+
case 'push':
|
|
91
|
+
return (0, sync_1.runPushCommand)(argv.slice(1));
|
|
84
92
|
case 'login':
|
|
85
93
|
return (0, auth_1.runLoginCommand)(argv.slice(1));
|
|
86
94
|
case 'logout':
|
package/dist/manage/output.js
CHANGED
|
@@ -110,7 +110,12 @@ async function writeOutput(data, opts) {
|
|
|
110
110
|
process.stdout.write(record.text.endsWith('\n') ? record.text : `${record.text}\n`);
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
|
-
const
|
|
113
|
+
const listKey = Array.isArray(record.items)
|
|
114
|
+
? 'items'
|
|
115
|
+
: 'pagination' in record
|
|
116
|
+
? Object.keys(record).find((k) => Array.isArray(record[k]))
|
|
117
|
+
: undefined;
|
|
118
|
+
const rows = Array.isArray(data) ? data : listKey ? record[listKey] : undefined;
|
|
114
119
|
if (rows) {
|
|
115
120
|
process.stdout.write(rows.length ? `${renderTable(rows)}\n` : '(none)\n');
|
|
116
121
|
const p = asRecord(record.pagination);
|
package/dist/manage/run.js
CHANGED
|
@@ -7,6 +7,7 @@ const input_1 = require("./input");
|
|
|
7
7
|
const output_1 = require("./output");
|
|
8
8
|
const project_1 = require("./project");
|
|
9
9
|
const registry_1 = require("./registry");
|
|
10
|
+
const workspace_1 = require("../sync/workspace");
|
|
10
11
|
const schema_1 = require("./schema");
|
|
11
12
|
function parse(rest) {
|
|
12
13
|
const parsed = { tokens: [], json: false, yes: false };
|
|
@@ -66,6 +67,27 @@ function isCommandPrefix(argv) {
|
|
|
66
67
|
const words = argv.filter((a) => !a.startsWith('-'));
|
|
67
68
|
return registry_1.COMMANDS.some((c) => words.length < c.path.length && words.every((w, i) => c.path[i] === w));
|
|
68
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A prompt positional may name a tracked directory (prompts/<dir> or <dir>) instead of the
|
|
72
|
+
* numeric id — only for the workspace's own project, since ids are per project.
|
|
73
|
+
*/
|
|
74
|
+
function promptRef(raw, project) {
|
|
75
|
+
if (/^\d+$/.test(raw))
|
|
76
|
+
return raw;
|
|
77
|
+
const root = (0, workspace_1.findRoot)();
|
|
78
|
+
const dir = raw.replace(/\/+$/, '');
|
|
79
|
+
const key = dir.startsWith('prompts/') ? dir : `prompts/${dir}`;
|
|
80
|
+
// A broken workspace surfaces its own (informative) CliError.
|
|
81
|
+
const workspaceProject = root ? (0, workspace_1.readProject)(root) : undefined;
|
|
82
|
+
const entry = root ? (0, workspace_1.readState)(root, workspaceProject).resources[key] : undefined;
|
|
83
|
+
if (!entry || entry.kind !== 'prompt') {
|
|
84
|
+
throw new errors_1.CliError(`Unknown prompt "${raw}": use the numeric id, or a prompts/<dir> tracked in .metrio/state.json`);
|
|
85
|
+
}
|
|
86
|
+
if (project !== undefined && project !== workspaceProject) {
|
|
87
|
+
throw new errors_1.CliError(`"${raw}" is a directory of project ${workspaceProject}, but this command targets project ${String(project)}`);
|
|
88
|
+
}
|
|
89
|
+
return entry.id;
|
|
90
|
+
}
|
|
69
91
|
/** Runs one registry command; argv starts with the group, e.g. ['version', 'create', '12', '-f', 's.md']. */
|
|
70
92
|
async function runManageCommand(argv, readStdin) {
|
|
71
93
|
const match = (0, registry_1.findCommand)(argv);
|
|
@@ -107,7 +129,14 @@ async function runManageCommand(argv, readStdin) {
|
|
|
107
129
|
put('project_slug', opts.project, 'project');
|
|
108
130
|
for (const [k, v] of Object.entries(values))
|
|
109
131
|
put(k, v, `--${(0, schema_1.kebab)(k)}`);
|
|
110
|
-
|
|
132
|
+
// A prompt directory resolves against the final project_slug, so it waits for it.
|
|
133
|
+
let promptDir;
|
|
134
|
+
expected.forEach((arg, i) => {
|
|
135
|
+
if (arg.prop === 'prompt_external_id' && !/^\d+$/.test(positionals[i]))
|
|
136
|
+
promptDir = { prop: arg.prop, raw: positionals[i], label: arg.label };
|
|
137
|
+
else
|
|
138
|
+
put(arg.prop, (0, schema_1.coercePositional)(positionals[i], props[arg.prop], arg.label), arg.label);
|
|
139
|
+
});
|
|
111
140
|
if ('project_slug' in props && !opts.project && args.project_slug === undefined) {
|
|
112
141
|
try {
|
|
113
142
|
const project = (0, project_1.resolveProject)(undefined);
|
|
@@ -119,6 +148,9 @@ async function runManageCommand(argv, readStdin) {
|
|
|
119
148
|
throw err;
|
|
120
149
|
}
|
|
121
150
|
}
|
|
151
|
+
if (promptDir) {
|
|
152
|
+
put(promptDir.prop, (0, schema_1.coercePositional)(promptRef(promptDir.raw, args.project_slug), props[promptDir.prop], promptDir.label), promptDir.label);
|
|
153
|
+
}
|
|
122
154
|
if ((schema.required ?? []).includes('project_slug') && args.project_slug === undefined) {
|
|
123
155
|
throw new errors_1.CliError('No project. Pass --project <slug>, set METRIO_PROJECT, or add "project: <slug>" to metrio.yaml.');
|
|
124
156
|
}
|