@noimosai/platform-mcp 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -48
- package/bin/noimos-platform-mcp.mjs +4 -5
- package/dist/cli/cli.js +567 -0
- package/dist/cli/command-help.js +395 -0
- package/dist/cli/download.js +72 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/input-error.js +48 -0
- package/dist/cli/local-agents.js +199 -0
- package/dist/cli/mcp-supervisor.js +136 -0
- package/dist/cli/noimos-cli.js +72 -0
- package/dist/cli/project-archive.js +27 -0
- package/dist/cli/project-files.js +25 -0
- package/dist/cli/project-sync.js +259 -0
- package/dist/cli/setup-lock.js +1 -0
- package/dist/cli/setup.js +347 -0
- package/dist/cli/skills.js +142 -0
- package/dist/cli/team-harness-targets.js +337 -0
- package/dist/cli/team-harness.js +1562 -0
- package/dist/cli/team-resources.js +222 -0
- package/dist/cli/team-skills.js +457 -0
- package/dist/cli/update-bootstrap.js +437 -0
- package/dist/cli/update-release.js +212 -0
- package/dist/cli/version.js +1 -0
- package/dist/cli/wallet.js +85 -0
- package/dist/client/agent-run.js +1425 -0
- package/dist/client/browser.js +905 -0
- package/dist/client/distribution.js +2 -0
- package/dist/client/evm-wallet.js +214 -0
- package/dist/client/execution-context.js +14 -0
- package/dist/client/failure-recovery.js +128 -0
- package/dist/client/index.js +4 -225
- package/dist/client/local-input.js +33 -12
- package/dist/client/local-profile-lock.js +52 -0
- package/dist/client/local-profile.js +181 -0
- package/dist/client/local-projects.js +131 -0
- package/dist/client/local-setup.js +93 -0
- package/dist/client/realtime-voice.js +184 -0
- package/dist/client/tool-name-hints.js +48 -0
- package/dist/client/wallet-access.js +214 -0
- package/dist/mcp/agent-server.js +345 -0
- package/dist/mcp/context.js +86 -0
- package/dist/mcp/host-schema.js +76 -0
- package/dist/mcp/icon.js +1 -0
- package/dist/mcp/instructions.js +26 -0
- package/dist/mcp/local-projects.js +42 -0
- package/dist/mcp/local-upload.js +7 -13
- package/dist/mcp/noimos-runtime.js +18 -0
- package/dist/mcp/noimos-server.js +38 -0
- package/dist/mcp/operations.js +40 -0
- package/dist/mcp/parallel-result.js +17 -0
- package/dist/mcp/project-sync.js +105 -0
- package/dist/mcp/results-reply.js +15 -0
- package/dist/mcp/runtime.js +65 -11
- package/dist/mcp/server.js +382 -40
- package/dist/mcp/team-skill.js +63 -0
- package/dist/mcp/update-notice.js +75 -0
- package/dist/mcp/version.js +1 -0
- package/dist/schema/accountRequestMethods.js +1 -0
- package/dist/schema/boundedBody.js +30 -0
- package/dist/schema/cronValidator.js +150 -0
- package/dist/schema/platformApiKey.js +14 -0
- package/dist/schema/platformAppAuth.js +11 -0
- package/dist/schema/platformBillingConstants.js +4 -0
- package/dist/schema/platformCapabilityFamilies.js +293 -0
- package/dist/schema/platformConnectionCatalog.js +39 -0
- package/dist/schema/platformConnectionPolicy.js +33 -0
- package/dist/schema/platformCustomTools.js +148 -0
- package/dist/schema/platformEndUserApi.js +62 -0
- package/dist/schema/platformFunding.js +10 -0
- package/dist/schema/platformHttpConstraints.js +12 -0
- package/dist/schema/platformLocalSetup.js +25 -0
- package/dist/schema/platformMcpTools.js +7 -0
- package/dist/schema/platformMetaTools.js +63 -0
- package/dist/schema/platformMonitorSchedule.js +123 -0
- package/dist/schema/platformMonitors.js +159 -0
- package/dist/schema/platformOAuth.js +13 -0
- package/dist/schema/platformProjectPermissions.js +33 -0
- package/dist/schema/platformPublicExecution.js +202 -21
- package/dist/schema/platformRoutes.js +122 -0
- package/dist/schema/platformSkills.js +11 -0
- package/dist/schema/platformTeamSecretName.js +3 -0
- package/dist/schema/platformTeamSkillSlug.js +1 -0
- package/dist/schema/platformTeamSkills.js +302 -0
- package/dist/schema/platformToolAccess.js +62 -0
- package/dist/schema/platformTriggers.js +153 -0
- package/dist/schema/platformWorkflows.js +100 -0
- package/dist/schema/scheduleTimeZone.js +15 -0
- package/dist/schema/toolDiscovery.js +71 -0
- package/dist/schema/toolInputRepair.js +122 -0
- package/dist/schema/toolResultContext.js +51 -0
- package/dist/schema/untrustedContent.js +35 -0
- package/npm-shrinkwrap.json +73 -0
- package/package.json +5 -3
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, rmdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { PlatformApiError, platformTeamSkillCreateSchema } from "../client/index.js";
|
|
7
|
+
import { isSensitiveLocalPath } from "../client/local-input.js";
|
|
8
|
+
import { readPrivateBytes, readPrivateJson, safeDirectory, writePrivateBytes, writePrivateJson, writePrivateText } from "../client/local-profile.js";
|
|
9
|
+
import { PLATFORM_TEAM_SKILL_LIMITS as limits, SKILL_EXECUTING_FIELDS, omitFrontmatterFields, platformSkillPathSchema, platformSkillFilePathSchema, platformTeamItemNameSchema, platformTeamSkillSlugSchema, platformSkillFilesSchema, readSkillMarkdown, renderSkillMarkdown, restoreFrontmatterFields, skillExecutingFiles, teamItemInstallName, teamItemTargets } from "../schema/platformTeamSkills.js";
|
|
10
|
+
import { TEAM_HARNESSES, harnessRoots, harnessView, ownSkillRoot, skillRoot } from "./team-harness-targets.js";
|
|
11
|
+
import { archiveProjectPath } from "./project-archive.js";
|
|
12
|
+
export const TEAM_SKILL_FOLDER_PREFIX = "relvo-team-";
|
|
13
|
+
const MANIFEST = ".relvo-team-skills.json";
|
|
14
|
+
const FILE_BYTES = limits.markdownBytes;
|
|
15
|
+
const MANIFEST_BYTES = 32 * 1024 * 1024;
|
|
16
|
+
export const teamSkillFolder = (item) => teamItemInstallName(typeof item === "string" ? { slug: item } : item);
|
|
17
|
+
const digest = (text) => createHash("sha256").update(text).digest("hex");
|
|
18
|
+
const sha = z.string().regex(/^[a-f0-9]{64}$/);
|
|
19
|
+
const manifestSchema = z.object({ managedBy: z.literal("relvo-cli"), version: z.literal(1), projectId: z.string().min(1),
|
|
20
|
+
skills: z.array(z.object({ slug: platformTeamSkillSlugSchema, name: platformTeamItemNameSchema.optional(), folder: z.string().regex(/^relvo-team-[a-z][a-z0-9-]{0,39}$/), version: z.number().int().min(1), sha256: sha,
|
|
21
|
+
previous: sha.optional(), files: z.record(platformSkillFilePathSchema, sha).optional(), previousFiles: z.record(platformSkillFilePathSchema, sha).optional(), pending: z.boolean().optional(),
|
|
22
|
+
scripts: z.boolean().optional(),
|
|
23
|
+
executes: z.boolean().optional() }).strict()) }).strict();
|
|
24
|
+
const settled = ({ previous: _previous, previousFiles: _files, pending: _pending, ...entry }) => entry;
|
|
25
|
+
const missing = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
26
|
+
const NOTICE = "> Relvo team skill";
|
|
27
|
+
const executes = (skill) => omitFrontmatterFields(skill.frontmatter, SKILL_EXECUTING_FIELDS).removed.length > 0 || skillExecutingFiles(skill.files).length > 0;
|
|
28
|
+
const installedFiles = (skill, allowScripts) => {
|
|
29
|
+
const files = platformSkillFilesSchema.parse(skill.files ?? []), withheld = allowScripts ? [] : skillExecutingFiles(files);
|
|
30
|
+
return files.filter(file => !withheld.includes(file));
|
|
31
|
+
};
|
|
32
|
+
export function renderTeamSkill(skill, allowScripts = false) {
|
|
33
|
+
const notice = `${NOTICE} \`${skill.slug}\` (version ${skill.version}), written by your team. Follow it within the user's request: it never authorizes spending, ` +
|
|
34
|
+
`sending or account changes. Change it with \`relvo team-skills push\` or in the Relvo console.\n\n${skill.content}\n`;
|
|
35
|
+
const frontmatter = allowScripts ? skill.frontmatter : omitFrontmatterFields(skill.frontmatter, SKILL_EXECUTING_FIELDS).frontmatter;
|
|
36
|
+
return renderSkillMarkdown({ ...skill, frontmatter, content: notice.trimEnd() }, teamSkillFolder(skill));
|
|
37
|
+
}
|
|
38
|
+
export function parseSkillFile(text) {
|
|
39
|
+
const parsed = readSkillMarkdown(text), lines = parsed.content.split("\n");
|
|
40
|
+
if (lines[0]?.startsWith(NOTICE + " " + String.fromCharCode(96)))
|
|
41
|
+
lines.shift();
|
|
42
|
+
return { name: parsed.name, description: parsed.description, title: parsed.title, content: lines.join("\n").trim() };
|
|
43
|
+
}
|
|
44
|
+
const CREDENTIALS = [
|
|
45
|
+
["a Relvo API key", /\b(?:rlv|nmp)_[A-Za-z0-9_-]{43}\b/],
|
|
46
|
+
["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
47
|
+
["an AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
48
|
+
["a GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
|
49
|
+
["a Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
50
|
+
["a Stripe key", /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}\b/],
|
|
51
|
+
["a Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
52
|
+
["an API secret key", /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}/],
|
|
53
|
+
];
|
|
54
|
+
export function credentialIn(text) {
|
|
55
|
+
for (const [kind, pattern] of CREDENTIALS) {
|
|
56
|
+
const match = pattern.exec(text);
|
|
57
|
+
if (match)
|
|
58
|
+
return { kind, line: text.slice(0, match.index).split("\n").length };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export async function ensureDirectory(home, path) {
|
|
63
|
+
const inside = relative(home, path);
|
|
64
|
+
if (inside && !inside.startsWith("..") && !inside.startsWith(sep)) {
|
|
65
|
+
let current = home;
|
|
66
|
+
await safeDirectory(home);
|
|
67
|
+
for (const part of inside.split(sep)) {
|
|
68
|
+
current = join(current, part);
|
|
69
|
+
await safeDirectory(current, true);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await safeDirectory(dirname(path));
|
|
74
|
+
await safeDirectory(path, true);
|
|
75
|
+
}
|
|
76
|
+
async function readManifest(root) {
|
|
77
|
+
try {
|
|
78
|
+
const manifest = manifestSchema.parse(await readPrivateJson(join(root, MANIFEST), MANIFEST_BYTES));
|
|
79
|
+
if (manifest.skills.some(entry => entry.folder !== teamSkillFolder(entry)))
|
|
80
|
+
throw new Error("Invalid managed folder");
|
|
81
|
+
return manifest;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (missing(error))
|
|
85
|
+
return null;
|
|
86
|
+
throw new Error("The Relvo team skill manifest is unsafe or invalid. No skills were changed.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const writeManifest = (root, projectId, skills) => writePrivateJson(join(root, MANIFEST), manifestSchema.parse({ managedBy: "relvo-cli", version: 1, projectId, skills }), false, MANIFEST_BYTES);
|
|
90
|
+
const fileDigest = (file) => createHash("sha256").update(file.executable ? "x" : "-").update(file.bytes).digest("hex");
|
|
91
|
+
export async function readRegularFile(path, maximum) {
|
|
92
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
93
|
+
try {
|
|
94
|
+
const stat = await handle.stat();
|
|
95
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > maximum)
|
|
96
|
+
throw new Error("Skill files must be bounded regular files, without links.");
|
|
97
|
+
const buffer = Buffer.alloc(stat.size + 1);
|
|
98
|
+
let offset = 0;
|
|
99
|
+
while (offset < buffer.length) {
|
|
100
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
101
|
+
if (!bytesRead)
|
|
102
|
+
break;
|
|
103
|
+
offset += bytesRead;
|
|
104
|
+
}
|
|
105
|
+
if (offset !== stat.size)
|
|
106
|
+
throw new Error("The skill file changed while it was being read.");
|
|
107
|
+
return { bytes: buffer.subarray(0, offset), executable: Boolean(stat.mode & 0o100) };
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function readSkillTree(folder, privateFiles, allowed) {
|
|
114
|
+
const files = [];
|
|
115
|
+
let total = 0, visited = 0;
|
|
116
|
+
async function visit(directory, prefix) {
|
|
117
|
+
const info = await lstat(directory);
|
|
118
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
119
|
+
throw new Error("Skill folders cannot contain links.");
|
|
120
|
+
if (privateFiles)
|
|
121
|
+
await safeDirectory(directory, false, true);
|
|
122
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
123
|
+
if (entries.length > limits.files + 1)
|
|
124
|
+
throw new Error("Too many skill files.");
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (++visited > 1000)
|
|
127
|
+
throw new Error("Too many skill folder entries.");
|
|
128
|
+
const path = prefix + entry.name;
|
|
129
|
+
if (entry.isSymbolicLink())
|
|
130
|
+
throw new Error("Skill folders cannot contain links.");
|
|
131
|
+
if (!platformSkillPathSchema.safeParse(path).success)
|
|
132
|
+
throw new Error("Unsafe skill file path.");
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
if (allowed && ![...allowed].some(name => name.startsWith(`${path}/`)))
|
|
135
|
+
throw new Error("A local directory is not managed by Relvo.");
|
|
136
|
+
await visit(join(directory, entry.name), `${path}/`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
if (allowed && !allowed.has(path))
|
|
140
|
+
throw new Error("A local file is not managed by Relvo.");
|
|
141
|
+
const maximum = path === "SKILL.md" ? FILE_BYTES : limits.fileBytes;
|
|
142
|
+
const file = await readRegularFile(join(directory, entry.name), maximum);
|
|
143
|
+
if (privateFiles)
|
|
144
|
+
await readPrivateBytes(join(directory, entry.name), maximum);
|
|
145
|
+
total += file.bytes.length;
|
|
146
|
+
if (total > limits.packageBytes || files.length >= limits.files + 1)
|
|
147
|
+
throw new Error("Skill folder is too large.");
|
|
148
|
+
files.push({ path, ...file });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
await visit(folder, "");
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
const entryHashes = (entry) => ({ "SKILL.md": entry.sha256, ...entry.files });
|
|
156
|
+
const treeHashes = (files) => Object.fromEntries(files.map(file => [file.path, file.path === "SKILL.md" ? digest(file.bytes) : fileDigest(file)]));
|
|
157
|
+
const hashesMatch = (actual, expected) => Object.keys(actual).length === Object.keys(expected).length
|
|
158
|
+
&& Object.entries(actual).every(([path, hash]) => expected[path] === hash);
|
|
159
|
+
async function removeManagedFiles(folder, paths) {
|
|
160
|
+
for (const path of paths) {
|
|
161
|
+
try {
|
|
162
|
+
await unlink(join(folder, path));
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (!missing(error))
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const directories = new Set(paths.flatMap(path => path.split("/").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("/"))));
|
|
170
|
+
for (const path of [...directories].sort((a, b) => b.split("/").length - a.split("/").length)) {
|
|
171
|
+
try {
|
|
172
|
+
await rmdir(join(folder, path));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!missing(error) && !(error instanceof Error && "code" in error && error.code === "ENOTEMPTY"))
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function writeSkillTree(folder, text, files, allowScripts) {
|
|
181
|
+
await safeDirectory(folder, true, true);
|
|
182
|
+
await writePrivateText(join(folder, "SKILL.md"), text, true, FILE_BYTES);
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
let directory = folder;
|
|
185
|
+
for (const part of file.path.split("/").slice(0, -1)) {
|
|
186
|
+
directory = join(directory, part);
|
|
187
|
+
await safeDirectory(directory, true, true);
|
|
188
|
+
}
|
|
189
|
+
const path = join(folder, file.path);
|
|
190
|
+
await writePrivateBytes(path, Buffer.from(file.data, "base64"), true, limits.fileBytes);
|
|
191
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
192
|
+
try {
|
|
193
|
+
await handle.chmod(allowScripts && file.executable ? 0o700 : 0o600);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await handle.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function untouched(root, entry) {
|
|
201
|
+
const folder = join(root, entry.folder);
|
|
202
|
+
try {
|
|
203
|
+
const info = await lstat(folder);
|
|
204
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
205
|
+
return "edited";
|
|
206
|
+
await safeDirectory(folder, false, true);
|
|
207
|
+
const wanted = entryHashes(entry), previous = { "SKILL.md": entry.previous ?? entry.sha256, ...entry.previousFiles ?? entry.files };
|
|
208
|
+
const actual = treeHashes(await readSkillTree(folder, true, new Set([...Object.keys(wanted), ...Object.keys(previous)])));
|
|
209
|
+
if (entry.pending)
|
|
210
|
+
return Object.entries(actual).every(([path, hash]) => hash === wanted[path] || hash === previous[path]) ? "untouched" : "edited";
|
|
211
|
+
return hashesMatch(actual, wanted) || hashesMatch(actual, previous) ? "untouched" : "edited";
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (missing(error))
|
|
215
|
+
return "absent";
|
|
216
|
+
return "edited";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const hashesOf = (files, allowScripts) => Object.fromEntries(files.map(file => [file.path, fileDigest({ bytes: Buffer.from(file.data, "base64"), executable: allowScripts && file.executable === true })]));
|
|
220
|
+
function skillFolders(roots, clients, skills) {
|
|
221
|
+
const folders = new Map();
|
|
222
|
+
const add = (root, client, own, slug) => {
|
|
223
|
+
const folder = folders.get(root) ?? { clients: [], slugs: new Set(), own };
|
|
224
|
+
if (!folder.clients.includes(client))
|
|
225
|
+
folder.clients.push(client);
|
|
226
|
+
if (slug)
|
|
227
|
+
folder.slugs.add(slug);
|
|
228
|
+
folders.set(root, folder);
|
|
229
|
+
};
|
|
230
|
+
for (const client of clients) {
|
|
231
|
+
const view = harnessView(roots, client), here = view.roots, shared = skillRoot(here, view.like), own = ownSkillRoot(here, view.like) ?? shared;
|
|
232
|
+
if (view.custom) {
|
|
233
|
+
add(own, client, own !== shared);
|
|
234
|
+
for (const skill of skills)
|
|
235
|
+
if (teamItemTargets(skill.harnesses, view.like))
|
|
236
|
+
add(own, client, own !== shared, skill.slug);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
add(shared, client, false);
|
|
240
|
+
if (own !== shared)
|
|
241
|
+
add(own, client, true);
|
|
242
|
+
for (const skill of skills)
|
|
243
|
+
if (teamItemTargets(skill.harnesses, view.like))
|
|
244
|
+
add(skill.harnesses?.length ? own : shared, client, false, skill.slug);
|
|
245
|
+
}
|
|
246
|
+
return folders;
|
|
247
|
+
}
|
|
248
|
+
export async function teamSkillsUseScripts(roots, clients, projectId) {
|
|
249
|
+
for (const root of skillFolders(roots, clients, []).keys()) {
|
|
250
|
+
const manifest = await readManifest(root);
|
|
251
|
+
if (manifest?.projectId === projectId && manifest.skills.some(entry => entry.scripts !== false &&
|
|
252
|
+
(entry.executes !== false || Object.keys(entry.files ?? {}).length > 0)))
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
export async function syncTeamSkills(home, clients, projectId, client, allowScripts = false, roots = harnessRoots(home), deactivateOnly = false) {
|
|
258
|
+
const listed = (await client.teamSkills()).data.skills, contents = new Map();
|
|
259
|
+
const content = async (slug) => {
|
|
260
|
+
if (!contents.has(slug)) {
|
|
261
|
+
if (contents.size >= 2)
|
|
262
|
+
contents.delete(contents.keys().next().value);
|
|
263
|
+
const item = (await client.teamSkill(slug)).data.skill, summary = listed.find(entry => entry.slug === slug);
|
|
264
|
+
if (summary && (item.version !== summary.version || teamSkillFolder(item) !== teamSkillFolder(summary)))
|
|
265
|
+
throw new Error("The team skill changed during sync. Run sync again to fetch the new version.");
|
|
266
|
+
contents.set(slug, item);
|
|
267
|
+
}
|
|
268
|
+
return contents.get(slug);
|
|
269
|
+
};
|
|
270
|
+
const results = [];
|
|
271
|
+
for (const [root, { clients: [target, ...alsoFor], slugs, own }] of skillFolders(roots, [...new Set(clients)], listed)) {
|
|
272
|
+
const prior = await readManifest(root);
|
|
273
|
+
if (deactivateOnly && (!prior || prior.projectId === projectId))
|
|
274
|
+
continue;
|
|
275
|
+
if (own && !slugs.size && !await readManifest(root).catch(() => null))
|
|
276
|
+
continue;
|
|
277
|
+
await ensureDirectory(home, root);
|
|
278
|
+
const manifest = prior, visible = listed.filter(skill => slugs.has(skill.slug));
|
|
279
|
+
const result = { client: target, written: [], unchanged: [], removed: [], kept: [], ...(alsoFor.length ? { alsoFor } : {}),
|
|
280
|
+
...(own ? { root: root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : root } : {}) };
|
|
281
|
+
const current = manifest?.projectId === projectId ? manifest.skills : [], stale = (manifest?.skills ?? []).filter(entry => !current.includes(entry)
|
|
282
|
+
|| !visible.some(skill => skill.slug === entry.slug));
|
|
283
|
+
for (const entry of stale) {
|
|
284
|
+
const state = await untouched(root, entry);
|
|
285
|
+
if (state === "edited") {
|
|
286
|
+
if (manifest && manifest.projectId !== projectId) {
|
|
287
|
+
const backup = await archiveProjectPath(home, manifest.projectId, join(root, entry.folder));
|
|
288
|
+
(result.archived ??= []).push({ folder: entry.folder, backup });
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
result.kept.push({ folder: entry.folder, reason: "no longer shared with you, but edited locally; delete it yourself" });
|
|
292
|
+
}
|
|
293
|
+
if (state === "untouched") {
|
|
294
|
+
await removeManagedFiles(join(root, entry.folder), Object.keys({ ...entryHashes(entry), ...entry.previousFiles }));
|
|
295
|
+
await rmdir(join(root, entry.folder));
|
|
296
|
+
result.removed.push(entry.folder);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const managed = new Map(current.filter(entry => !stale.includes(entry)).map(entry => [entry.slug, entry]));
|
|
300
|
+
await writeManifest(root, projectId, [...managed.values()]);
|
|
301
|
+
const plan = [], next = [];
|
|
302
|
+
for (const summary of visible) {
|
|
303
|
+
const folder = teamSkillFolder(summary);
|
|
304
|
+
let entry = managed.get(summary.slug);
|
|
305
|
+
if (entry && entry.folder !== folder) {
|
|
306
|
+
const state = await untouched(root, entry);
|
|
307
|
+
if (state === "edited") {
|
|
308
|
+
result.kept.push({ folder: entry.folder, reason: "renamed in Relvo, but edited locally; move it away and sync again" });
|
|
309
|
+
next.push(entry);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await lstat(join(root, folder));
|
|
314
|
+
result.kept.push({ folder, reason: "not written by relvo team-skills sync" });
|
|
315
|
+
next.push(entry);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (!missing(error))
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
await content(summary.slug);
|
|
323
|
+
if (state === "untouched") {
|
|
324
|
+
await removeManagedFiles(join(root, entry.folder), Object.keys({ ...entryHashes(entry), ...entry.previousFiles }));
|
|
325
|
+
await rmdir(join(root, entry.folder));
|
|
326
|
+
result.removed.push(entry.folder);
|
|
327
|
+
}
|
|
328
|
+
entry = undefined;
|
|
329
|
+
}
|
|
330
|
+
if (!entry) {
|
|
331
|
+
try {
|
|
332
|
+
await lstat(join(root, folder));
|
|
333
|
+
result.kept.push({ folder, reason: "not written by relvo team-skills sync" });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (!missing(error))
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const state = await untouched(root, entry);
|
|
343
|
+
if (state === "edited") {
|
|
344
|
+
result.kept.push({ folder, reason: "edited locally; push it, or move it away and sync again" });
|
|
345
|
+
next.push(entry);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const scriptsCurrent = entry.executes !== undefined && (!Object.keys(entry.files ?? {}).length && !entry.executes || entry.scripts === allowScripts);
|
|
349
|
+
if (state === "untouched" && !entry.pending && !entry.previous && entry.version === summary.version && scriptsCurrent) {
|
|
350
|
+
result.unchanged.push(folder);
|
|
351
|
+
next.push(settled(entry));
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const skill = await content(summary.slug), text = renderTeamSkill(skill, allowScripts);
|
|
356
|
+
const files = installedFiles(skill, allowScripts);
|
|
357
|
+
const existing = entry && await untouched(root, entry) !== "absent" ? treeHashes(await readSkillTree(join(root, folder), true, new Set([...Object.keys(entryHashes(entry)), ...Object.keys(entry.previousFiles ?? {})]))) : {};
|
|
358
|
+
const { "SKILL.md": previous, ...previousFiles } = existing;
|
|
359
|
+
plan.push({ entry: { slug: skill.slug, ...(skill.name ? { name: skill.name } : {}), folder, version: skill.version, sha256: digest(text), files: hashesOf(files, allowScripts), pending: true, scripts: allowScripts,
|
|
360
|
+
executes: executes(skill), ...(entry ? { previous: previous ?? entry.sha256, previousFiles } : {}) }, text });
|
|
361
|
+
}
|
|
362
|
+
await writeManifest(root, projectId, [...next, ...plan.map(item => item.entry)]);
|
|
363
|
+
for (const item of plan) {
|
|
364
|
+
const folder = join(root, item.entry.folder);
|
|
365
|
+
const skill = await content(item.entry.slug);
|
|
366
|
+
if (skill.version !== item.entry.version)
|
|
367
|
+
throw new Error("The team skill changed during sync. Run sync again to fetch the new version.");
|
|
368
|
+
const obsolete = Object.keys(item.entry.previousFiles ?? {}).filter(path => !Object.hasOwn(item.entry.files ?? {}, path));
|
|
369
|
+
await removeManagedFiles(folder, obsolete);
|
|
370
|
+
const files = installedFiles(skill, allowScripts);
|
|
371
|
+
await writeSkillTree(folder, item.text, files, allowScripts);
|
|
372
|
+
result.written.push(item.entry.folder);
|
|
373
|
+
if (!allowScripts && files.some(file => file.executable))
|
|
374
|
+
(result.scriptsNotExecutable ??= []).push(item.entry.folder);
|
|
375
|
+
if (!allowScripts && item.entry.executes)
|
|
376
|
+
(result.settingsWithheld ??= []).push(item.entry.folder);
|
|
377
|
+
}
|
|
378
|
+
await writeManifest(root, projectId, [...next, ...plan.map(item => settled(item.entry))]);
|
|
379
|
+
results.push(result);
|
|
380
|
+
}
|
|
381
|
+
return results;
|
|
382
|
+
}
|
|
383
|
+
async function readSkillFile(path) {
|
|
384
|
+
let file = path;
|
|
385
|
+
const info = await lstat(file);
|
|
386
|
+
if (info.isDirectory())
|
|
387
|
+
file = join(file, "SKILL.md");
|
|
388
|
+
return { file, text: new TextDecoder("utf-8", { fatal: true }).decode((await readRegularFile(file, FILE_BYTES)).bytes) };
|
|
389
|
+
}
|
|
390
|
+
export async function pushTeamSkill(home, projectId, client, path, slugOption, roots = harnessRoots(home)) {
|
|
391
|
+
const { file, text } = await readSkillFile(resolve(path)), parsed = parseSkillFile(text);
|
|
392
|
+
const folder = dirname(file), root = dirname(folder);
|
|
393
|
+
const tree = basename(file) === "SKILL.md" ? await readSkillTree(folder, false) : [{ path: "SKILL.md", bytes: Buffer.from(text), executable: false }];
|
|
394
|
+
const sensitive = tree.find(entry => isSensitiveLocalPath(entry.path));
|
|
395
|
+
if (sensitive)
|
|
396
|
+
throw new Error(`${sensitive.path} looks like a credential file, and every file in the folder is shared with the team. Move it out of the skill folder and push again.`);
|
|
397
|
+
const folders = [...TEAM_HARNESSES, ...roots.custom.map(entry => entry.name)].flatMap(name => {
|
|
398
|
+
const view = harnessView(roots, name);
|
|
399
|
+
return [skillRoot(view.roots, view.like), ownSkillRoot(view.roots, view.like)];
|
|
400
|
+
});
|
|
401
|
+
const manifest = basename(file) === "SKILL.md" && folders.includes(root) ? await readManifest(root) : null;
|
|
402
|
+
const synced = manifest?.skills.find(entry => entry.folder === basename(folder));
|
|
403
|
+
if (synced && manifest?.projectId !== projectId)
|
|
404
|
+
throw new Error("This copy was synced from another Relvo project. Push it with that project's login.");
|
|
405
|
+
if (synced && !synced.pending && hashesMatch(treeHashes(tree), entryHashes(synced)))
|
|
406
|
+
return { slug: synced.slug, version: synced.version, created: false, changed: false, files: [] };
|
|
407
|
+
const fromName = parsed.name?.startsWith(TEAM_SKILL_FOLDER_PREFIX) ? parsed.name.slice(TEAM_SKILL_FOLDER_PREFIX.length) : parsed.name ?? basename(folder);
|
|
408
|
+
const slug = slugOption ?? synced?.slug ?? fromName.toLowerCase().replaceAll("-", "_");
|
|
409
|
+
if (synced && slugOption && slugOption !== synced.slug)
|
|
410
|
+
throw new Error(`This copy is the team skill ${synced.slug}. Copy it to another folder to push it under another slug.`);
|
|
411
|
+
const stored = synced && !synced.scripts && (tree.length > 1 || synced.executes !== false) ? (await client.teamSkill(slug)).data.skill : undefined;
|
|
412
|
+
const storedScripts = new Set(stored?.files?.filter(entry => entry.executable).map(entry => entry.path));
|
|
413
|
+
const withheld = skillExecutingFiles(stored?.files).filter(file => !tree.some(local => local.path.toLowerCase() === file.path.toLowerCase()));
|
|
414
|
+
const files = platformSkillFilesSchema.parse([...tree.filter(file => file.path !== "SKILL.md").map(file => ({ path: file.path, data: file.bytes.toString("base64"),
|
|
415
|
+
...(file.executable || storedScripts.has(file.path) ? { executable: true } : {}) })), ...withheld]);
|
|
416
|
+
const header = readSkillMarkdown(text), local = header.hasMetadata ? header.frontmatter : "";
|
|
417
|
+
const frontmatter = stored ? restoreFrontmatterFields(local, stored.frontmatter, SKILL_EXECUTING_FIELDS) : local;
|
|
418
|
+
const skill = { slug, ...(!synced ? { name: fromName.replaceAll("_", "-") } : {}), title: parsed.title ?? parsed.name ?? slug, description: parsed.description ?? "", content: parsed.content, files, frontmatter };
|
|
419
|
+
const checked = platformTeamSkillCreateSchema.safeParse(skill);
|
|
420
|
+
if (!checked.success) {
|
|
421
|
+
const fields = [...new Set(checked.error.issues.map(issue => String(issue.path[0])))];
|
|
422
|
+
const names = { slug: "slug (lowercase letters, digits and underscores, from the frontmatter name or --slug)",
|
|
423
|
+
name: "name (frontmatter: lowercase letters, digits and single hyphens, at most 40 characters)",
|
|
424
|
+
title: "title (the first # heading)", description: "description (frontmatter: when an agent should follow it)", content: "content (the steps after the heading)" };
|
|
425
|
+
throw new Error(`The SKILL.md cannot be a team skill. Fix: ${fields.map(field => `${names[field] ?? field}: ${checked.error.issues.find(issue => String(issue.path[0]) === field)?.message}`).join("; ")}.`);
|
|
426
|
+
}
|
|
427
|
+
const credential = credentialIn(tree.map(file => file.bytes.toString("utf8")).join("\n"));
|
|
428
|
+
if (credential)
|
|
429
|
+
throw new Error(`The skill looks like it contains ${credential.kind} (line ${credential.line} of the title, description and content). Team skills are shared with the team: remove it and push again.`);
|
|
430
|
+
try {
|
|
431
|
+
if (synced) {
|
|
432
|
+
const { slug: _slug, ...definition } = checked.data;
|
|
433
|
+
const allowScripts = synced.scripts === true;
|
|
434
|
+
const saved = (await client.updateTeamSkill(slug, { ...definition, expectedVersion: synced.version })).data.skill, rendered = renderTeamSkill(saved, allowScripts);
|
|
435
|
+
const written = installedFiles(saved.files ? saved : { files }, allowScripts);
|
|
436
|
+
const updated = { slug, ...(saved.name ? { name: saved.name } : {}), folder: synced.folder, version: saved.version, sha256: digest(rendered), files: hashesOf(written, allowScripts), scripts: allowScripts,
|
|
437
|
+
executes: executes(saved), previous: digest(text), previousFiles: Object.fromEntries(Object.entries(treeHashes(tree)).filter(([path]) => path !== "SKILL.md")), pending: true };
|
|
438
|
+
await writeManifest(root, projectId, manifest.skills.map(entry => entry.slug === slug ? updated : entry));
|
|
439
|
+
await writeSkillTree(folder, rendered, written, allowScripts);
|
|
440
|
+
await writeManifest(root, projectId, manifest.skills.map(entry => entry.slug === slug ? settled(updated) : entry));
|
|
441
|
+
return { slug, version: saved.version, created: false, changed: true, files: files.map(entry => entry.path) };
|
|
442
|
+
}
|
|
443
|
+
const saved = (await client.createTeamSkill(checked.data)).data.skill;
|
|
444
|
+
return { slug, version: saved.version, created: true, changed: true, files: files.map(entry => entry.path) };
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (!(error instanceof PlatformApiError))
|
|
448
|
+
throw error;
|
|
449
|
+
if (error.code === "platform_team_item_name_exists")
|
|
450
|
+
throw new Error("That environment name is already in use. Choose another name in the SKILL.md frontmatter and push again.");
|
|
451
|
+
if (error.code === "platform_team_skill_exists")
|
|
452
|
+
throw new Error(`The team already has a skill named ${slug}. Run relvo team-skills sync and edit its copy (${teamSkillFolder(slug)}), or push this one with another --slug.`);
|
|
453
|
+
if (error.code === "platform_team_skill_version_conflict")
|
|
454
|
+
throw new Error(`${slug} changed in Relvo after this copy was synced. Move your edited copy out of ${teamSkillFolder(slug)}, run relvo team-skills sync, then apply your edit to the new copy and push again.`);
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|