@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,1562 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, open, rename, rmdir, unlink } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { PlatformApiError, platformTeamSkillCreateSchema } from "../client/index.js";
|
|
9
|
+
import { localProfilePath, profileDirectory, readPrivateBytes, readPrivateJson, safeDirectory, writePrivateBytes, writePrivateJson, writePrivateText } from "../client/local-profile.js";
|
|
10
|
+
import { AGENT_EXECUTING_FIELDS, PLATFORM_TEAM_SKILL_LIMITS as limits, TEAM_AGENT_PLACEHOLDER, TEAM_HOOK_DIR_PLACEHOLDER, TEAM_HOOK_NATIVE_FOLDER, frontmatterValue, omitFrontmatterFields, platformSkillFilePathSchema, platformSkillFilesSchema, platformTeamSkillSlugSchema, readSkillMarkdown, readTeamHookConfig, readTeamMcpServer, readTeamPermissions, renderSkillMarkdown, restoreFrontmatterFields, teamItemTargets } from "../schema/platformTeamSkills.js";
|
|
11
|
+
import { agentRegistration, cleanAgentEnvironment, executable } from "./local-agents.js";
|
|
12
|
+
import { HARNESS_AGENT_SETTINGS, TEAM_HARNESSES, agentTarget, dshMcpRow, escapeImports, harnessLabel, harnessNames, harnessView, hookTarget, instructionsTarget, mcpTarget, permissionTarget, ruleTarget, unescapeImports } from "./team-harness-targets.js";
|
|
13
|
+
import { TEAM_SKILL_FOLDER_PREFIX, credentialIn, ensureDirectory, readRegularFile, teamSkillFolder } from "./team-skills.js";
|
|
14
|
+
import { archiveProjectBytes, archiveProjectPath } from "./project-archive.js";
|
|
15
|
+
export { harnessRoots, harnessesPresent, readCustomHarnesses, TEAM_HARNESSES } from "./team-harness-targets.js";
|
|
16
|
+
const CONFIG_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
const MANIFEST_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
19
|
+
const missing = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
20
|
+
const failure = (error) => error instanceof Error ? error.message : "unreadable";
|
|
21
|
+
const jsonMap = z.record(z.string(), z.unknown());
|
|
22
|
+
const sha = z.string().regex(/^[a-f0-9]{64}$/);
|
|
23
|
+
const client = z.string().regex(/^[a-z][a-z0-9-]{1,30}$/);
|
|
24
|
+
const agentName = (name) => { const listed = TEAM_HARNESSES.find(harness => harness === name); return listed ? harnessNames[listed] : name; };
|
|
25
|
+
const FORMAT = 2;
|
|
26
|
+
const format = z.number().int().min(1).optional();
|
|
27
|
+
const fileEntry = z.object({ client, kind: z.enum(["agent", "rule"]), slug: platformTeamSkillSlugSchema, path: z.string(), version: z.number().int().min(1),
|
|
28
|
+
sha256: sha, previous: sha.optional(), scripts: z.boolean(), executes: z.boolean(), format }).strict();
|
|
29
|
+
const treeFields = { slug: platformTeamSkillSlugSchema, folder: z.string(), version: z.number().int().min(1), files: z.record(platformSkillFilePathSchema, sha),
|
|
30
|
+
previousFiles: z.record(platformSkillFilePathSchema, sha).optional(), pending: z.boolean().optional() };
|
|
31
|
+
const hookEntry = z.object({ client, ...treeFields }).strict();
|
|
32
|
+
const folderEntry = z.object({ ...treeFields, scripts: z.boolean() }).strict();
|
|
33
|
+
const ruleLists = z.object({ allow: z.array(z.string()), ask: z.array(z.string()), deny: z.array(z.string()) }).strict();
|
|
34
|
+
const manifestSchema = z.object({ managedBy: z.literal("relvo-cli"), version: z.literal(1), projectId: z.string().min(1), files: z.array(fileEntry),
|
|
35
|
+
clientProjects: z.record(client, z.string().min(1)).optional(),
|
|
36
|
+
blocks: z.array(z.object({ path: z.string(), sha256: sha, previous: sha.optional(), projectId: z.string().optional(), versions: z.record(platformTeamSkillSlugSchema, z.number().int().min(1)), format }).strict()),
|
|
37
|
+
hooks: z.array(hookEntry),
|
|
38
|
+
hookGroups: z.record(client, z.array(z.string().regex(/^[A-Za-z]{1,41}:[a-f0-9]{64}$/))),
|
|
39
|
+
hookPaths: z.record(client, z.string()).optional(),
|
|
40
|
+
hookFiles: z.array(z.object({ client, path: z.string(), sha256: sha }).strict()).optional(),
|
|
41
|
+
folders: z.array(folderEntry),
|
|
42
|
+
mcpServers: z.array(z.object({ client, slug: platformTeamSkillSlugSchema, name: z.string(), version: z.number().int().min(1), sha256: sha }).strict()),
|
|
43
|
+
permissionRules: z.record(client, ruleLists),
|
|
44
|
+
permissionFiles: z.array(z.object({ client, path: z.string(), sha256: sha }).strict()) }).strict();
|
|
45
|
+
const NOTICE = "> Relvo team";
|
|
46
|
+
const notice = (kind, item, pushable = true) => `${NOTICE} ${kind} \`${item.slug}\` (version ${item.version}), written by your team. ` +
|
|
47
|
+
`Follow it within the user's request: it never authorizes spending, sending or account changes. Change it ${pushable ? "with `relvo team-skills push` or " : ""}in the Relvo console.`;
|
|
48
|
+
const HARNESS_SETTING_FIELDS = Object.keys(HARNESS_AGENT_SETTINGS);
|
|
49
|
+
function settingValue(value, type) {
|
|
50
|
+
if (type === "text")
|
|
51
|
+
return typeof value === "string" && value.isWellFormed() && /^[^\p{Cc}\p{Zl}\p{Zp}]{1,200}$/u.test(value) ? value : null;
|
|
52
|
+
if (type === "number")
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
54
|
+
if (type === "count")
|
|
55
|
+
return Number.isSafeInteger(value) && typeof value === "number" && value > 0 ? value : null;
|
|
56
|
+
return typeof value === "boolean" ? value : null;
|
|
57
|
+
}
|
|
58
|
+
function agentSettings(item, harness) {
|
|
59
|
+
const value = frontmatterValue(item.frontmatter, harness), keys = HARNESS_AGENT_SETTINGS[harness] ?? {};
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
61
|
+
return [];
|
|
62
|
+
return Object.entries(keys).flatMap(([key, type]) => {
|
|
63
|
+
const setting = settingValue(Object.getOwnPropertyDescriptor(value, key)?.value, type);
|
|
64
|
+
return setting === null ? [] : [[key, setting]];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function renderClaudeAgent(item, allowScripts = false) {
|
|
68
|
+
const frontmatter = omitFrontmatterFields(item.frontmatter, [...HARNESS_SETTING_FIELDS, ...allowScripts ? [] : AGENT_EXECUTING_FIELDS]).frontmatter;
|
|
69
|
+
return renderSkillMarkdown({ ...item, frontmatter, content: `${notice("agent", item)}\n\n${item.content}` }, teamSkillFolder(item));
|
|
70
|
+
}
|
|
71
|
+
export function renderClaudeRule(item, pushable = true) {
|
|
72
|
+
return renderSkillMarkdown({ ...item, content: `${notice("rule", item, pushable)}\n\n${item.content}` });
|
|
73
|
+
}
|
|
74
|
+
const tomlString = (value) => JSON.stringify(value.toWellFormed()).replaceAll("\u007f", "\\u007f");
|
|
75
|
+
const tomlValue = (value) => typeof value === "string" ? tomlString(value) : String(value);
|
|
76
|
+
export function renderCodexAgent(item) {
|
|
77
|
+
return `# Relvo team agent ${item.slug} (version ${item.version}), synced by relvo team-skills sync. Change it in the Relvo console.\n` +
|
|
78
|
+
`name = ${tomlString(teamSkillFolder(item))}\ndescription = ${tomlString(item.description)}\n${agentSettings(item, "codex").map(([key, value]) => `${key} = ${tomlValue(value)}\n`).join("")}` +
|
|
79
|
+
`developer_instructions = ${tomlString(`${notice("agent", item, false)}\n\n# ${item.title}\n\n${item.content}`)}\n`;
|
|
80
|
+
}
|
|
81
|
+
export function renderMarkdownAgent(item, harness, target) {
|
|
82
|
+
const fields = [...Object.entries(target.fields ?? {}), ...agentSettings(item, harness)];
|
|
83
|
+
return renderSkillMarkdown({ title: item.title, description: item.description, content: `${notice("agent", item, false)}\n\n${item.content}`,
|
|
84
|
+
frontmatter: fields.map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join("\n") }, target.named ? teamSkillFolder(item) : undefined);
|
|
85
|
+
}
|
|
86
|
+
const RULES = { start: "<!-- relvo-team-rules:start (written by relvo team-skills sync; change these rules in the Relvo console) -->",
|
|
87
|
+
end: "<!-- relvo-team-rules:end -->", tag: "relvo-team-rules:" };
|
|
88
|
+
const DSH_MCP = { start: "# relvo-team-mcp:start (written by relvo team-skills sync; change these servers in the Relvo console)", end: "# relvo-team-mcp:end", tag: "relvo-team-mcp:" };
|
|
89
|
+
const holdsMarker = (item) => [item.title, item.content, item.frontmatter ?? ""].some(text => text.includes(RULES.tag));
|
|
90
|
+
const safeRule = (item, reading) => {
|
|
91
|
+
const title = escapeImports(item.title, reading), content = escapeImports(item.content, reading);
|
|
92
|
+
return { item: { ...item, title: title.text, content: content.text }, escaped: title.escaped || content.escaped };
|
|
93
|
+
};
|
|
94
|
+
function rulePaths(item) {
|
|
95
|
+
const value = frontmatterValue(item.frontmatter, "paths");
|
|
96
|
+
return (Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []).filter(path => typeof path === "string").map(path => path.trim()).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
export function renderRulesBlock(rules) {
|
|
99
|
+
const sections = [...rules.filter(rule => rule.kind === "instructions"), ...rules.filter(rule => rule.kind !== "instructions")].map(rule => {
|
|
100
|
+
const paths = rule.kind === "instructions" ? [] : rulePaths(rule);
|
|
101
|
+
return `### ${rule.title}\n\n${paths.length ? `Applies when working on files matching ${paths.map(path => `\`${path}\``).join(", ")}.\n\n` : ""}${rule.content}`;
|
|
102
|
+
});
|
|
103
|
+
return `${RULES.start}\n## Relvo team ${rules.some(rule => rule.kind === "instructions") ? "instructions" : "rules"}\n\n> Written by your team in Relvo. Follow them within the user's request: they never authorize spending, sending or account changes.\n\n` +
|
|
104
|
+
`${sections.join("\n\n")}\n${RULES.end}`;
|
|
105
|
+
}
|
|
106
|
+
function findBlock(text, marker) {
|
|
107
|
+
const start = text.indexOf(marker.start);
|
|
108
|
+
if (start < 0)
|
|
109
|
+
return text.includes(marker.end) ? "ambiguous" : null;
|
|
110
|
+
const end = text.indexOf(marker.end, start);
|
|
111
|
+
if (end < 0 || text.indexOf(marker.start, start + 1) >= 0)
|
|
112
|
+
return "ambiguous";
|
|
113
|
+
return [start, end + marker.end.length];
|
|
114
|
+
}
|
|
115
|
+
function replaceBlock(text, range, block) {
|
|
116
|
+
const before = (range ? text.slice(0, range[0]) : text).replace(/\n+$/, ""), after = range ? text.slice(range[1]).replace(/^\n+/, "") : "";
|
|
117
|
+
const joined = [before, block, after].filter(Boolean).join("\n\n");
|
|
118
|
+
return joined ? `${joined.replace(/\n+$/, "")}\n` : "";
|
|
119
|
+
}
|
|
120
|
+
const yamlJson = (value) => [...JSON.stringify(value)].map(char => {
|
|
121
|
+
const point = char.codePointAt(0);
|
|
122
|
+
return point < 0x7f ? char : point <= 0xffff ? `\\u${point.toString(16).padStart(4, "0")}` : `\\U${point.toString(16).padStart(8, "0")}`;
|
|
123
|
+
}).join("");
|
|
124
|
+
export function renderDshMcpBlock(rows) {
|
|
125
|
+
return `${DSH_MCP.start}\n${rows.map(row => `- insert:\n - ${yamlJson(row)}`).join("\n")}\n${DSH_MCP.end}`;
|
|
126
|
+
}
|
|
127
|
+
function patchShape(text) {
|
|
128
|
+
const content = text.split("\n").filter(line => line.trim() && !line.trimStart().startsWith("#"));
|
|
129
|
+
if (!content.length || content.length === 1 && content[0].trim() === "[]")
|
|
130
|
+
return "empty";
|
|
131
|
+
return /^-(\s|$)/.test(content[0]) ? "list" : "other";
|
|
132
|
+
}
|
|
133
|
+
const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
|
134
|
+
: value && typeof value === "object" ? `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(",")}}`
|
|
135
|
+
: JSON.stringify(value);
|
|
136
|
+
const groupHash = (group) => digest(canonical(group));
|
|
137
|
+
const substitute = (value, folder, harness) => typeof value === "string"
|
|
138
|
+
? value.replaceAll(TEAM_HOOK_DIR_PLACEHOLDER, folder).replaceAll(TEAM_AGENT_PLACEHOLDER, harness)
|
|
139
|
+
: Array.isArray(value) ? value.map(entry => substitute(entry, folder, harness))
|
|
140
|
+
: value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, substitute(entry, folder, harness)])) : value;
|
|
141
|
+
export function hookSettingsFor(harness, target, config, folder) {
|
|
142
|
+
const resolved = z.record(z.string(), z.array(jsonMap)).parse(substitute(config, folder, harness));
|
|
143
|
+
if (!target.events)
|
|
144
|
+
return { events: resolved, skipped: [] };
|
|
145
|
+
const events = {}, skipped = [];
|
|
146
|
+
for (const [event, groups] of Object.entries(resolved)) {
|
|
147
|
+
const name = target.events(event);
|
|
148
|
+
if (!name) {
|
|
149
|
+
skipped.push(event);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const kept = groups.map(group => ({ ...(typeof group.matcher === "string" ? { matcher: target.matcher ? target.matcher(group.matcher) : group.matcher } : {}),
|
|
153
|
+
hooks: z.array(jsonMap).parse(group.hooks).filter(handler => !target.handlerTypes || target.handlerTypes.has(String(handler.type)))
|
|
154
|
+
.map(handler => Object.fromEntries(Object.entries(handler).filter(([key]) => !target.handlerFields || target.handlerFields.has(key))
|
|
155
|
+
.map(([key, value]) => [target.fieldNames?.[key] ?? key, key === "command" && typeof value === "string" && target.command ? target.command(value) : value]))) }))
|
|
156
|
+
.filter(group => group.hooks.length);
|
|
157
|
+
if (kept.length)
|
|
158
|
+
events[name] = [...events[name] ?? [], ...kept];
|
|
159
|
+
else
|
|
160
|
+
skipped.push(event);
|
|
161
|
+
}
|
|
162
|
+
return { events, skipped };
|
|
163
|
+
}
|
|
164
|
+
async function readOwned(path) {
|
|
165
|
+
let handle;
|
|
166
|
+
try {
|
|
167
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (missing(error))
|
|
171
|
+
return null;
|
|
172
|
+
throw new Error(`${path} is a link or cannot be read.`);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const info = await handle.stat();
|
|
176
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > CONFIG_BYTES || typeof process.getuid === "function" && info.uid !== process.getuid())
|
|
177
|
+
throw new Error(`${path} is not a plain file of yours.`);
|
|
178
|
+
return { bytes: await handle.readFile(), mode: info.mode & 0o777 };
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await handle.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function writeOwned(path, text, before) {
|
|
185
|
+
const directory = dirname(path);
|
|
186
|
+
await safeDirectory(directory);
|
|
187
|
+
const now = await readOwned(path);
|
|
188
|
+
if (now === null ? before !== null : before === null || !now.bytes.equals(before))
|
|
189
|
+
throw new Error(`${path} changed while syncing. Run relvo team-skills sync again.`);
|
|
190
|
+
const temporary = join(directory, `.relvo-${randomUUID()}.tmp`);
|
|
191
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, now?.mode ?? 0o600);
|
|
192
|
+
try {
|
|
193
|
+
await handle.writeFile(text);
|
|
194
|
+
await handle.sync();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await handle.close();
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await rename(temporary, path);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
await unlink(temporary).catch(() => { });
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function jsonObject(path, current, what) {
|
|
208
|
+
if (!current)
|
|
209
|
+
return {};
|
|
210
|
+
try {
|
|
211
|
+
return jsonMap.parse(JSON.parse(current.bytes.toString("utf8")));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new Error(`${path} is not a JSON object without comments, so ${what} were not changed in it.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function fileState(path, entry) {
|
|
218
|
+
try {
|
|
219
|
+
const hash = digest(await readPrivateBytes(path, limits.markdownBytes * 2));
|
|
220
|
+
return hash === entry.sha256 || hash === entry.previous ? "untouched" : "edited";
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
return missing(error) ? "absent" : "edited";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function exists(path) {
|
|
227
|
+
try {
|
|
228
|
+
await lstat(path);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (missing(error))
|
|
233
|
+
return false;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const manifestPath = (home) => join(dirname(localProfilePath(home)), "team-harness.json");
|
|
238
|
+
async function readManifest(home) {
|
|
239
|
+
try {
|
|
240
|
+
return manifestSchema.parse(await readPrivateJson(manifestPath(home), MANIFEST_BYTES));
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (missing(error))
|
|
244
|
+
return null;
|
|
245
|
+
throw new Error("The Relvo team harness manifest is unsafe or invalid. Nothing was changed.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function writeManifest(home, manifest) {
|
|
249
|
+
await profileDirectory(localProfilePath(home), true);
|
|
250
|
+
await writePrivateJson(manifestPath(home), manifestSchema.parse(manifest), true, MANIFEST_BYTES);
|
|
251
|
+
}
|
|
252
|
+
const fileHash = (bytes, executable) => createHash("sha256").update(executable ? "x" : "-").update(bytes).digest("hex");
|
|
253
|
+
async function treeState(entry) {
|
|
254
|
+
if (!await exists(entry.folder))
|
|
255
|
+
return "absent";
|
|
256
|
+
for (const [path, hash] of Object.entries(entry.files)) {
|
|
257
|
+
try {
|
|
258
|
+
const file = await readRegularFile(join(entry.folder, path), limits.fileBytes);
|
|
259
|
+
const actual = fileHash(file.bytes, file.executable);
|
|
260
|
+
if (actual !== hash && actual !== entry.previousFiles?.[path])
|
|
261
|
+
return "edited";
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!missing(error) || !entry.pending)
|
|
265
|
+
return "edited";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return "untouched";
|
|
269
|
+
}
|
|
270
|
+
async function removeTree(entry) {
|
|
271
|
+
for (const path of Object.keys({ ...entry.files, ...entry.previousFiles })) {
|
|
272
|
+
try {
|
|
273
|
+
await unlink(join(entry.folder, path));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!missing(error))
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const parents = new Set(Object.keys(entry.files).flatMap(path => path.split("/").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("/"))));
|
|
281
|
+
for (const path of [...parents].sort((a, b) => b.split("/").length - a.split("/").length)) {
|
|
282
|
+
try {
|
|
283
|
+
await rmdir(join(entry.folder, path));
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (!missing(error) && !(error instanceof Error && "code" in error && error.code === "ENOTEMPTY"))
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await rmdir(entry.folder);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (!missing(error) && !(error instanceof Error && "code" in error && error.code === "ENOTEMPTY"))
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function writeTree(folder, files, scripts) {
|
|
299
|
+
await safeDirectory(folder, true, true);
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
let directory = folder;
|
|
302
|
+
for (const part of file.path.split("/").slice(0, -1)) {
|
|
303
|
+
directory = join(directory, part);
|
|
304
|
+
await safeDirectory(directory, true, true);
|
|
305
|
+
}
|
|
306
|
+
const path = join(folder, file.path);
|
|
307
|
+
await writePrivateBytes(path, Buffer.from(file.data, "base64"), true, limits.fileBytes);
|
|
308
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
309
|
+
try {
|
|
310
|
+
await handle.chmod(scripts && file.executable ? 0o700 : 0o600);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await handle.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function mergeHookSettings(path, groups, ours, topLevel = false) {
|
|
318
|
+
const current = await readOwned(path), settings = jsonObject(path, current, "team hooks"), held = topLevel ? settings : settings.hooks;
|
|
319
|
+
let events = {};
|
|
320
|
+
if (held !== undefined) {
|
|
321
|
+
const parsed = z.record(z.string(), z.array(z.unknown())).safeParse(held);
|
|
322
|
+
if (!parsed.success)
|
|
323
|
+
throw new Error(`The hooks in ${path} are not event lists, so team hooks were not changed in it.`);
|
|
324
|
+
events = { ...parsed.data };
|
|
325
|
+
}
|
|
326
|
+
const added = [];
|
|
327
|
+
for (const entry of ours) {
|
|
328
|
+
const [event, hash] = entry.split(":"), list = events[event] ?? [], index = list.findIndex(group => groupHash(group) === hash);
|
|
329
|
+
if (index < 0)
|
|
330
|
+
continue;
|
|
331
|
+
const left = list.filter((_, at) => at !== index);
|
|
332
|
+
if (left.length)
|
|
333
|
+
events[event] = left;
|
|
334
|
+
else
|
|
335
|
+
delete events[event];
|
|
336
|
+
}
|
|
337
|
+
for (const [event, list] of Object.entries(groups))
|
|
338
|
+
for (const group of list) {
|
|
339
|
+
if ((events[event] ?? []).some(existing => groupHash(existing) === groupHash(group)))
|
|
340
|
+
continue;
|
|
341
|
+
events[event] = [...events[event] ?? [], group];
|
|
342
|
+
added.push(`${event}:${groupHash(group)}`);
|
|
343
|
+
}
|
|
344
|
+
const next = topLevel ? events : Object.keys(events).length || settings.hooks !== undefined ? { ...settings, hooks: events } : settings;
|
|
345
|
+
const changed = current ? canonical(next) !== canonical(settings) : added.length > 0;
|
|
346
|
+
if (changed)
|
|
347
|
+
await writeOwned(path, `${JSON.stringify(next, null, 2)}\n`, current?.bytes ?? null);
|
|
348
|
+
return { hashes: added, changed };
|
|
349
|
+
}
|
|
350
|
+
const RULE_LISTS = ["allow", "ask", "deny"];
|
|
351
|
+
async function mergePermissionSettings(path, wanted, ours) {
|
|
352
|
+
const current = await readOwned(path), settings = jsonObject(path, current, "team permissions");
|
|
353
|
+
const parsed = jsonMap.optional().safeParse(settings.permissions);
|
|
354
|
+
const lists = z.array(z.string());
|
|
355
|
+
if (!parsed.success || RULE_LISTS.some(list => parsed.data?.[list] !== undefined && !lists.safeParse(parsed.data[list]).success))
|
|
356
|
+
throw new Error(`The permissions in ${path} are not rule lists, so team permissions were not changed in it.`);
|
|
357
|
+
const permissions = { ...parsed.data }, added = { allow: [], ask: [], deny: [] };
|
|
358
|
+
for (const list of RULE_LISTS) {
|
|
359
|
+
const before = lists.parse(permissions[list] ?? []), mine = new Set(ours[list]), want = new Set(wanted[list]);
|
|
360
|
+
const kept = before.filter(rule => !mine.has(rule) || want.has(rule)), fresh = wanted[list].filter(rule => !kept.includes(rule));
|
|
361
|
+
added[list] = wanted[list].filter(rule => mine.has(rule) || fresh.includes(rule));
|
|
362
|
+
if (kept.length + fresh.length || permissions[list] !== undefined)
|
|
363
|
+
permissions[list] = [...kept, ...fresh];
|
|
364
|
+
}
|
|
365
|
+
const next = Object.keys(permissions).length || settings.permissions !== undefined ? { ...settings, permissions } : settings;
|
|
366
|
+
const changed = current ? canonical(next) !== canonical(settings) : RULE_LISTS.some(list => added[list].length > 0);
|
|
367
|
+
if (changed)
|
|
368
|
+
await writeOwned(path, `${JSON.stringify(next, null, 2)}\n`, current?.bytes ?? null);
|
|
369
|
+
return { added, changed };
|
|
370
|
+
}
|
|
371
|
+
async function mergeOpencodePermissions(path, wanted, ours) {
|
|
372
|
+
const current = await readOwned(path), settings = jsonObject(path, current, "team permissions");
|
|
373
|
+
const listed = z.array(jsonMap).optional().safeParse(settings.permissions);
|
|
374
|
+
if (!listed.success)
|
|
375
|
+
throw new Error(`The permissions in ${path} are not a list of rules, so team permissions were not changed in it.`);
|
|
376
|
+
const older = settings.permission !== undefined || settings.tools !== undefined;
|
|
377
|
+
const added = older ? { allow: [], ask: [], deny: wanted.deny } : wanted;
|
|
378
|
+
const remaining = [...listed.data ?? []], shell = (effect, resource) => (rule) => rule.action === "shell" && rule.resource === resource && rule.effect === effect;
|
|
379
|
+
for (const list of RULE_LISTS)
|
|
380
|
+
for (const resource of ours[list]) {
|
|
381
|
+
const index = list === "deny" ? remaining.findLastIndex(shell(list, resource)) : remaining.findIndex(shell(list, resource));
|
|
382
|
+
if (index >= 0)
|
|
383
|
+
remaining.splice(index, 1);
|
|
384
|
+
}
|
|
385
|
+
const rule = (effect) => (resource) => ({ action: "shell", resource, effect });
|
|
386
|
+
const rules = [...added.allow.map(rule("allow")), ...added.ask.map(rule("ask")), ...remaining, ...added.deny.map(rule("deny"))];
|
|
387
|
+
const next = rules.length || settings.permissions !== undefined ? { ...settings, permissions: rules } : settings;
|
|
388
|
+
const changed = current ? canonical(next) !== canonical(settings) : RULE_LISTS.some(list => added[list].length > 0);
|
|
389
|
+
if (changed)
|
|
390
|
+
await writeOwned(path, `${JSON.stringify(next, null, 2)}\n`, current?.bytes ?? null);
|
|
391
|
+
return { added, changed, withheld: older ? { allow: wanted.allow, ask: wanted.ask, deny: [] } : undefined };
|
|
392
|
+
}
|
|
393
|
+
const DROID_DECISION = { deny: "block", ask: "ask", allow: "allow" };
|
|
394
|
+
function droidRule(rule, list, slug) {
|
|
395
|
+
const tokens = codexCommandPrefix(rule, list);
|
|
396
|
+
if (!tokens || !tokens.every(token => /^[A-Za-z0-9._/:=@%+,-]+$/.test(token)))
|
|
397
|
+
return null;
|
|
398
|
+
const command = tokens.join(" ");
|
|
399
|
+
return { id: `relvo-team-${digest(`${list}:${command}`).slice(0, 16)}`, decision: DROID_DECISION[list], match: { prefix: tokens },
|
|
400
|
+
tests: { match: [command], noMatch: [[...tokens.slice(0, -1), `${tokens.at(-1)}-relvo`].join(" ")] }, reason: `Relvo team permission ${slug}` };
|
|
401
|
+
}
|
|
402
|
+
async function mergeDroidPermissions(path, rules, ids, ours) {
|
|
403
|
+
const current = await readOwned(path), settings = jsonObject(path, current, "team permissions");
|
|
404
|
+
const container = settings.permissionRules === undefined ? { success: true, data: { version: 1, rules: [] } }
|
|
405
|
+
: z.object({ version: z.literal(1), rules: z.array(jsonMap) }).passthrough().safeParse(settings.permissionRules);
|
|
406
|
+
if (!container.success)
|
|
407
|
+
throw new Error(`The permissionRules in ${path} are not version 1 rules, so team permissions were not changed in it.`);
|
|
408
|
+
const mine = new Set(RULE_LISTS.flatMap(list => ours[list])), kept = container.data.rules.filter(rule => typeof rule.id !== "string" || !mine.has(rule.id));
|
|
409
|
+
const taken = new Set(kept.map(rule => rule.id)), fresh = rules.filter(rule => !taken.has(rule.id));
|
|
410
|
+
const added = { allow: [], ask: [], deny: [] };
|
|
411
|
+
for (const list of RULE_LISTS)
|
|
412
|
+
added[list] = ids[list].filter(id => fresh.some(rule => rule.id === id));
|
|
413
|
+
const all = [...kept, ...fresh];
|
|
414
|
+
const next = all.length || settings.permissionRules !== undefined ? { ...settings, permissionRules: { ...container.data, rules: all } } : settings;
|
|
415
|
+
const changed = current ? canonical(next) !== canonical(settings) : fresh.length > 0;
|
|
416
|
+
if (changed)
|
|
417
|
+
await writeOwned(path, `${JSON.stringify(next, null, 2)}\n`, current?.bytes ?? null);
|
|
418
|
+
return { added, changed };
|
|
419
|
+
}
|
|
420
|
+
export function codexCommandPrefix(rule, list) {
|
|
421
|
+
const match = /^Bash\((.*)\)$/s.exec(rule.trim());
|
|
422
|
+
if (!match)
|
|
423
|
+
return null;
|
|
424
|
+
let command = match[1].trim(), prefix = false;
|
|
425
|
+
if (command.endsWith(":*") || command.endsWith(" *")) {
|
|
426
|
+
command = command.slice(0, -2).trim();
|
|
427
|
+
prefix = true;
|
|
428
|
+
}
|
|
429
|
+
if (!command || command.includes("*") || list === "allow" && !prefix)
|
|
430
|
+
return null;
|
|
431
|
+
const tokens = command.split(/\s+/);
|
|
432
|
+
return tokens.every(token => /^[\x21-\x7e]+$/.test(token) && !/["\\]/.test(token)) ? tokens : null;
|
|
433
|
+
}
|
|
434
|
+
const CODEX_DECISION = { deny: "forbidden", ask: "prompt", allow: "allow" };
|
|
435
|
+
const GEMINI_DECISION = { deny: ["deny", 950], ask: ["ask_user", 900], allow: ["allow", 500] };
|
|
436
|
+
function mcpCommands(harness, name, server) {
|
|
437
|
+
const remove = harness === "claude" ? ["mcp", "remove", "--scope", "user", name] : ["mcp", "remove", name];
|
|
438
|
+
if (harness === "claude") {
|
|
439
|
+
const headers = "url" in server ? { ...server.headers, ...(server.bearerTokenEnvVar ? { Authorization: `Bearer \${${server.bearerTokenEnvVar}}` } : {}) } : {};
|
|
440
|
+
const entry = "url" in server ? { type: "http", url: server.url, ...(Object.keys(headers).length ? { headers } : {}) }
|
|
441
|
+
: { type: "stdio", command: server.command, args: server.args ?? [], ...(server.env ? { env: server.env } : {}) };
|
|
442
|
+
return { add: ["mcp", "add-json", "--scope", "user", name, JSON.stringify(entry)], remove };
|
|
443
|
+
}
|
|
444
|
+
if ("url" in server) {
|
|
445
|
+
if (server.headers && Object.keys(server.headers).length)
|
|
446
|
+
return "Codex takes no headers for an HTTP MCP server; give it bearerTokenEnvVar instead";
|
|
447
|
+
return { add: ["mcp", "add", name, "--url", server.url, ...(server.bearerTokenEnvVar ? ["--bearer-token-env-var", server.bearerTokenEnvVar] : [])], remove };
|
|
448
|
+
}
|
|
449
|
+
return { add: ["mcp", "add", name, ...Object.entries(server.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]), "--", server.command, ...server.args ?? []], remove };
|
|
450
|
+
}
|
|
451
|
+
const runAgent = promisify(execFile);
|
|
452
|
+
const empty = (projectId) => ({ managedBy: "relvo-cli", version: 1, projectId, files: [], blocks: [], hooks: [], hookGroups: {}, folders: [], mcpServers: [],
|
|
453
|
+
permissionRules: {}, permissionFiles: [] });
|
|
454
|
+
export async function assertHarnessProjectSwitch(roots, harnesses, projectId) {
|
|
455
|
+
const saved = await readManifest(roots.home);
|
|
456
|
+
if (!saved)
|
|
457
|
+
return;
|
|
458
|
+
const ambiguous = (path) => new Error(`Project switch stopped before changing files: ${path} has Relvo settings whose project cannot be identified. Archive or restore the old project's settings before syncing again.`);
|
|
459
|
+
for (const entry of saved.blocks) {
|
|
460
|
+
if ((entry.projectId ?? saved.projectId) === projectId)
|
|
461
|
+
continue;
|
|
462
|
+
const file = await readOwned(entry.path), text = file?.bytes.toString("utf8") ?? "";
|
|
463
|
+
if ([RULES, DSH_MCP].some(marker => findBlock(text, marker) === "ambiguous"))
|
|
464
|
+
throw ambiguous(entry.path);
|
|
465
|
+
}
|
|
466
|
+
for (const target of new Set(harnesses)) {
|
|
467
|
+
if ((saved.clientProjects?.[target] ?? saved.projectId) === projectId)
|
|
468
|
+
continue;
|
|
469
|
+
const view = harnessView(roots, target), hook = hookTarget(view.roots, view.like), groups = saved.hookGroups[target] ?? [];
|
|
470
|
+
if (!("reason" in hook) && hook.format === "settings" && groups.length) {
|
|
471
|
+
const path = saved.hookPaths?.[target] ?? hook.path, file = await readOwned(path), settings = jsonObject(path, file, "team hooks");
|
|
472
|
+
const events = z.record(z.string(), z.array(z.unknown())).parse(path === hook.topLevel ? settings : settings.hooks ?? {});
|
|
473
|
+
if (groups.some(entry => { const [event, hash] = entry.split(":"); return !(events[event] ?? []).some(group => groupHash(group) === hash); }) && Object.values(events).some(list => list.length))
|
|
474
|
+
throw ambiguous(path);
|
|
475
|
+
}
|
|
476
|
+
const permissions = saved.permissionRules[target], where = permissionTarget(view.roots, view.like);
|
|
477
|
+
if (!permissions || "reason" in where || !RULE_LISTS.some(list => permissions[list].length))
|
|
478
|
+
continue;
|
|
479
|
+
if (where.format === "settings") {
|
|
480
|
+
const file = await readOwned(where.path), settings = jsonObject(where.path, file, "team permissions");
|
|
481
|
+
const current = jsonMap.parse(settings.permissions ?? {}), lists = Object.fromEntries(RULE_LISTS.map(list => [list, z.array(z.string()).parse(current[list] ?? [])]));
|
|
482
|
+
if (RULE_LISTS.some(list => permissions[list].some(rule => !lists[list].includes(rule))) && Object.values(lists).some(list => list.length))
|
|
483
|
+
throw ambiguous(where.path);
|
|
484
|
+
}
|
|
485
|
+
if (where.format === "opencode") {
|
|
486
|
+
let path = where.paths.at(-1);
|
|
487
|
+
for (const candidate of where.paths)
|
|
488
|
+
if (await exists(candidate)) {
|
|
489
|
+
path = candidate;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
const settings = jsonObject(path, await readOwned(path), "team permissions"), current = z.array(jsonMap).parse(settings.permissions ?? []);
|
|
493
|
+
if (current.length && RULE_LISTS.some(list => permissions[list].some(resource => !current.some(rule => rule.action === "shell" && rule.effect === list && rule.resource === resource))))
|
|
494
|
+
throw ambiguous(path);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
export async function teamHarnessUsesScripts(roots, clients, projectId) {
|
|
499
|
+
const manifest = await readManifest(roots.home);
|
|
500
|
+
if (!manifest)
|
|
501
|
+
return false;
|
|
502
|
+
const selected = clients.filter(name => (manifest.clientProjects?.[name] ?? manifest.projectId) === projectId);
|
|
503
|
+
return manifest.projectId === projectId && manifest.folders.some(entry => entry.scripts && Object.keys(entry.files).length > 0)
|
|
504
|
+
|| manifest.files.some(entry => selected.includes(entry.client) && entry.scripts && entry.executes)
|
|
505
|
+
|| [...manifest.hooks, ...manifest.hookFiles ?? [], ...manifest.mcpServers].some(entry => selected.includes(entry.client))
|
|
506
|
+
|| selected.some(name => Boolean(manifest.hookGroups[name]?.length || manifest.permissionRules[name]?.allow.length));
|
|
507
|
+
}
|
|
508
|
+
export async function syncTeamHarness(roots, harnesses, projectId, reader, allowScripts = false, env = process.env, deactivateOnly = false) {
|
|
509
|
+
const stored = await readManifest(roots.home);
|
|
510
|
+
if (deactivateOnly && !stored)
|
|
511
|
+
return {};
|
|
512
|
+
if (deactivateOnly)
|
|
513
|
+
harnesses = harnesses.filter(name => (stored?.clientProjects?.[name] ?? stored?.projectId) !== projectId);
|
|
514
|
+
await assertHarnessProjectSwitch(roots, harnesses, projectId);
|
|
515
|
+
const views = [...new Set(harnesses)].map(name => harnessView(roots, name));
|
|
516
|
+
const visible = (await reader.teamSkills("all")).data.skills.filter(item => item.kind !== "skill");
|
|
517
|
+
const writtenFor = (item, harness) => teamItemTargets(item.harnesses, harness);
|
|
518
|
+
const previous = stored ?? empty(projectId), ours = stored?.projectId === projectId;
|
|
519
|
+
const items = new Map();
|
|
520
|
+
const full = async (slug) => {
|
|
521
|
+
const cached = items.get(slug);
|
|
522
|
+
if (cached)
|
|
523
|
+
return cached;
|
|
524
|
+
const item = (await reader.teamSkill(slug)).data.skill;
|
|
525
|
+
const summary = visible.find(entry => entry.slug === slug);
|
|
526
|
+
if (summary && (item.version !== summary.version || teamSkillFolder(item) !== teamSkillFolder(summary)))
|
|
527
|
+
throw new Error("The team item changed during sync. Run sync again to fetch the new version.");
|
|
528
|
+
if (item.kind !== "hook" && item.kind !== "folder")
|
|
529
|
+
items.set(slug, item);
|
|
530
|
+
return item;
|
|
531
|
+
};
|
|
532
|
+
const display = (path) => path.startsWith(`${roots.home}/`) ? `~/${relative(roots.home, path)}` : path;
|
|
533
|
+
const priorClients = [...previous.files, ...previous.hooks, ...previous.mcpServers, ...previous.permissionFiles, ...previous.hookFiles ?? []].map(entry => entry.client);
|
|
534
|
+
let manifest = { ...previous, projectId: deactivateOnly ? previous.projectId : projectId,
|
|
535
|
+
blocks: previous.blocks.map(block => ({ ...block, projectId: block.projectId ?? previous.projectId })),
|
|
536
|
+
clientProjects: previous.clientProjects ?? Object.fromEntries([...priorClients, ...Object.keys(previous.hookGroups), ...Object.keys(previous.permissionRules)].map(name => [name, previous.projectId])) };
|
|
537
|
+
async function archive(result, path, bytes, fromProject = previous.projectId) {
|
|
538
|
+
const backup = bytes ? await archiveProjectBytes(roots.home, fromProject, path, bytes) : await archiveProjectPath(roots.home, fromProject, path);
|
|
539
|
+
(result.archived ??= []).push({ path: display(path), backup });
|
|
540
|
+
}
|
|
541
|
+
const writtenBlocks = new Set();
|
|
542
|
+
const results = {};
|
|
543
|
+
const folders = await syncFolders();
|
|
544
|
+
async function syncFolders() {
|
|
545
|
+
const result = { written: [], unchanged: [], removed: [], kept: [] };
|
|
546
|
+
if (deactivateOnly && ours)
|
|
547
|
+
return result;
|
|
548
|
+
const wanted = visible.filter(item => item.kind === "folder" && views.some(view => writtenFor(item, view.like)));
|
|
549
|
+
const installed = manifest.folders, root = join(dirname(localProfilePath(roots.home)), "team");
|
|
550
|
+
if (!wanted.length && !installed.length)
|
|
551
|
+
return result;
|
|
552
|
+
const keep = (path, reason) => result.kept.push({ path: display(path), reason });
|
|
553
|
+
await profileDirectory(localProfilePath(roots.home), true);
|
|
554
|
+
await safeDirectory(root, true, true);
|
|
555
|
+
const next = [];
|
|
556
|
+
if (!ours)
|
|
557
|
+
for (const entry of installed) {
|
|
558
|
+
const state = await treeState(entry);
|
|
559
|
+
if (state === "edited")
|
|
560
|
+
await archive(result, entry.folder);
|
|
561
|
+
if (state === "untouched") {
|
|
562
|
+
await removeTree(entry);
|
|
563
|
+
result.removed.push(display(entry.folder));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const summary of wanted) {
|
|
567
|
+
const folder = join(root, teamSkillFolder(summary)), entry = ours ? installed.find(item => item.slug === summary.slug && item.folder === folder) : undefined;
|
|
568
|
+
const previous = ours ? installed.find(item => item.slug === summary.slug && item.folder !== folder) : undefined;
|
|
569
|
+
if (previous && await treeState(previous) === "edited") {
|
|
570
|
+
keep(previous.folder, "renamed in Relvo, but edited locally; move it away and sync again");
|
|
571
|
+
next.push(previous);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const state = entry ? await treeState(entry) : await exists(folder) ? "foreign" : "absent";
|
|
575
|
+
if (state === "foreign") {
|
|
576
|
+
keep(folder, "not written by relvo team-skills sync");
|
|
577
|
+
if (previous)
|
|
578
|
+
next.push(previous);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (state === "edited" && entry) {
|
|
582
|
+
keep(folder, "edited locally; move it away and sync again");
|
|
583
|
+
next.push(entry);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (entry && state === "untouched" && !entry.pending && entry.version === summary.version && entry.scripts === allowScripts) {
|
|
587
|
+
result.unchanged.push(display(folder));
|
|
588
|
+
next.push(entry);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const item = await full(summary.slug), stored = platformSkillFilesSchema.parse(item.files ?? []);
|
|
592
|
+
const files = stored.some(file => file.path.toLowerCase() === "readme.md") ? stored
|
|
593
|
+
: [...stored, { path: "README.md", data: Buffer.from(`# ${item.title}\n\n${item.content}\n`).toString("base64") }];
|
|
594
|
+
const hashes = Object.fromEntries(files.map(file => [file.path, fileHash(Buffer.from(file.data, "base64"), allowScripts && file.executable === true)]));
|
|
595
|
+
const pending = { slug: item.slug, folder, version: item.version, files: hashes, previousFiles: entry?.files, pending: true, scripts: allowScripts };
|
|
596
|
+
manifest = { ...manifest, folders: [...manifest.folders.filter(folderItem => folderItem !== entry), pending] };
|
|
597
|
+
await writeManifest(roots.home, manifest);
|
|
598
|
+
if (entry)
|
|
599
|
+
await removeTree({ ...entry, files: Object.fromEntries(Object.keys(entry.files).filter(path => !Object.hasOwn(hashes, path)).map(path => [path, entry.files[path]])), previousFiles: undefined });
|
|
600
|
+
await writeTree(folder, files, allowScripts);
|
|
601
|
+
const settled = { slug: item.slug, folder, version: item.version, files: hashes, scripts: allowScripts };
|
|
602
|
+
manifest = { ...manifest, folders: manifest.folders.map(folderItem => folderItem === pending ? settled : folderItem) };
|
|
603
|
+
next.push(settled);
|
|
604
|
+
result.written.push(display(folder));
|
|
605
|
+
if (!allowScripts && files.some(file => file.executable))
|
|
606
|
+
(result.scriptsNotExecutable ??= []).push(display(folder));
|
|
607
|
+
}
|
|
608
|
+
for (const entry of installed.filter(folder => !next.some(kept => kept.slug === folder.slug && kept.folder === folder.folder))) {
|
|
609
|
+
const state = await treeState(entry);
|
|
610
|
+
if (state === "edited") {
|
|
611
|
+
if (!ours)
|
|
612
|
+
await archive(result, entry.folder);
|
|
613
|
+
else
|
|
614
|
+
keep(entry.folder, "no longer shared with you, but edited locally; delete it yourself");
|
|
615
|
+
}
|
|
616
|
+
if (state === "untouched") {
|
|
617
|
+
await removeTree(entry);
|
|
618
|
+
result.removed.push(display(entry.folder));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
manifest = { ...manifest, folders: next };
|
|
622
|
+
await writeManifest(roots.home, manifest);
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
for (const view of views) {
|
|
626
|
+
const target = view.name, like = view.like, here = view.roots, label = harnessLabel(view);
|
|
627
|
+
const fromProject = manifest.clientProjects?.[target] ?? previous.projectId, ours = fromProject === projectId;
|
|
628
|
+
const result = { written: [], unchanged: [], removed: [], kept: [], ...(Object.keys(results).length ? {} : { folders }) };
|
|
629
|
+
results[target] = result;
|
|
630
|
+
const keep = (path, reason) => result.kept.push({ path: display(path), reason });
|
|
631
|
+
const mine = visible.filter(item => writtenFor(item, like));
|
|
632
|
+
const unsupported = (kind, reason) => { if (mine.some(item => item.kind === kind))
|
|
633
|
+
(result.notSupported ??= []).push({ kind, reason }); };
|
|
634
|
+
const agents = agentTarget(here, like), rules = ruleTarget(here, like), instructions = instructionsTarget(here, like), agentFile = "reason" in agents ? null : agents;
|
|
635
|
+
if ("reason" in agents)
|
|
636
|
+
unsupported("agent", agents.reason);
|
|
637
|
+
if ("reason" in rules)
|
|
638
|
+
unsupported("rule", rules.reason);
|
|
639
|
+
if ("reason" in instructions)
|
|
640
|
+
unsupported("instructions", instructions.reason);
|
|
641
|
+
const wanted = mine.flatMap((item) => item.kind === "agent" && agentFile ? [{ summary: item, kind: "agent", path: agentFile.path(teamSkillFolder(item)) }]
|
|
642
|
+
: item.kind === "rule" && "format" in rules && rules.format === "file" ? [{ summary: item, kind: "rule", path: rules.path(teamSkillFolder(item)) }] : []);
|
|
643
|
+
const synced = manifest.files.filter(entry => entry.client === target), others = manifest.files.filter(entry => entry.client !== target), next = [];
|
|
644
|
+
const renamed = ours ? synced.filter(entry => wanted.some(item => item.kind === entry.kind && item.summary.slug === entry.slug && item.path !== entry.path)) : [];
|
|
645
|
+
for (const entry of synced) {
|
|
646
|
+
if (renamed.includes(entry))
|
|
647
|
+
continue;
|
|
648
|
+
if (ours && wanted.some(item => item.kind === entry.kind && item.summary.slug === entry.slug && item.path === entry.path))
|
|
649
|
+
continue;
|
|
650
|
+
const state = await fileState(entry.path, entry);
|
|
651
|
+
if (state === "edited") {
|
|
652
|
+
if (!ours)
|
|
653
|
+
await archive(result, entry.path, undefined, fromProject);
|
|
654
|
+
else
|
|
655
|
+
keep(entry.path, "no longer shared with you, but edited locally; delete it yourself");
|
|
656
|
+
}
|
|
657
|
+
if (state === "untouched") {
|
|
658
|
+
await unlink(entry.path);
|
|
659
|
+
result.removed.push(display(entry.path));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const pushable = target === "claude";
|
|
663
|
+
const plan = [];
|
|
664
|
+
for (const { summary, kind, path } of wanted) {
|
|
665
|
+
const entry = ours ? synced.find(item => item.kind === kind && item.slug === summary.slug && item.path === path) : undefined;
|
|
666
|
+
const previous = renamed.find(item => item.kind === kind && item.slug === summary.slug);
|
|
667
|
+
if (previous && await fileState(previous.path, previous) === "edited") {
|
|
668
|
+
keep(previous.path, "renamed in Relvo, but edited locally; move it away and sync again");
|
|
669
|
+
next.push(previous);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (!entry && await exists(path)) {
|
|
673
|
+
keep(path, "not written by relvo team-skills sync");
|
|
674
|
+
if (previous)
|
|
675
|
+
next.push(previous);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const state = entry ? await fileState(path, entry) : "absent";
|
|
679
|
+
if (entry && state === "edited") {
|
|
680
|
+
keep(path, pushable ? "edited locally; push it, or move it away and sync again" : "edited locally; move it away and sync again");
|
|
681
|
+
next.push(entry);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (entry && state === "untouched" && !entry.previous && entry.version === summary.version && entry.format === FORMAT && (!entry.executes || entry.scripts === allowScripts)) {
|
|
685
|
+
result.unchanged.push(display(path));
|
|
686
|
+
next.push(entry);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const item = await full(summary.slug), safe = safeRule(item, "imports" in rules ? rules.imports : false);
|
|
690
|
+
if (kind === "rule" && safe.escaped)
|
|
691
|
+
(result.importsEscaped ??= []).push(item.slug);
|
|
692
|
+
const text = kind === "rule" ? renderClaudeRule(safe.item, pushable) : agentFile?.format === "codex" ? renderCodexAgent(item)
|
|
693
|
+
: agentFile?.format === "markdown" ? renderMarkdownAgent(item, like, agentFile) : renderClaudeAgent(item, allowScripts);
|
|
694
|
+
const executes = kind === "agent" && agentFile?.format === "claude" && omitFrontmatterFields(item.frontmatter, AGENT_EXECUTING_FIELDS).removed.length > 0;
|
|
695
|
+
if (kind === "agent" && agentFile?.format !== "claude" && ["tools", "disallowedTools"].some(key => frontmatterValue(item.frontmatter, key) !== undefined))
|
|
696
|
+
(result.toolsNotKept ??= []).push(display(path));
|
|
697
|
+
plan.push({ entry: { client: target, kind, slug: item.slug, path, version: item.version, sha256: digest(text), previous: entry?.sha256, scripts: allowScripts, executes, format: FORMAT },
|
|
698
|
+
text, executes });
|
|
699
|
+
}
|
|
700
|
+
manifest = { ...manifest, files: [...others, ...next, ...plan.map(item => item.entry), ...renamed.filter(entry => !next.includes(entry))] };
|
|
701
|
+
if (plan.length)
|
|
702
|
+
await writeManifest(roots.home, manifest);
|
|
703
|
+
for (const item of plan) {
|
|
704
|
+
await ensureDirectory(roots.home, dirname(item.entry.path));
|
|
705
|
+
await writePrivateText(item.entry.path, item.text, false, limits.markdownBytes * 2);
|
|
706
|
+
result.written.push(display(item.entry.path));
|
|
707
|
+
if (item.executes && !allowScripts)
|
|
708
|
+
(result.settingsWithheld ??= []).push(display(item.entry.path));
|
|
709
|
+
}
|
|
710
|
+
for (const entry of renamed) {
|
|
711
|
+
const replacement = manifest.files.find(item => item.client === target && item.kind === entry.kind && item.slug === entry.slug
|
|
712
|
+
&& wanted.some(value => value.path === item.path));
|
|
713
|
+
if (!replacement || replacement.path === entry.path || await fileState(replacement.path, replacement) !== "untouched")
|
|
714
|
+
continue;
|
|
715
|
+
const state = await fileState(entry.path, entry);
|
|
716
|
+
if (state === "edited") {
|
|
717
|
+
keep(entry.path, "renamed in Relvo, but edited locally; move it away and sync again");
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (state === "untouched") {
|
|
721
|
+
await unlink(entry.path);
|
|
722
|
+
result.removed.push(display(entry.path));
|
|
723
|
+
}
|
|
724
|
+
manifest = { ...manifest, files: manifest.files.filter(item => item !== entry) };
|
|
725
|
+
}
|
|
726
|
+
if (plan.length || renamed.length) {
|
|
727
|
+
manifest = { ...manifest, files: manifest.files.map(entry => plan.some(item => item.entry === entry) ? { ...entry, previous: undefined } : entry) };
|
|
728
|
+
await writeManifest(roots.home, manifest);
|
|
729
|
+
}
|
|
730
|
+
const blocks = new Map();
|
|
731
|
+
for (const [kind, where] of [["rule", rules], ["instructions", instructions]]) {
|
|
732
|
+
if (!("format" in where) || where.format !== "block")
|
|
733
|
+
continue;
|
|
734
|
+
const group = blocks.get(where.path);
|
|
735
|
+
if (group)
|
|
736
|
+
group.kinds.push(kind);
|
|
737
|
+
else
|
|
738
|
+
blocks.set(where.path, { where, kinds: [kind] });
|
|
739
|
+
}
|
|
740
|
+
for (const { where, kinds } of blocks.values()) {
|
|
741
|
+
const ruleItems = mine.filter(item => kinds.some(kind => item.kind === kind));
|
|
742
|
+
const own = where.own && { ...where, ...where.own, before: [], after: [] };
|
|
743
|
+
if (own && view.custom)
|
|
744
|
+
await syncRuleBlock(own, ruleItems);
|
|
745
|
+
else if (own) {
|
|
746
|
+
await syncRuleBlock(where, ruleItems.filter(item => !item.harnesses?.length));
|
|
747
|
+
await syncRuleBlock(own, ruleItems.filter(item => item.harnesses?.length));
|
|
748
|
+
}
|
|
749
|
+
else
|
|
750
|
+
await syncRuleBlock(where, ruleItems);
|
|
751
|
+
}
|
|
752
|
+
await syncHooks();
|
|
753
|
+
await syncMcpServers();
|
|
754
|
+
await syncPermissions();
|
|
755
|
+
if (!ours && result.kept.length)
|
|
756
|
+
throw new Error(`Project sync is incomplete for ${label}: ${result.kept.map(item => `${item.path}: ${item.reason}`).join("; ")}. Resolve these paths before reloading the agent.`);
|
|
757
|
+
manifest = { ...manifest, clientProjects: { ...manifest.clientProjects, [target]: projectId } };
|
|
758
|
+
await writeManifest(roots.home, manifest);
|
|
759
|
+
async function syncBlock(path, marker, versions, render, fit = outside => outside) {
|
|
760
|
+
let current;
|
|
761
|
+
try {
|
|
762
|
+
current = await readOwned(path);
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
keep(path, failure(error));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const text = current?.bytes.toString("utf8") ?? "", range = findBlock(text, marker), entry = manifest.blocks.find(block => block.path === path);
|
|
769
|
+
if (deactivateOnly && (entry?.projectId ?? previous.projectId) === projectId)
|
|
770
|
+
return;
|
|
771
|
+
if (range === "ambiguous") {
|
|
772
|
+
keep(path, "its Relvo team block was changed by hand; restore or delete it, then sync again");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const block = range ? text.slice(range[0], range[1]) : null;
|
|
776
|
+
const blockOurs = (entry?.projectId ?? previous.projectId) === projectId || writtenBlocks.has(path);
|
|
777
|
+
if (block !== null && (!entry || digest(block) !== entry.sha256 && digest(block) !== entry.previous)) {
|
|
778
|
+
if (entry && !blockOurs && current)
|
|
779
|
+
await archive(result, path, current.bytes, entry.projectId ?? previous.projectId);
|
|
780
|
+
else {
|
|
781
|
+
keep(path, "its Relvo team block was edited locally; delete the block to sync again");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (block !== null && blockOurs && entry && !entry.previous && entry.format === FORMAT && canonical(entry.versions) === canonical(versions)) {
|
|
786
|
+
result.unchanged.push(display(path));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (!Object.keys(versions).length && block === null) {
|
|
790
|
+
if (entry) {
|
|
791
|
+
manifest = { ...manifest, blocks: manifest.blocks.filter(item => item !== entry) };
|
|
792
|
+
await writeManifest(roots.home, manifest);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const rendered = await render();
|
|
797
|
+
if (rendered === null && block === null)
|
|
798
|
+
return;
|
|
799
|
+
if (rendered !== null) {
|
|
800
|
+
const whole = findBlock(rendered, marker);
|
|
801
|
+
if (whole === null || whole === "ambiguous" || whole[0] !== 0 || whole[1] !== rendered.length) {
|
|
802
|
+
keep(path, "the team's items could not be written as one block");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const outside = fit(range ? replaceBlock(text, range, null) : text, rendered);
|
|
807
|
+
if (outside === null) {
|
|
808
|
+
keep(path, "it holds something sync cannot add its block to");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const nextText = rendered === null ? outside : replaceBlock(outside, null, rendered);
|
|
812
|
+
const others = manifest.blocks.filter(item => item.path !== path);
|
|
813
|
+
manifest = { ...manifest, blocks: rendered ? [...others, { path, projectId, sha256: digest(rendered), previous: block === null ? undefined : digest(block), versions, format: FORMAT }] : others };
|
|
814
|
+
await writeManifest(roots.home, manifest);
|
|
815
|
+
try {
|
|
816
|
+
if (!current)
|
|
817
|
+
await ensureDirectory(roots.home, dirname(path));
|
|
818
|
+
await writeOwned(path, nextText, current?.bytes ?? null);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
keep(path, failure(error));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
writtenBlocks.add(path);
|
|
825
|
+
(rendered ? result.written : result.removed).push(display(path));
|
|
826
|
+
manifest = { ...manifest, blocks: manifest.blocks.map(item => item.path === path ? { ...item, previous: undefined } : item) };
|
|
827
|
+
await writeManifest(roots.home, manifest);
|
|
828
|
+
}
|
|
829
|
+
async function syncRuleBlock(where, wanted) {
|
|
830
|
+
const entry = manifest.blocks.find(block => block.path === where.path);
|
|
831
|
+
if (!wanted.length && !entry)
|
|
832
|
+
return;
|
|
833
|
+
for (const path of where.before) {
|
|
834
|
+
const shadow = await readOwned(path).catch(() => null);
|
|
835
|
+
if (shadow && (where.emptyShadows || shadow.bytes.toString("utf8").trim())) {
|
|
836
|
+
if (wanted.length)
|
|
837
|
+
keep(path, `${label} reads it instead of ${basename(where.path)}, so team rules were not written`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (!entry && wanted.length && !await exists(where.path))
|
|
842
|
+
for (const path of where.after) {
|
|
843
|
+
if (await exists(path)) {
|
|
844
|
+
keep(path, `${label} would stop reading it if ${display(where.path)} existed, so team rules were not written`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
await syncBlock(where.path, RULES, Object.fromEntries(wanted.map(rule => [rule.slug, rule.version])), async () => {
|
|
849
|
+
const loaded = [];
|
|
850
|
+
for (const rule of wanted) {
|
|
851
|
+
const safe = safeRule(await full(rule.slug), where.imports);
|
|
852
|
+
if (holdsMarker(safe.item)) {
|
|
853
|
+
keep(where.path, `the rule ${rule.slug} holds the text ${RULES.tag}, so it was left out`);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (safe.escaped && !result.importsEscaped?.includes(rule.slug))
|
|
857
|
+
(result.importsEscaped ??= []).push(rule.slug);
|
|
858
|
+
loaded.push(safe.item);
|
|
859
|
+
}
|
|
860
|
+
return loaded.length ? renderRulesBlock(loaded) : null;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
async function syncMcpServers() {
|
|
864
|
+
const servers = mine.filter(item => item.kind === "mcp_server"), registered = manifest.mcpServers.filter(entry => entry.client === target);
|
|
865
|
+
const where = view.custom && like === "claude" ? { reason: `${label} reads MCP servers from no file of its own folder; add them yourself` } : mcpTarget(here, like);
|
|
866
|
+
if ("reason" in where) {
|
|
867
|
+
unsupported("mcp_server", where.reason);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (servers.length && !allowScripts)
|
|
871
|
+
result.notInstalled = [...result.notInstalled ?? [], ...servers.map(item => teamSkillFolder(item))];
|
|
872
|
+
const desired = allowScripts ? servers : [];
|
|
873
|
+
if (where.format === "dsh") {
|
|
874
|
+
await syncDshServers(where.path, desired);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (!desired.length && !registered.length)
|
|
878
|
+
return;
|
|
879
|
+
if (where.format === "json") {
|
|
880
|
+
await syncJsonServers(where, desired, registered);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const harness = like === "claude" ? "claude" : "codex";
|
|
884
|
+
const agentEnv = view.custom ? { ...env, CODEX_HOME: here.dirs.codex } : env;
|
|
885
|
+
const config = harness === "claude" ? join(roots.home, ".claude.json") : join(here.dirs.codex, "config.toml"), at = (name) => `${display(config)} (${name})`;
|
|
886
|
+
if (harness === "claude" && env.CLAUDE_CONFIG_DIR !== undefined) {
|
|
887
|
+
keep(config, "Claude Code uses a custom configuration directory, so team MCP servers were not changed");
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
let agent;
|
|
891
|
+
try {
|
|
892
|
+
agent = await executable(harness, agentEnv);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
keep(config, `${harnessNames[harness]} was not found on PATH, so team MCP servers were not changed`);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const run = (args) => runAgent(agent, args, { cwd: roots.home, env: cleanAgentEnvironment(agentEnv, roots.home), timeout: 20000, maxBuffer: 1048576 });
|
|
899
|
+
const read = (name) => agentRegistration(harness, agent, agentEnv, roots.home, name);
|
|
900
|
+
const next = [];
|
|
901
|
+
try {
|
|
902
|
+
for (const entry of registered) {
|
|
903
|
+
if (ours && desired.some(item => item.slug === entry.slug))
|
|
904
|
+
continue;
|
|
905
|
+
const current = await read(entry.name);
|
|
906
|
+
if (current === null)
|
|
907
|
+
continue;
|
|
908
|
+
if (groupHash(current) !== entry.sha256) {
|
|
909
|
+
if (ours) {
|
|
910
|
+
keep(config, `${entry.name} was changed locally, so it was left in place; remove it yourself`);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const file = await readOwned(config);
|
|
914
|
+
await archive(result, config, file?.bytes ?? Buffer.from(JSON.stringify({ [entry.name]: current }, null, 2)), fromProject);
|
|
915
|
+
}
|
|
916
|
+
await run(harness === "claude" ? ["mcp", "remove", "--scope", "user", entry.name] : ["mcp", "remove", entry.name]);
|
|
917
|
+
result.removed.push(at(entry.name));
|
|
918
|
+
}
|
|
919
|
+
for (const summary of desired) {
|
|
920
|
+
const name = teamSkillFolder(summary), entry = ours ? registered.find(item => item.slug === summary.slug && item.name === name) : undefined, current = await read(name);
|
|
921
|
+
const previous = ours ? registered.find(item => item.slug === summary.slug && item.name !== name) : undefined;
|
|
922
|
+
if (previous) {
|
|
923
|
+
const value = await read(previous.name);
|
|
924
|
+
if (value !== null && groupHash(value) !== previous.sha256) {
|
|
925
|
+
keep(config, `${previous.name} was renamed in Relvo but changed locally; remove it to sync again`);
|
|
926
|
+
next.push(previous);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (current !== null && !entry) {
|
|
931
|
+
keep(config, `${name} was not added by relvo team-skills sync`);
|
|
932
|
+
if (previous)
|
|
933
|
+
next.push(previous);
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (current !== null && entry && groupHash(current) !== entry.sha256) {
|
|
937
|
+
keep(config, `${name} was changed locally; remove it to sync again`);
|
|
938
|
+
next.push(entry);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (current !== null && entry && entry.version === summary.version) {
|
|
942
|
+
result.unchanged.push(at(name));
|
|
943
|
+
next.push(entry);
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
let server;
|
|
947
|
+
try {
|
|
948
|
+
server = readTeamMcpServer((await full(summary.slug)).content);
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
keep(config, `${name}: ${failure(error)}`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const commands = mcpCommands(harness, name, server);
|
|
955
|
+
if (typeof commands === "string") {
|
|
956
|
+
keep(config, `${name}: ${commands}`);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (current !== null)
|
|
960
|
+
await run(commands.remove);
|
|
961
|
+
await run(commands.add);
|
|
962
|
+
const saved = await read(name);
|
|
963
|
+
if (saved === null) {
|
|
964
|
+
keep(config, `${name} could not be verified after it was added`);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
next.push({ client: target, slug: summary.slug, name, version: summary.version, sha256: groupHash(saved) });
|
|
968
|
+
result.written.push(at(name));
|
|
969
|
+
}
|
|
970
|
+
for (const entry of registered) {
|
|
971
|
+
if (!next.some(item => item.slug === entry.slug && item.name !== entry.name))
|
|
972
|
+
continue;
|
|
973
|
+
const current = await read(entry.name);
|
|
974
|
+
if (current === null)
|
|
975
|
+
continue;
|
|
976
|
+
if (groupHash(current) !== entry.sha256) {
|
|
977
|
+
keep(config, `${entry.name} was changed locally, so it was left in place; remove it yourself`);
|
|
978
|
+
next.push(entry);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
await run(harness === "claude" ? ["mcp", "remove", "--scope", "user", entry.name] : ["mcp", "remove", entry.name]);
|
|
982
|
+
result.removed.push(at(entry.name));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
keep(config, `team MCP servers could not be changed: ${failure(error)}`);
|
|
987
|
+
next.push(...registered.filter(entry => !next.some(kept => kept.name === entry.name)));
|
|
988
|
+
}
|
|
989
|
+
manifest = { ...manifest, mcpServers: [...manifest.mcpServers.filter(entry => entry.client !== target), ...next] };
|
|
990
|
+
}
|
|
991
|
+
async function syncJsonServers(where, desired, registered) {
|
|
992
|
+
let path = where.paths.at(-1);
|
|
993
|
+
for (const candidate of where.paths)
|
|
994
|
+
if (await exists(candidate)) {
|
|
995
|
+
path = candidate;
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
const label = (name) => `${display(path)} (${name})`, next = [];
|
|
999
|
+
let current, settings;
|
|
1000
|
+
try {
|
|
1001
|
+
current = await readOwned(path);
|
|
1002
|
+
settings = jsonObject(path, current, "team MCP servers");
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
keep(path, failure(error));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const found = settings[where.at] === undefined ? { success: true, data: {} } : jsonMap.safeParse(settings[where.at]);
|
|
1009
|
+
if (!found.success) {
|
|
1010
|
+
keep(path, `its ${where.at} is not a map of servers, so team MCP servers were not changed`);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const servers = { ...found.data }, written = [], removed = [];
|
|
1014
|
+
for (const entry of registered) {
|
|
1015
|
+
if (ours && desired.some(item => item.slug === entry.slug))
|
|
1016
|
+
continue;
|
|
1017
|
+
if (!Object.hasOwn(servers, entry.name))
|
|
1018
|
+
continue;
|
|
1019
|
+
if (groupHash(servers[entry.name]) !== entry.sha256) {
|
|
1020
|
+
if (ours) {
|
|
1021
|
+
keep(path, `${entry.name} was changed locally, so it was left in place; remove it yourself`);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const file = await readOwned(path);
|
|
1025
|
+
await archive(result, path, file?.bytes ?? Buffer.from(JSON.stringify({ [entry.name]: servers[entry.name] }, null, 2)), fromProject);
|
|
1026
|
+
}
|
|
1027
|
+
delete servers[entry.name];
|
|
1028
|
+
removed.push(label(entry.name));
|
|
1029
|
+
}
|
|
1030
|
+
for (const summary of desired) {
|
|
1031
|
+
const name = teamSkillFolder(summary), entry = ours ? registered.find(item => item.slug === summary.slug && item.name === name) : undefined, existing = Object.hasOwn(servers, name);
|
|
1032
|
+
const previous = ours ? registered.find(item => item.slug === summary.slug && item.name !== name) : undefined;
|
|
1033
|
+
if (previous && Object.hasOwn(servers, previous.name) && groupHash(servers[previous.name]) !== previous.sha256) {
|
|
1034
|
+
keep(path, `${previous.name} was renamed in Relvo but changed locally; remove it to sync again`);
|
|
1035
|
+
next.push(previous);
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
if (existing && !entry) {
|
|
1039
|
+
keep(path, `${name} was not added by relvo team-skills sync`);
|
|
1040
|
+
if (previous)
|
|
1041
|
+
next.push(previous);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (existing && entry && groupHash(servers[name]) !== entry.sha256) {
|
|
1045
|
+
keep(path, `${name} was changed locally; remove it to sync again`);
|
|
1046
|
+
next.push(entry);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (existing && entry && entry.version === summary.version) {
|
|
1050
|
+
result.unchanged.push(label(name));
|
|
1051
|
+
next.push(entry);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
let value;
|
|
1055
|
+
try {
|
|
1056
|
+
value = where.entry(name, readTeamMcpServer((await full(summary.slug)).content));
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
keep(path, `${name}: ${failure(error)}`);
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (typeof value === "string") {
|
|
1063
|
+
keep(path, `${name}: ${value}`);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
servers[name] = value;
|
|
1067
|
+
next.push({ client: target, slug: summary.slug, name, version: summary.version, sha256: groupHash(value) });
|
|
1068
|
+
written.push(label(name));
|
|
1069
|
+
}
|
|
1070
|
+
for (const entry of registered) {
|
|
1071
|
+
if (!next.some(item => item.slug === entry.slug && item.name !== entry.name) || !Object.hasOwn(servers, entry.name))
|
|
1072
|
+
continue;
|
|
1073
|
+
if (groupHash(servers[entry.name]) !== entry.sha256) {
|
|
1074
|
+
keep(path, `${entry.name} was changed locally, so it was left in place; remove it yourself`);
|
|
1075
|
+
next.push(entry);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
delete servers[entry.name];
|
|
1079
|
+
removed.push(label(entry.name));
|
|
1080
|
+
}
|
|
1081
|
+
if (canonical(servers) !== canonical(found.data)) {
|
|
1082
|
+
try {
|
|
1083
|
+
if (!current)
|
|
1084
|
+
await ensureDirectory(roots.home, dirname(path));
|
|
1085
|
+
await writeOwned(path, `${JSON.stringify({ ...settings, [where.at]: servers }, null, 2)}\n`, current?.bytes ?? null);
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
keep(path, `team MCP servers could not be changed: ${failure(error)}`);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
result.written.push(...written);
|
|
1093
|
+
result.removed.push(...removed);
|
|
1094
|
+
manifest = { ...manifest, mcpServers: [...manifest.mcpServers.filter(entry => entry.client !== target), ...next] };
|
|
1095
|
+
}
|
|
1096
|
+
async function syncDshServers(path, desired) {
|
|
1097
|
+
if (!desired.length && !manifest.blocks.some(block => block.path === path))
|
|
1098
|
+
return;
|
|
1099
|
+
await syncBlock(path, DSH_MCP, Object.fromEntries(desired.map(item => [item.slug, item.version])), async () => {
|
|
1100
|
+
const rows = [];
|
|
1101
|
+
for (const summary of desired) {
|
|
1102
|
+
const name = teamSkillFolder(summary);
|
|
1103
|
+
let row;
|
|
1104
|
+
try {
|
|
1105
|
+
row = dshMcpRow(name, readTeamMcpServer((await full(summary.slug)).content));
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
keep(path, `${name}: ${failure(error)}`);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (typeof row === "string")
|
|
1112
|
+
keep(path, `${name}: ${row}`);
|
|
1113
|
+
else if (JSON.stringify(row).includes(DSH_MCP.tag))
|
|
1114
|
+
keep(path, `${name} holds the text ${DSH_MCP.tag}, so it was left out`);
|
|
1115
|
+
else
|
|
1116
|
+
rows.push(row);
|
|
1117
|
+
}
|
|
1118
|
+
return rows.length ? renderDshMcpBlock(rows) : null;
|
|
1119
|
+
}, (outside, block) => {
|
|
1120
|
+
const shape = patchShape(outside);
|
|
1121
|
+
if (shape === "other")
|
|
1122
|
+
return null;
|
|
1123
|
+
if (shape === "list")
|
|
1124
|
+
return outside;
|
|
1125
|
+
const comments = outside.split("\n").filter(line => line.trim() !== "[]").join("\n");
|
|
1126
|
+
return block !== null ? comments : replaceBlock(comments, null, "[]");
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
async function syncPermissions() {
|
|
1130
|
+
const sets = mine.filter(item => item.kind === "permission"), where = permissionTarget(here, like);
|
|
1131
|
+
if ("reason" in where) {
|
|
1132
|
+
unsupported("permission", where.reason);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const merges = where.format === "settings" || where.format === "opencode" || where.format === "droid";
|
|
1136
|
+
const settled = merges ? manifest.permissionRules[target] : manifest.permissionFiles.find(entry => entry.client === target);
|
|
1137
|
+
if (!sets.length && !settled)
|
|
1138
|
+
return;
|
|
1139
|
+
let path = where.format === "opencode" ? where.paths.at(-1) : where.path;
|
|
1140
|
+
if (where.format === "opencode")
|
|
1141
|
+
for (const candidate of where.paths)
|
|
1142
|
+
if (await exists(candidate)) {
|
|
1143
|
+
path = candidate;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
const wanted = { allow: [], ask: [], deny: [] };
|
|
1147
|
+
for (const summary of sets) {
|
|
1148
|
+
let rules;
|
|
1149
|
+
try {
|
|
1150
|
+
rules = readTeamPermissions((await full(summary.slug)).content);
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
keep(path, `${summary.slug}: ${failure(error)}`);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
for (const list of RULE_LISTS)
|
|
1157
|
+
for (const rule of rules[list] ?? [])
|
|
1158
|
+
wanted[list].push({ rule, slug: summary.slug, ...(summary.name ? { name: summary.name } : {}) });
|
|
1159
|
+
}
|
|
1160
|
+
if (wanted.allow.length && !allowScripts)
|
|
1161
|
+
result.notInstalled = [...new Set([...result.notInstalled ?? [], ...wanted.allow.map(item => teamSkillFolder(item))])];
|
|
1162
|
+
if (!allowScripts)
|
|
1163
|
+
wanted.allow = [];
|
|
1164
|
+
const skipped = [];
|
|
1165
|
+
if (merges) {
|
|
1166
|
+
const rules = { allow: [], ask: [], deny: [] }, droidRules = [];
|
|
1167
|
+
const translate = where.format === "settings" || where.format === "opencode" ? where.translate : null, origins = new Map();
|
|
1168
|
+
for (const list of RULE_LISTS)
|
|
1169
|
+
for (const { rule, slug } of wanted[list]) {
|
|
1170
|
+
if (!translate) {
|
|
1171
|
+
const made = droidRule(rule, list, slug);
|
|
1172
|
+
if (!made)
|
|
1173
|
+
skipped.push(rule);
|
|
1174
|
+
else if (!droidRules.some(existing => existing.id === made.id)) {
|
|
1175
|
+
droidRules.push(made);
|
|
1176
|
+
rules[list].push(made.id);
|
|
1177
|
+
}
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
const translated = translate(rule, list);
|
|
1181
|
+
if (translated === null) {
|
|
1182
|
+
skipped.push(rule);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (!rules[list].includes(translated))
|
|
1186
|
+
rules[list].push(translated);
|
|
1187
|
+
origins.set(`${list}:${translated}`, [...origins.get(`${list}:${translated}`) ?? [], rule]);
|
|
1188
|
+
}
|
|
1189
|
+
const any = RULE_LISTS.some(list => rules[list].length), recorded = manifest.permissionRules[target] ?? { allow: [], ask: [], deny: [] };
|
|
1190
|
+
if (where.format === "settings" && where.existing && !await exists(path)) {
|
|
1191
|
+
if (any)
|
|
1192
|
+
keep(path, `${label} writes this file on its first run; run it once, then sync again`);
|
|
1193
|
+
if (skipped.length)
|
|
1194
|
+
result.permissionsSkipped = skipped;
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
if (any)
|
|
1199
|
+
await ensureDirectory(roots.home, dirname(path));
|
|
1200
|
+
if (!ours && RULE_LISTS.some(list => recorded[list].length)) {
|
|
1201
|
+
const current = await readOwned(path);
|
|
1202
|
+
if (current)
|
|
1203
|
+
await archive(result, path, current.bytes, fromProject);
|
|
1204
|
+
}
|
|
1205
|
+
const merged = where.format === "droid"
|
|
1206
|
+
? await mergeDroidPermissions(path, droidRules, rules, recorded)
|
|
1207
|
+
: where.format === "opencode" ? await mergeOpencodePermissions(path, rules, recorded) : await mergePermissionSettings(path, rules, recorded);
|
|
1208
|
+
for (const list of RULE_LISTS)
|
|
1209
|
+
for (const rule of merged.withheld?.[list] ?? [])
|
|
1210
|
+
skipped.push(...origins.get(`${list}:${rule}`) ?? []);
|
|
1211
|
+
const { [target]: _previous, ...others } = manifest.permissionRules;
|
|
1212
|
+
manifest = { ...manifest, permissionRules: RULE_LISTS.some(list => merged.added[list].length) ? { ...others, [target]: merged.added } : others };
|
|
1213
|
+
if (merged.changed)
|
|
1214
|
+
(any ? result.written : result.removed).push(`${display(path)} (permissions)`);
|
|
1215
|
+
else if (any)
|
|
1216
|
+
result.unchanged.push(`${display(path)} (permissions)`);
|
|
1217
|
+
}
|
|
1218
|
+
catch (error) {
|
|
1219
|
+
keep(path, failure(error));
|
|
1220
|
+
}
|
|
1221
|
+
if (skipped.length)
|
|
1222
|
+
result.permissionsSkipped = skipped;
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const lines = [];
|
|
1226
|
+
for (const list of ["deny", "ask", "allow"])
|
|
1227
|
+
for (const { rule, slug } of wanted[list]) {
|
|
1228
|
+
const pattern = codexCommandPrefix(rule, list);
|
|
1229
|
+
if (!pattern) {
|
|
1230
|
+
skipped.push(rule);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const why = `Relvo team permission ${slug}`;
|
|
1234
|
+
lines.push(where.format === "codex-rules"
|
|
1235
|
+
? `prefix_rule(pattern = ${JSON.stringify(pattern)}, decision = "${CODEX_DECISION[list]}", justification = ${JSON.stringify(why)})`
|
|
1236
|
+
: `[[rule]]\ntoolName = "run_shell_command"\ncommandPrefix = ${tomlString(pattern.join(" "))}\ndecision = "${GEMINI_DECISION[list][0]}"\npriority = ${GEMINI_DECISION[list][1]}` +
|
|
1237
|
+
(list === "deny" ? `\ndenyMessage = ${tomlString(why)}` : ""));
|
|
1238
|
+
}
|
|
1239
|
+
if (skipped.length)
|
|
1240
|
+
result.permissionsSkipped = skipped;
|
|
1241
|
+
const text = lines.length ? `# Relvo team permission rules, written by relvo team-skills sync. Change them in the Relvo console.\n${[...new Set(lines)].join(where.format === "codex-rules" ? "\n" : "\n\n")}\n` : null;
|
|
1242
|
+
const entry = manifest.permissionFiles.find(file => file.client === target && file.path === where.path);
|
|
1243
|
+
let state = entry ? await fileState(where.path, entry) : await exists(where.path) ? "foreign" : "absent";
|
|
1244
|
+
const others = manifest.permissionFiles.filter(file => file.client !== target);
|
|
1245
|
+
if (state === "foreign") {
|
|
1246
|
+
keep(where.path, "not written by relvo team-skills sync");
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (state === "edited") {
|
|
1250
|
+
if (ours) {
|
|
1251
|
+
keep(where.path, "edited locally; delete it to sync again");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
await archive(result, where.path, undefined, fromProject);
|
|
1255
|
+
state = "absent";
|
|
1256
|
+
}
|
|
1257
|
+
if (text === null) {
|
|
1258
|
+
if (state === "untouched") {
|
|
1259
|
+
await unlink(where.path);
|
|
1260
|
+
result.removed.push(display(where.path));
|
|
1261
|
+
}
|
|
1262
|
+
manifest = { ...manifest, permissionFiles: others };
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (state === "untouched" && entry?.sha256 === digest(text)) {
|
|
1266
|
+
result.unchanged.push(display(where.path));
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
await ensureDirectory(roots.home, dirname(where.path));
|
|
1270
|
+
await writePrivateText(where.path, text, false, CONFIG_BYTES);
|
|
1271
|
+
manifest = { ...manifest, permissionFiles: [...others, { client: target, path: where.path, sha256: digest(text) }] };
|
|
1272
|
+
result.written.push(display(where.path));
|
|
1273
|
+
}
|
|
1274
|
+
async function placeFolder(item, folder, files, installed, parent) {
|
|
1275
|
+
const entry = ours ? installed.find(hook => hook.slug === item.slug && hook.folder === folder) : undefined;
|
|
1276
|
+
const previous = ours ? installed.find(hook => hook.slug === item.slug && hook.folder !== folder) : undefined;
|
|
1277
|
+
if (previous && await treeState(previous) === "edited") {
|
|
1278
|
+
keep(previous.folder, "renamed in Relvo, but edited locally; move it away and sync again");
|
|
1279
|
+
return previous;
|
|
1280
|
+
}
|
|
1281
|
+
const state = entry ? await treeState(entry) : await exists(folder) ? "foreign" : "absent";
|
|
1282
|
+
if (state === "foreign") {
|
|
1283
|
+
keep(folder, "not written by relvo team-skills sync");
|
|
1284
|
+
return previous ?? null;
|
|
1285
|
+
}
|
|
1286
|
+
if (state === "edited" && entry) {
|
|
1287
|
+
keep(folder, "edited locally; move it away and sync again");
|
|
1288
|
+
return entry;
|
|
1289
|
+
}
|
|
1290
|
+
const hashes = Object.fromEntries(files.map(file => [file.path, fileHash(Buffer.from(file.data, "base64"), file.executable === true)]));
|
|
1291
|
+
if (entry && entry.version === item.version && !entry.pending && canonical(entry.files) === canonical(hashes) && !(state === "absent" && Object.keys(entry.files).length > 0)) {
|
|
1292
|
+
if (files.length)
|
|
1293
|
+
result.unchanged.push(display(folder));
|
|
1294
|
+
return entry;
|
|
1295
|
+
}
|
|
1296
|
+
const pending = { client: target, slug: item.slug, folder, version: item.version, files: hashes, previousFiles: entry?.files, pending: true };
|
|
1297
|
+
manifest = { ...manifest, hooks: [...manifest.hooks.filter(hook => hook !== entry), pending] };
|
|
1298
|
+
await writeManifest(roots.home, manifest);
|
|
1299
|
+
const obsolete = Object.keys(entry?.files ?? {}).filter(path => !Object.hasOwn(hashes, path));
|
|
1300
|
+
if (entry)
|
|
1301
|
+
await removeTree({ ...entry, files: Object.fromEntries(obsolete.map(path => [path, entry.files[path]])), previousFiles: undefined });
|
|
1302
|
+
if (files.length) {
|
|
1303
|
+
await parent();
|
|
1304
|
+
await writeTree(folder, files, true);
|
|
1305
|
+
}
|
|
1306
|
+
const settled = { client: target, slug: item.slug, folder, version: item.version, files: hashes };
|
|
1307
|
+
manifest = { ...manifest, hooks: manifest.hooks.map(hook => hook === pending ? settled : hook) };
|
|
1308
|
+
if (files.length)
|
|
1309
|
+
result.written.push(display(folder));
|
|
1310
|
+
return settled;
|
|
1311
|
+
}
|
|
1312
|
+
async function settleFolders(installed, nextHooks) {
|
|
1313
|
+
for (const entry of installed.filter(hook => !nextHooks.some(kept => kept.slug === hook.slug && kept.folder === hook.folder))) {
|
|
1314
|
+
const state = await treeState(entry);
|
|
1315
|
+
if (state === "edited") {
|
|
1316
|
+
if (!ours)
|
|
1317
|
+
await archive(result, entry.folder, undefined, fromProject);
|
|
1318
|
+
else
|
|
1319
|
+
keep(entry.folder, "no longer installed, but edited locally; delete it yourself");
|
|
1320
|
+
}
|
|
1321
|
+
if (state === "untouched") {
|
|
1322
|
+
await removeTree(entry);
|
|
1323
|
+
result.removed.push(display(entry.folder));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
manifest = { ...manifest, hooks: [...manifest.hooks.filter(hook => hook.client !== target), ...nextHooks] };
|
|
1327
|
+
await writeManifest(roots.home, manifest);
|
|
1328
|
+
}
|
|
1329
|
+
async function syncHooks() {
|
|
1330
|
+
const hooks = mine.filter(item => item.kind === "hook"), installed = manifest.hooks.filter(entry => entry.client === target), where = hookTarget(here, like);
|
|
1331
|
+
if ("reason" in where) {
|
|
1332
|
+
unsupported("hook", where.reason);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (!ours)
|
|
1336
|
+
for (const entry of installed) {
|
|
1337
|
+
const state = await treeState(entry);
|
|
1338
|
+
if (state === "edited")
|
|
1339
|
+
await archive(result, entry.folder, undefined, fromProject);
|
|
1340
|
+
if (state === "untouched") {
|
|
1341
|
+
await removeTree(entry);
|
|
1342
|
+
result.removed.push(display(entry.folder));
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (hooks.length && !allowScripts)
|
|
1346
|
+
result.notInstalled = [...result.notInstalled ?? [], ...hooks.map(item => teamSkillFolder(item))];
|
|
1347
|
+
const desired = allowScripts ? hooks : [];
|
|
1348
|
+
if (where.format === "plugin") {
|
|
1349
|
+
await syncPlugins(where, desired, installed);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const ownGroups = manifest.hookGroups[target] ?? [], ownFile = manifest.hookFiles?.find(file => file.client === target);
|
|
1353
|
+
if (!desired.length && !installed.length && !ownGroups.length && !ownFile)
|
|
1354
|
+
return;
|
|
1355
|
+
const groups = {}, nextHooks = [];
|
|
1356
|
+
for (const summary of desired) {
|
|
1357
|
+
const item = await full(summary.slug), folder = join(where.scripts, teamSkillFolder(item));
|
|
1358
|
+
let config;
|
|
1359
|
+
try {
|
|
1360
|
+
config = readTeamHookConfig(item.content);
|
|
1361
|
+
}
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
keep(folder, failure(error));
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
const files = platformSkillFilesSchema.parse(item.files ?? []).filter(file => file.path.split("/")[0] !== TEAM_HOOK_NATIVE_FOLDER);
|
|
1367
|
+
const entry = await placeFolder(item, folder, files, installed, async () => { await ensureDirectory(roots.home, here.dirs[like]); await safeDirectory(where.scripts, true, true); });
|
|
1368
|
+
if (!entry)
|
|
1369
|
+
continue;
|
|
1370
|
+
nextHooks.push(entry);
|
|
1371
|
+
const { events, skipped } = hookSettingsFor(target, where, config, entry.folder);
|
|
1372
|
+
for (const [event, list] of Object.entries(events))
|
|
1373
|
+
groups[event] = [...(groups[event] ?? []), ...list];
|
|
1374
|
+
if (skipped.length)
|
|
1375
|
+
result.hookEventsSkipped = [...new Set([...(result.hookEventsSkipped ?? []), ...skipped])];
|
|
1376
|
+
}
|
|
1377
|
+
try {
|
|
1378
|
+
if (where.format === "file")
|
|
1379
|
+
await writeHookFile(where.path, groups, ownFile);
|
|
1380
|
+
else {
|
|
1381
|
+
const path = where.topLevel && await exists(where.topLevel) ? where.topLevel : where.path, before = manifest.hookPaths?.[target] ?? where.path;
|
|
1382
|
+
let ownHere = ownGroups;
|
|
1383
|
+
if (before !== path && ownGroups.length) {
|
|
1384
|
+
const moved = await mergeHookSettings(before, {}, ownGroups, before === where.topLevel);
|
|
1385
|
+
if (moved.changed)
|
|
1386
|
+
result.removed.push(display(before));
|
|
1387
|
+
ownHere = [];
|
|
1388
|
+
}
|
|
1389
|
+
if (Object.keys(groups).length)
|
|
1390
|
+
await ensureDirectory(roots.home, dirname(path));
|
|
1391
|
+
const merged = await mergeHookSettings(path, groups, ownHere, path === where.topLevel);
|
|
1392
|
+
const { [target]: _path, ...otherPaths } = manifest.hookPaths ?? {};
|
|
1393
|
+
manifest = { ...manifest, hookGroups: { ...manifest.hookGroups, [target]: merged.hashes }, hookPaths: path === where.path ? otherPaths : { ...otherPaths, [target]: path } };
|
|
1394
|
+
if (merged.changed)
|
|
1395
|
+
(Object.keys(groups).length ? result.written : result.removed).push(display(path));
|
|
1396
|
+
else if (Object.keys(groups).length)
|
|
1397
|
+
result.unchanged.push(display(path));
|
|
1398
|
+
if (where.trust && merged.changed && Object.keys(groups).length)
|
|
1399
|
+
result.hooksToTrust = desired.map(item => teamSkillFolder(item));
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
catch (error) {
|
|
1403
|
+
keep(where.path, failure(error));
|
|
1404
|
+
manifest = { ...manifest, hooks: [...manifest.hooks.filter(hook => hook.client !== target || !nextHooks.includes(hook)), ...nextHooks] };
|
|
1405
|
+
await writeManifest(roots.home, manifest);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
await settleFolders(installed, nextHooks);
|
|
1409
|
+
}
|
|
1410
|
+
async function writeHookFile(path, groups, entry) {
|
|
1411
|
+
const hooks = Object.fromEntries(Object.entries(groups).map(([event, list]) => [event, list.flatMap(group => z.array(jsonMap).parse(group.hooks).map(handler => ({ ...handler, ...(typeof group.matcher === "string" ? { matcher: group.matcher } : {}) })))]));
|
|
1412
|
+
const text = Object.keys(hooks).length ? `${JSON.stringify({ version: 1, hooks }, null, 2)}\n` : null;
|
|
1413
|
+
let state = entry ? await fileState(path, entry) : await exists(path) ? "foreign" : "absent";
|
|
1414
|
+
const others = (manifest.hookFiles ?? []).filter(file => file.client !== target);
|
|
1415
|
+
if (state === "foreign") {
|
|
1416
|
+
keep(path, "not written by relvo team-skills sync");
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (state === "edited") {
|
|
1420
|
+
if (ours) {
|
|
1421
|
+
keep(path, "edited locally; delete it to sync again");
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
await archive(result, path, undefined, fromProject);
|
|
1425
|
+
state = "absent";
|
|
1426
|
+
}
|
|
1427
|
+
if (text === null) {
|
|
1428
|
+
if (state === "untouched") {
|
|
1429
|
+
await unlink(path);
|
|
1430
|
+
result.removed.push(display(path));
|
|
1431
|
+
}
|
|
1432
|
+
manifest = { ...manifest, hookFiles: others };
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (state === "untouched" && entry?.sha256 === digest(text)) {
|
|
1436
|
+
result.unchanged.push(display(path));
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
await ensureDirectory(roots.home, dirname(path));
|
|
1440
|
+
await writePrivateText(path, text, false, CONFIG_BYTES);
|
|
1441
|
+
manifest = { ...manifest, hookFiles: [...others, { client: target, path, sha256: digest(text) }] };
|
|
1442
|
+
result.written.push(display(path));
|
|
1443
|
+
}
|
|
1444
|
+
async function syncPlugins(where, desired, installed) {
|
|
1445
|
+
if (!desired.length && !installed.length)
|
|
1446
|
+
return;
|
|
1447
|
+
for (const shadow of where.shadows) {
|
|
1448
|
+
const file = await readOwned(shadow.path).catch(() => ({ bytes: Buffer.from("null") }));
|
|
1449
|
+
if (!file)
|
|
1450
|
+
continue;
|
|
1451
|
+
let hides = true;
|
|
1452
|
+
if (shadow.when) {
|
|
1453
|
+
try {
|
|
1454
|
+
hides = shadow.when(JSON.parse(file.bytes.toString("utf8")));
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
hides = true;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (hides) {
|
|
1461
|
+
if (desired.length)
|
|
1462
|
+
keep(shadow.path, `${label} loads only this file from ${display(where.folder)}, so team hooks were not installed there`);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const prefix = `${TEAM_HOOK_NATIVE_FOLDER}/${like}/`, nextHooks = [];
|
|
1467
|
+
for (const summary of desired) {
|
|
1468
|
+
const item = await full(summary.slug), folder = join(where.folder, teamSkillFolder(item));
|
|
1469
|
+
const files = platformSkillFilesSchema.parse(item.files ?? []).filter(file => file.path.startsWith(prefix)).map(file => ({ ...file, path: file.path.slice(prefix.length) }));
|
|
1470
|
+
if (!files.length) {
|
|
1471
|
+
(result.hooksWithoutPlugin ??= []).push(teamSkillFolder(item));
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (!files.some(file => where.entries.includes(file.path))) {
|
|
1475
|
+
keep(folder, `${label} starts a plugin folder from ${where.entries.join(", ")}, and ${prefix} of ${item.slug} has none`);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
const entry = await placeFolder(item, folder, files, installed, () => ensureDirectory(roots.home, where.folder));
|
|
1479
|
+
if (entry)
|
|
1480
|
+
nextHooks.push(entry);
|
|
1481
|
+
}
|
|
1482
|
+
await settleFolders(installed, nextHooks);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return results;
|
|
1486
|
+
}
|
|
1487
|
+
export function harnessKindOf(path) {
|
|
1488
|
+
if (!path.endsWith(".md") || basename(path) === "SKILL.md")
|
|
1489
|
+
return null;
|
|
1490
|
+
const parent = basename(dirname(resolve(path)));
|
|
1491
|
+
return parent === "agents" ? "agent" : parent === "rules" ? "rule"
|
|
1492
|
+
: ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "QWEN.md"].includes(basename(path)) ? "instructions" : null;
|
|
1493
|
+
}
|
|
1494
|
+
export function parseHarnessFile(text) {
|
|
1495
|
+
const parsed = readSkillMarkdown(text), lines = parsed.content.split("\n");
|
|
1496
|
+
if (lines[0]?.startsWith(`${NOTICE} agent \``) || lines[0]?.startsWith(`${NOTICE} rule \``))
|
|
1497
|
+
lines.shift();
|
|
1498
|
+
return { ...parsed, content: lines.join("\n").trim() };
|
|
1499
|
+
}
|
|
1500
|
+
export async function pushTeamHarnessFile(roots, projectId, client, path, kind, slugOption) {
|
|
1501
|
+
const file = resolve(path);
|
|
1502
|
+
if (file.endsWith(".toml"))
|
|
1503
|
+
throw new Error("Push the Claude Code copy of an agent (a Markdown file); sync writes the Codex agent from it.");
|
|
1504
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode((await readRegularFile(file, limits.markdownBytes)).bytes), parsed = parseHarnessFile(text);
|
|
1505
|
+
if (kind === "instructions" && text.includes(RULES.tag))
|
|
1506
|
+
throw new Error("This file contains synced team instructions. Edit the original item in the Relvo console instead of sharing the combined file.");
|
|
1507
|
+
const manifest = await readManifest(roots.home), synced = manifest?.files.find(entry => entry.path === file);
|
|
1508
|
+
if (synced && synced.client !== "claude")
|
|
1509
|
+
throw new Error(`This is the ${agentName(synced.client)} copy of the team ${synced.kind} ${synced.slug}. Edit the Claude Code copy and push that, or change it in the Relvo console.`);
|
|
1510
|
+
if (synced && manifest?.projectId !== projectId)
|
|
1511
|
+
throw new Error("This copy was synced from another Relvo project. Push it with that project's login.");
|
|
1512
|
+
if (synced && synced.kind !== kind)
|
|
1513
|
+
throw new Error(`This copy is the team ${synced.kind} ${synced.slug}.`);
|
|
1514
|
+
if (synced && digest(text) === synced.sha256)
|
|
1515
|
+
return { slug: synced.slug, kind, version: synced.version, created: false, changed: false };
|
|
1516
|
+
const fromName = parsed.name?.startsWith(TEAM_SKILL_FOLDER_PREFIX) ? parsed.name.slice(TEAM_SKILL_FOLDER_PREFIX.length) : parsed.name ?? basename(file, ".md");
|
|
1517
|
+
const fromFile = basename(file, ".md").startsWith(TEAM_SKILL_FOLDER_PREFIX) ? basename(file, ".md").slice(TEAM_SKILL_FOLDER_PREFIX.length) : basename(file, ".md");
|
|
1518
|
+
const slug = slugOption ?? synced?.slug ?? (kind === "agent" ? fromName : fromFile).toLowerCase().replaceAll("-", "_");
|
|
1519
|
+
if (synced && slugOption && slugOption !== synced.slug)
|
|
1520
|
+
throw new Error(`This copy is the team ${kind} ${synced.slug}. Copy it to another file to push it under another slug.`);
|
|
1521
|
+
const local = parsed.hasMetadata ? parsed.frontmatter : "";
|
|
1522
|
+
const withheld = synced && kind === "agent" ? [...HARNESS_SETTING_FIELDS, ...!synced.scripts && synced.executes ? AGENT_EXECUTING_FIELDS : []] : [];
|
|
1523
|
+
const frontmatter = withheld.length ? restoreFrontmatterFields(local, (await client.teamSkill(slug)).data.skill.frontmatter, withheld) : local;
|
|
1524
|
+
const team = synced && kind === "rule" ? (await client.teamSkill(slug)).data.skill : undefined;
|
|
1525
|
+
const plain = (text) => team ? unescapeImports(text, `${team.title}\n${team.content}`) : text;
|
|
1526
|
+
const item = { slug, ...(!synced ? { name: (kind === "agent" ? fromName : kind === "instructions" ? fromFile.toLowerCase() : fromFile).replaceAll("_", "-") } : {}), kind,
|
|
1527
|
+
title: plain(parsed.title ?? parsed.name ?? slug), description: parsed.description ?? (kind === "instructions" ? parsed.title ?? basename(file) : ""), content: plain(parsed.content), frontmatter };
|
|
1528
|
+
const checked = platformTeamSkillCreateSchema.safeParse(item);
|
|
1529
|
+
if (!checked.success) {
|
|
1530
|
+
const fields = [...new Set(checked.error.issues.map(issue => String(issue.path[0])))];
|
|
1531
|
+
throw new Error(`The file cannot be a team ${kind}. Fix: ${fields.map(field => `${field}: ${checked.error.issues.find(issue => String(issue.path[0]) === field)?.message}`).join("; ")}.`);
|
|
1532
|
+
}
|
|
1533
|
+
const credential = credentialIn(text);
|
|
1534
|
+
if (credential)
|
|
1535
|
+
throw new Error(`The ${kind} looks like it contains ${credential.kind} (line ${credential.line}). It is shared with the team: remove it and push again.`);
|
|
1536
|
+
try {
|
|
1537
|
+
if (synced && manifest) {
|
|
1538
|
+
const { slug: _slug, kind: _kind, ...definition } = checked.data;
|
|
1539
|
+
const saved = (await client.updateTeamSkill(slug, { ...definition, expectedVersion: synced.version })).data.skill;
|
|
1540
|
+
const rendered = kind === "rule" ? renderClaudeRule(saved) : renderClaudeAgent(saved, synced.scripts);
|
|
1541
|
+
const updated = { ...synced, version: saved.version, sha256: digest(rendered), previous: digest(text), format: FORMAT };
|
|
1542
|
+
await writeManifest(roots.home, { ...manifest, files: manifest.files.map(entry => entry === synced ? updated : entry) });
|
|
1543
|
+
await writePrivateText(file, rendered, false, limits.markdownBytes * 2);
|
|
1544
|
+
await writeManifest(roots.home, { ...manifest, files: manifest.files.map(entry => entry === synced ? { ...updated, previous: undefined } : entry) });
|
|
1545
|
+
return { slug, kind, version: saved.version, created: false, changed: true };
|
|
1546
|
+
}
|
|
1547
|
+
const saved = (await client.createTeamSkill(checked.data)).data.skill;
|
|
1548
|
+
return { slug, kind, version: saved.version, created: true, changed: true };
|
|
1549
|
+
}
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
if (!(error instanceof PlatformApiError))
|
|
1552
|
+
throw error;
|
|
1553
|
+
if (error.code === "platform_team_item_name_exists")
|
|
1554
|
+
throw new Error(`That environment name is already in use. Choose another ${kind === "agent" ? "frontmatter name" : "Markdown filename"} and push again.`);
|
|
1555
|
+
if (error.code === "platform_team_skill_exists")
|
|
1556
|
+
throw new Error(kind === "instructions" ? `The team already has instructions named ${slug}. Edit them in the Relvo console, or push this one with another --slug.`
|
|
1557
|
+
: `The team already has a skill, agent, rule or hook named ${slug}, and this file is not its synced copy. Move this file away, run relvo team-skills sync, apply your edit to the synced copy and push that, or push this one with another --slug.`);
|
|
1558
|
+
if (error.code === "platform_team_skill_version_conflict")
|
|
1559
|
+
throw new Error(`${slug} changed in Relvo after this copy was synced. Move your edited copy away, run relvo team-skills sync, then apply your edit to the new copy and push again.`);
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
}
|