agent-trellis 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +29 -4
- package/dist/commands/mcp.d.ts +60 -0
- package/dist/commands/mcp.js +105 -1
- package/dist/commands/skill.d.ts +45 -1
- package/dist/commands/skill.js +83 -2
- package/dist/lib/mcpRuntime.d.ts +6 -0
- package/dist/lib/mcpRuntime.js +52 -1
- package/dist/lib/scopeSelection.d.ts +36 -0
- package/dist/lib/scopeSelection.js +65 -0
- package/dist/lib/skillManifest.d.ts +65 -0
- package/dist/lib/skillManifest.js +203 -0
- package/dist/lib/skillProvider.d.ts +20 -0
- package/dist/lib/skillProvider.js +61 -1
- package/dist/pi-bridge/bundle.js +632 -632
- package/docs/skills-over-mcp.md +79 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -9,13 +9,14 @@ import { runInit } from "./commands/init.js";
|
|
|
9
9
|
import { runMigrate } from "./commands/migrate.js";
|
|
10
10
|
import { runOnboard } from "./commands/onboard.js";
|
|
11
11
|
import { runSync } from "./commands/sync.js";
|
|
12
|
-
import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
|
|
12
|
+
import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, runMcpScope, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
|
|
13
|
+
import { parseScopeArgs } from "./lib/scopeSelection.js";
|
|
13
14
|
import { runMcpImport } from "./commands/mcpImport.js";
|
|
14
15
|
import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
|
|
15
16
|
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
16
17
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
17
18
|
import { runRollback } from "./commands/rollback.js";
|
|
18
|
-
import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
19
|
+
import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
19
20
|
import { runRemoteSkillAdd, runRemoteSkillList, runRemoteSkillUpdate } from "./commands/remoteSkill.js";
|
|
20
21
|
import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./commands/memory.js";
|
|
21
22
|
import { parseManageArgs, runManage } from "./commands/manage.js";
|
|
@@ -142,6 +143,14 @@ Commands:
|
|
|
142
143
|
--auth none removes it along with the classification.
|
|
143
144
|
--dry-run preview the plan, write nothing
|
|
144
145
|
--json machine-readable output, no report text
|
|
146
|
+
mcp scope <name> --agents <ids> | --all | --none
|
|
147
|
+
Choose which managed agents reach a canonical MCP server, then
|
|
148
|
+
run the same sync as mcp sync. Exactly one selector is
|
|
149
|
+
required; ids must be managed agents. Selecting every managed
|
|
150
|
+
agent records no explicit scope, so a newly managed agent is
|
|
151
|
+
included. An entry Trellis did not write is never removed.
|
|
152
|
+
--dry-run preview the plan, write nothing
|
|
153
|
+
--json machine-readable output, no report text
|
|
145
154
|
mcp auth <server-name>
|
|
146
155
|
Authorize a remote (http/sse) MCP server that uses OAuth —
|
|
147
156
|
opens a browser once, stores the result 0600 under
|
|
@@ -169,6 +178,12 @@ Commands:
|
|
|
169
178
|
--dry-run preview the plan, write nothing
|
|
170
179
|
--json machine-readable output, no report text
|
|
171
180
|
skill remove <name>
|
|
181
|
+
skill scope <name> --agents <ids> | --all | --none
|
|
182
|
+
Choose which managed agents receive a canonical skill, then
|
|
183
|
+
sync it. Same selector rules as mcp scope; a built-in skill's
|
|
184
|
+
scope is not editable
|
|
185
|
+
--dry-run preview the plan, write nothing
|
|
186
|
+
--json machine-readable output, no report text
|
|
172
187
|
skill update-builtin
|
|
173
188
|
Refresh the package-owned trellis-runtime Skill in canonical
|
|
174
189
|
source; existing native symlinks see the update
|
|
@@ -440,6 +455,11 @@ async function main(argv) {
|
|
|
440
455
|
process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
|
|
441
456
|
return;
|
|
442
457
|
}
|
|
458
|
+
if (subcommand === "scope") {
|
|
459
|
+
const [name] = mcpRest;
|
|
460
|
+
process.exitCode = (await runMcpScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(mcpRest), { json, dryRun })).exitCode;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
443
463
|
if (subcommand === "remove") {
|
|
444
464
|
const [name] = mcpRest;
|
|
445
465
|
if (!name) {
|
|
@@ -461,7 +481,7 @@ async function main(argv) {
|
|
|
461
481
|
process.exitCode = exitCode;
|
|
462
482
|
return;
|
|
463
483
|
}
|
|
464
|
-
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|remove <name>|auth <name>\n`);
|
|
484
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>\n`);
|
|
465
485
|
process.exitCode = 1;
|
|
466
486
|
return;
|
|
467
487
|
}
|
|
@@ -495,11 +515,16 @@ async function main(argv) {
|
|
|
495
515
|
process.exitCode = (await runSkillRemove(name, { json, dryRun })).exitCode;
|
|
496
516
|
return;
|
|
497
517
|
}
|
|
518
|
+
if (subcommand === "scope") {
|
|
519
|
+
const [name] = skillRest;
|
|
520
|
+
process.exitCode = (await runSkillScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(skillRest), { json, dryRun })).exitCode;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
498
523
|
if (subcommand === "update-builtin") {
|
|
499
524
|
process.exitCode = runSkillUpdateBuiltin({ json, dryRun }).exitCode;
|
|
500
525
|
return;
|
|
501
526
|
}
|
|
502
|
-
console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|update-builtin\n`);
|
|
527
|
+
console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|scope <name> --agents <ids>|--all|--none|update-builtin\n`);
|
|
503
528
|
process.exitCode = 1;
|
|
504
529
|
return;
|
|
505
530
|
}
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { type ServersYamlWriteResult } from "../core/canonical.js";
|
|
16
16
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
17
17
|
import type { AgentId, McpAuthMode, McpConfig, McpServerDef, Transport } from "../core/types.js";
|
|
18
|
+
import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
|
|
18
19
|
import { type BackupSession } from "../lib/backup.js";
|
|
19
20
|
export interface RunMcpSyncOptions {
|
|
20
21
|
json?: boolean;
|
|
@@ -62,6 +63,12 @@ export interface McpListEntry {
|
|
|
62
63
|
* ever changes and there is one less place a future secret-looking field
|
|
63
64
|
* could leak through. */
|
|
64
65
|
preRegisteredClient?: true;
|
|
66
|
+
/** State of the stored OAuth credential, for OAuth-classified servers only
|
|
67
|
+
* (trellis-scope-editing-and-auth-status design.md D6). It describes the
|
|
68
|
+
* credential, not the connection: an `authorized` server can still be down. */
|
|
69
|
+
authStatus?: McpAuthStatus;
|
|
70
|
+
/** Epoch ms, when the stored token advertises an expiry. */
|
|
71
|
+
authExpiresAt?: number;
|
|
65
72
|
enabled: boolean;
|
|
66
73
|
agents: readonly AgentId[];
|
|
67
74
|
command?: string;
|
|
@@ -77,6 +84,18 @@ export interface McpListEntry {
|
|
|
77
84
|
* printed in full. */
|
|
78
85
|
staticEnv?: Record<string, string>;
|
|
79
86
|
}
|
|
87
|
+
export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
|
|
88
|
+
/**
|
|
89
|
+
* Reads only the per-server token store and returns the state, never a
|
|
90
|
+
* token. Classification stays explicit — the caller only asks for servers
|
|
91
|
+
* already declared OAuth, so a stray token file never makes one. A server
|
|
92
|
+
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
93
|
+
* credential: one odd entry must not take down the whole listing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function mcpCredentialState(homeDir: string, serverName: string): {
|
|
96
|
+
authStatus: McpAuthStatus;
|
|
97
|
+
authExpiresAt?: number;
|
|
98
|
+
};
|
|
80
99
|
export declare function collectMcpListPlan(homeDir?: string): McpListEntry[];
|
|
81
100
|
export declare function runMcpList(opts?: {
|
|
82
101
|
homeDir?: string;
|
|
@@ -169,6 +188,47 @@ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthR
|
|
|
169
188
|
}): {
|
|
170
189
|
exitCode: number;
|
|
171
190
|
};
|
|
191
|
+
export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
192
|
+
export interface McpScopePlan {
|
|
193
|
+
name: string;
|
|
194
|
+
action: McpScopeAction;
|
|
195
|
+
detail: string;
|
|
196
|
+
/** Recorded as `agents:` on the entry; `undefined` removes the key so the
|
|
197
|
+
* server reaches every managed agent. */
|
|
198
|
+
scope?: AgentId[];
|
|
199
|
+
mode?: "all" | "none" | "explicit";
|
|
200
|
+
normalizedFromFull?: boolean;
|
|
201
|
+
effective?: readonly AgentId[];
|
|
202
|
+
/** Only set when action === "updated"; consumed by applyMcpScopeWithSync. */
|
|
203
|
+
def?: McpServerDef;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `mcp scope <name> --agents a,b | --all | --none`
|
|
207
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
|
|
208
|
+
* `skill scope`, which is why both go through `resolveScopeSelection`.
|
|
209
|
+
*/
|
|
210
|
+
export declare function collectMcpScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): McpScopePlan;
|
|
211
|
+
export interface McpScopeOutcome {
|
|
212
|
+
plan: McpScopePlan;
|
|
213
|
+
writeError?: string;
|
|
214
|
+
sync?: McpSyncReport;
|
|
215
|
+
}
|
|
216
|
+
/** Write inside a backup session, then the same `mcp sync` `add` / `remove`
|
|
217
|
+
* run. Removal of an entry from an agent narrowed away stays gated by the
|
|
218
|
+
* ownership ledger, so an entry Trellis did not write is never deleted
|
|
219
|
+
* (design.md D5). */
|
|
220
|
+
export declare function applyMcpScopeWithSync(plan: McpScopePlan, opts?: {
|
|
221
|
+
homeDir?: string;
|
|
222
|
+
dryRun?: boolean;
|
|
223
|
+
backupSession?: BackupSession;
|
|
224
|
+
}): Promise<McpScopeOutcome>;
|
|
225
|
+
export declare function runMcpScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
|
|
226
|
+
homeDir?: string;
|
|
227
|
+
json?: boolean;
|
|
228
|
+
dryRun?: boolean;
|
|
229
|
+
}): Promise<{
|
|
230
|
+
exitCode: number;
|
|
231
|
+
}>;
|
|
172
232
|
export declare function applyMcpAddPlan(plan: McpAddPlan, homeDir?: string, backup?: BackupSession): ServersYamlWriteResult;
|
|
173
233
|
export interface McpAddOutcome {
|
|
174
234
|
plan: McpAddPlan;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -18,6 +18,8 @@ import { join } from "node:path";
|
|
|
18
18
|
import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
|
|
19
19
|
import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
|
|
20
20
|
import { isValidSecretVarName } from "../lib/secretEnv.js";
|
|
21
|
+
import { isExpired, readToken } from "../lib/oauth/store.js";
|
|
22
|
+
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
21
23
|
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
22
24
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
23
25
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
@@ -114,6 +116,28 @@ export function printReport(report, dryRun) {
|
|
|
114
116
|
function serversYamlPath(homeDir) {
|
|
115
117
|
return join(homeDir, ".trellis", "mcp", "servers.yaml");
|
|
116
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads only the per-server token store and returns the state, never a
|
|
121
|
+
* token. Classification stays explicit — the caller only asks for servers
|
|
122
|
+
* already declared OAuth, so a stray token file never makes one. A server
|
|
123
|
+
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
124
|
+
* credential: one odd entry must not take down the whole listing.
|
|
125
|
+
*/
|
|
126
|
+
export function mcpCredentialState(homeDir, serverName) {
|
|
127
|
+
let token;
|
|
128
|
+
try {
|
|
129
|
+
token = readToken(homeDir, serverName);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { authStatus: "not-authorized" };
|
|
133
|
+
}
|
|
134
|
+
if (!token?.accessToken)
|
|
135
|
+
return { authStatus: "not-authorized" };
|
|
136
|
+
const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
|
|
137
|
+
if (!isExpired(token))
|
|
138
|
+
return { authStatus: "authorized", ...expiry };
|
|
139
|
+
return { authStatus: token.refreshToken ? "refreshable" : "expired", ...expiry };
|
|
140
|
+
}
|
|
117
141
|
export function collectMcpListPlan(homeDir = homedir()) {
|
|
118
142
|
const canonical = loadCanonicalSource(homeDir);
|
|
119
143
|
return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
|
|
@@ -121,6 +145,7 @@ export function collectMcpListPlan(homeDir = homedir()) {
|
|
|
121
145
|
transport: def.transport,
|
|
122
146
|
auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
|
|
123
147
|
preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
|
|
148
|
+
...(isOAuthAuth(def.auth) ? mcpCredentialState(homeDir, name) : {}),
|
|
124
149
|
enabled: def.enabled ?? true,
|
|
125
150
|
agents: resolveScope(def.agents, canonical.managedAgents),
|
|
126
151
|
command: def.command,
|
|
@@ -150,7 +175,7 @@ export function runMcpList(opts = {}) {
|
|
|
150
175
|
else {
|
|
151
176
|
for (const e of entries) {
|
|
152
177
|
const scope = e.agents.length > 0 ? e.agents.join(", ") : "(no managed agent reaches it)";
|
|
153
|
-
console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
|
|
178
|
+
console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""}${e.authStatus ? `, credential: ${e.authStatus}` : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
|
|
154
179
|
if (e.env && e.env.length > 0)
|
|
155
180
|
console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
|
|
156
181
|
if (e.staticEnv)
|
|
@@ -399,6 +424,85 @@ export function runMcpSetAuth(name, raw, opts = {}) {
|
|
|
399
424
|
}
|
|
400
425
|
return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
|
|
401
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* `mcp scope <name> --agents a,b | --all | --none`
|
|
429
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
|
|
430
|
+
* `skill scope`, which is why both go through `resolveScopeSelection`.
|
|
431
|
+
*/
|
|
432
|
+
export function collectMcpScopePlan(name, raw, homeDir = homedir()) {
|
|
433
|
+
if (!name)
|
|
434
|
+
return { name: "(none)", action: "invalid-input", detail: "usage: trellis mcp scope <name> --agents <ids> | --all | --none" };
|
|
435
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
436
|
+
const selection = resolveScopeSelection(raw, canonical.managedAgents);
|
|
437
|
+
if (!selection.ok)
|
|
438
|
+
return { name, action: "invalid-input", detail: selection.detail };
|
|
439
|
+
const current = canonical.mcp.servers[name];
|
|
440
|
+
if (!current)
|
|
441
|
+
return { name, action: "not-found", detail: `no canonical MCP server named "${name}"` };
|
|
442
|
+
const base = {
|
|
443
|
+
name,
|
|
444
|
+
scope: selection.scope,
|
|
445
|
+
mode: selection.mode,
|
|
446
|
+
normalizedFromFull: selection.normalizedFromFull,
|
|
447
|
+
effective: resolveScope(selection.scope, canonical.managedAgents),
|
|
448
|
+
};
|
|
449
|
+
const summary = describeScope(selection.scope, canonical.managedAgents);
|
|
450
|
+
if (sameScope(current.agents, selection.scope)) {
|
|
451
|
+
return { ...base, action: "already-set", detail: `scope is already ${summary}` };
|
|
452
|
+
}
|
|
453
|
+
const { agents: _agents, ...withoutAgents } = current;
|
|
454
|
+
const def = selection.scope === undefined ? withoutAgents : { ...withoutAgents, agents: selection.scope };
|
|
455
|
+
const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
|
|
456
|
+
return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}`, def };
|
|
457
|
+
}
|
|
458
|
+
/** Write inside a backup session, then the same `mcp sync` `add` / `remove`
|
|
459
|
+
* run. Removal of an entry from an agent narrowed away stays gated by the
|
|
460
|
+
* ownership ledger, so an entry Trellis did not write is never deleted
|
|
461
|
+
* (design.md D5). */
|
|
462
|
+
export async function applyMcpScopeWithSync(plan, opts = {}) {
|
|
463
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
464
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "mcp-scope") : undefined;
|
|
465
|
+
const session = opts.backupSession ?? ownSession;
|
|
466
|
+
let writeError;
|
|
467
|
+
if (!opts.dryRun && plan.action === "updated" && plan.def) {
|
|
468
|
+
const result = upsertServerYaml(serversYamlPath(homeDir), plan.name, plan.def, session);
|
|
469
|
+
if (!result.ok)
|
|
470
|
+
writeError = result.error;
|
|
471
|
+
}
|
|
472
|
+
ownSession?.finalize();
|
|
473
|
+
let syncReport;
|
|
474
|
+
if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
|
|
475
|
+
syncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun });
|
|
476
|
+
}
|
|
477
|
+
return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
|
|
478
|
+
}
|
|
479
|
+
export async function runMcpScope(name, raw, opts = {}) {
|
|
480
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
481
|
+
let outcome;
|
|
482
|
+
try {
|
|
483
|
+
outcome = await applyMcpScopeWithSync(collectMcpScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
487
|
+
return { exitCode: 1 };
|
|
488
|
+
}
|
|
489
|
+
const { plan, writeError, sync: syncReport } = outcome;
|
|
490
|
+
if (opts.json) {
|
|
491
|
+
const payload = writeError ? { ...plan, writeError } : plan;
|
|
492
|
+
console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}mcp scope ${plan.name}`);
|
|
496
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
497
|
+
if (writeError)
|
|
498
|
+
console.error(` write failed: ${writeError}`);
|
|
499
|
+
if (syncReport)
|
|
500
|
+
printReport(syncReport, opts.dryRun ?? false);
|
|
501
|
+
}
|
|
502
|
+
const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
|
|
503
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
504
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
505
|
+
}
|
|
402
506
|
export function applyMcpAddPlan(plan, homeDir = homedir(), backup) {
|
|
403
507
|
if (plan.action !== "create" || !plan.def)
|
|
404
508
|
return { ok: true };
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
11
|
import { type BackupSession } from "../lib/backup.js";
|
|
12
12
|
import type { SyncReport } from "./sync.js";
|
|
13
|
+
import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
|
|
13
14
|
import type { RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
|
|
14
15
|
export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
|
|
15
16
|
export interface BuiltinSkillUpdatePlan {
|
|
@@ -97,3 +98,46 @@ export declare function runSkillRemove(name: string, opts?: {
|
|
|
97
98
|
}): Promise<{
|
|
98
99
|
exitCode: number;
|
|
99
100
|
}>;
|
|
101
|
+
export type SkillScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
102
|
+
export interface SkillScopePlan {
|
|
103
|
+
name: string;
|
|
104
|
+
action: SkillScopeAction;
|
|
105
|
+
detail: string;
|
|
106
|
+
/** What will be recorded in `scope.yaml`; `undefined` clears the explicit
|
|
107
|
+
* scope so the skill reaches every managed agent. Set when the selection was
|
|
108
|
+
* valid, whatever the action. */
|
|
109
|
+
scope?: AgentId[];
|
|
110
|
+
mode?: "all" | "none" | "explicit";
|
|
111
|
+
/** True when a full selection was dropped instead of recorded
|
|
112
|
+
* (trellis-scope-editing-and-auth-status design.md D3). */
|
|
113
|
+
normalizedFromFull?: boolean;
|
|
114
|
+
/** The agents that will actually receive the skill afterwards. */
|
|
115
|
+
effective?: readonly AgentId[];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
119
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
120
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function collectSkillScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): SkillScopePlan;
|
|
123
|
+
export interface SkillScopeOutcome {
|
|
124
|
+
plan: SkillScopePlan;
|
|
125
|
+
writeError?: string;
|
|
126
|
+
sync?: SyncReport;
|
|
127
|
+
}
|
|
128
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
129
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
130
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
131
|
+
* (design.md D5). */
|
|
132
|
+
export declare function applySkillScopeWithSync(plan: SkillScopePlan, opts?: {
|
|
133
|
+
homeDir?: string;
|
|
134
|
+
dryRun?: boolean;
|
|
135
|
+
backupSession?: BackupSession;
|
|
136
|
+
}): Promise<SkillScopeOutcome>;
|
|
137
|
+
export declare function runSkillScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
|
|
138
|
+
homeDir?: string;
|
|
139
|
+
json?: boolean;
|
|
140
|
+
dryRun?: boolean;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
exitCode: number;
|
|
143
|
+
}>;
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -14,12 +14,13 @@ import { dirname } from "node:path";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { decideDirImport } from "../lib/dirEquals.js";
|
|
16
16
|
import { findSkillFile } from "../lib/skillFile.js";
|
|
17
|
-
import { loadCanonicalSource } from "../core/canonical.js";
|
|
17
|
+
import { loadCanonicalSource, writeSkillScopeYaml } from "../core/canonical.js";
|
|
18
18
|
import { resolveScope } from "../core/types.js";
|
|
19
19
|
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
20
20
|
import { openBackupSession } from "../lib/backup.js";
|
|
21
21
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
22
22
|
import { remoteSkillProvenance } from "./remoteSkill.js";
|
|
23
|
+
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
23
24
|
function builtinSkillTemplate() {
|
|
24
25
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
@@ -205,3 +206,83 @@ export async function runSkillRemove(name, opts = {}) {
|
|
|
205
206
|
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
206
207
|
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
211
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
212
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
213
|
+
*/
|
|
214
|
+
export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
|
|
215
|
+
if (!name)
|
|
216
|
+
return { name: "(none)", action: "invalid-input", detail: "usage: trellis skill scope <name> --agents <ids> | --all | --none" };
|
|
217
|
+
if (isBuiltinSkillName(name)) {
|
|
218
|
+
return { name, action: "invalid-input", detail: `"${name}" is a Trellis package-owned Skill — its scope is not editable` };
|
|
219
|
+
}
|
|
220
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
221
|
+
const selection = resolveScopeSelection(raw, canonical.managedAgents);
|
|
222
|
+
if (!selection.ok)
|
|
223
|
+
return { name, action: "invalid-input", detail: selection.detail };
|
|
224
|
+
const skill = canonical.skills.find((candidate) => candidate.name === name);
|
|
225
|
+
if (!skill)
|
|
226
|
+
return { name, action: "not-found", detail: `no canonical skill named "${name}"` };
|
|
227
|
+
const base = {
|
|
228
|
+
name,
|
|
229
|
+
scope: selection.scope,
|
|
230
|
+
mode: selection.mode,
|
|
231
|
+
normalizedFromFull: selection.normalizedFromFull,
|
|
232
|
+
effective: resolveScope(selection.scope, canonical.managedAgents),
|
|
233
|
+
};
|
|
234
|
+
const summary = describeScope(selection.scope, canonical.managedAgents);
|
|
235
|
+
const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
|
|
236
|
+
if (sameScope(skill.scope, selection.scope)) {
|
|
237
|
+
return { ...base, action: "already-set", detail: `scope is already ${summary}` };
|
|
238
|
+
}
|
|
239
|
+
return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}` };
|
|
240
|
+
}
|
|
241
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
242
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
243
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
244
|
+
* (design.md D5). */
|
|
245
|
+
export async function applySkillScopeWithSync(plan, opts = {}) {
|
|
246
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
247
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "skill-scope") : undefined;
|
|
248
|
+
const session = opts.backupSession ?? ownSession;
|
|
249
|
+
let writeError;
|
|
250
|
+
if (!opts.dryRun && plan.action === "updated") {
|
|
251
|
+
const result = writeSkillScopeYaml(join(homeDir, ".trellis", "scope.yaml"), plan.name, plan.scope, session);
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
writeError = result.error;
|
|
254
|
+
}
|
|
255
|
+
ownSession?.finalize();
|
|
256
|
+
let syncReport;
|
|
257
|
+
if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
|
|
258
|
+
syncReport = await collectSyncReport({ target: "skills", homeDir, dryRun: opts.dryRun });
|
|
259
|
+
}
|
|
260
|
+
return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
|
|
261
|
+
}
|
|
262
|
+
export async function runSkillScope(name, raw, opts = {}) {
|
|
263
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
264
|
+
let outcome;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await applySkillScopeWithSync(collectSkillScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
270
|
+
return { exitCode: 1 };
|
|
271
|
+
}
|
|
272
|
+
const { plan, writeError, sync: syncReport } = outcome;
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
const payload = writeError ? { ...plan, writeError } : plan;
|
|
275
|
+
console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill scope ${plan.name}`);
|
|
279
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
280
|
+
if (writeError)
|
|
281
|
+
console.error(` write failed: ${writeError}`);
|
|
282
|
+
if (syncReport)
|
|
283
|
+
printSyncReport(syncReport, opts.dryRun ?? false);
|
|
284
|
+
}
|
|
285
|
+
const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
|
|
286
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
287
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
288
|
+
}
|
package/dist/lib/mcpRuntime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
2
2
|
import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import type { AgentId } from "../core/types.js";
|
|
4
4
|
import type { GatewayBackend } from "./gatewayBackend.js";
|
|
5
|
+
import type { SkillEntry } from "./skillManifest.js";
|
|
5
6
|
export interface RuntimeContext {
|
|
6
7
|
agentId: AgentId;
|
|
7
8
|
homeDir: string;
|
|
@@ -14,6 +15,9 @@ export interface TrellisProvider {
|
|
|
14
15
|
readResource?(uri: URL, context: RuntimeContext): Promise<ReadResourceResult> | ReadResourceResult;
|
|
15
16
|
listPrompts?(context: RuntimeContext): Promise<Prompt[]> | Prompt[];
|
|
16
17
|
getPrompt?(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult> | GetPromptResult;
|
|
18
|
+
/** Skills-over-MCP `skills/list` and `skills/get` (trellis-skills-over-mcp). */
|
|
19
|
+
listSkillEntries?(context: RuntimeContext): Promise<SkillEntry[]> | SkillEntry[];
|
|
20
|
+
getSkillEntry?(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined> | SkillEntry | undefined;
|
|
17
21
|
close?(): Promise<void>;
|
|
18
22
|
}
|
|
19
23
|
export declare class BuiltinRegistry {
|
|
@@ -29,6 +33,8 @@ export declare class BuiltinRegistry {
|
|
|
29
33
|
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
30
34
|
listResources(context: RuntimeContext): Promise<Resource[]>;
|
|
31
35
|
readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
|
|
36
|
+
listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
|
|
37
|
+
getSkill(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined>;
|
|
32
38
|
listPrompts(context: RuntimeContext): Promise<Prompt[]>;
|
|
33
39
|
getPrompt(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult>;
|
|
34
40
|
close(): Promise<void>;
|
package/dist/lib/mcpRuntime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
-
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { CallToolRequestSchema, ErrorCode, McpError, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { allocateExposedToolNames } from "./mcpToolRegistry.js";
|
|
4
4
|
function compactBuiltinToolName(name) {
|
|
5
5
|
return name.startsWith("trellis.") ? name.slice("trellis.".length).replaceAll(".", "_") : name;
|
|
@@ -106,6 +106,35 @@ export class BuiltinRegistry {
|
|
|
106
106
|
throw new Error(`unknown Trellis resource "${key}"`);
|
|
107
107
|
return provider.readResource(uri, context);
|
|
108
108
|
}
|
|
109
|
+
async listSkills(context) {
|
|
110
|
+
const skills = [];
|
|
111
|
+
for (const provider of this.providers) {
|
|
112
|
+
if (!provider.listSkillEntries)
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
skills.push(...(await provider.listSkillEntries(context)));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
this.warn(providerError(provider.id, err));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return skills;
|
|
122
|
+
}
|
|
123
|
+
async getSkill(uri, context) {
|
|
124
|
+
for (const provider of this.providers) {
|
|
125
|
+
if (!provider.getSkillEntry)
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
const entry = await provider.getSkillEntry(uri, context);
|
|
129
|
+
if (entry)
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
this.warn(providerError(provider.id, err));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
109
138
|
async listPrompts(context) {
|
|
110
139
|
const prompts = [];
|
|
111
140
|
this.promptOwners.clear();
|
|
@@ -181,5 +210,27 @@ export function createRuntimeServer(serverInfo, context, registry) {
|
|
|
181
210
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => registry.readResource(new URL(request.params.uri), context));
|
|
182
211
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await registry.listPrompts(context) }));
|
|
183
212
|
server.setRequestHandler(GetPromptRequestSchema, async (request) => registry.getPrompt(request.params.name, request.params.arguments, context));
|
|
213
|
+
// `skills/list` and `skills/get` are not in this SDK's schema set (it speaks
|
|
214
|
+
// at most 2025-11-25), so they are answered through the SDK's fallback for
|
|
215
|
+
// methods with no registered handler, which keeps the extension free of a new
|
|
216
|
+
// direct zod dependency. The extension itself is deliberately NOT declared in
|
|
217
|
+
// the capabilities above: the specification declares it only through
|
|
218
|
+
// `server/discover`, which this revision lacks, and inventing another field
|
|
219
|
+
// would claim a protocol nobody defined (trellis-skills-over-mcp D4). Any
|
|
220
|
+
// other unknown method keeps the SDK's usual "method not found".
|
|
221
|
+
server.fallbackRequestHandler = async (request) => {
|
|
222
|
+
const cache = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
|
|
223
|
+
if (request.method === "skills/list") {
|
|
224
|
+
return { ...cache, skills: await registry.listSkills(context) };
|
|
225
|
+
}
|
|
226
|
+
if (request.method === "skills/get") {
|
|
227
|
+
const uri = request.params?.uri;
|
|
228
|
+
const entry = typeof uri === "string" ? await registry.getSkill(uri, context) : undefined;
|
|
229
|
+
if (!entry)
|
|
230
|
+
throw new McpError(ErrorCode.InvalidParams, "unknown skill");
|
|
231
|
+
return { ...cache, skill: entry };
|
|
232
|
+
}
|
|
233
|
+
throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`);
|
|
234
|
+
};
|
|
184
235
|
return server;
|
|
185
236
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared agent-scope selection for `skill scope` / `mcp scope`
|
|
3
|
+
* (trellis-scope-editing-and-auth-status design.md D1–D3). Both commands
|
|
4
|
+
* take exactly one of `--agents a,b`, `--all`, `--none`; keeping the parsing,
|
|
5
|
+
* validation and normalization here means a skill and an MCP server can never
|
|
6
|
+
* disagree about what a given selection means.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentId } from "../core/types.js";
|
|
9
|
+
/** Raw, unvalidated flag values — the parser stays a dumb reader and every
|
|
10
|
+
* rule below lives in `resolveScopeSelection`. */
|
|
11
|
+
export interface ScopeSelectorRaw {
|
|
12
|
+
/** Comma-separated agent ids. */
|
|
13
|
+
agents?: string;
|
|
14
|
+
all?: boolean;
|
|
15
|
+
none?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type ScopeSelection = {
|
|
18
|
+
ok: true;
|
|
19
|
+
/** What to record: `undefined` clears the explicit scope. */
|
|
20
|
+
scope: AgentId[] | undefined;
|
|
21
|
+
mode: "all" | "none" | "explicit";
|
|
22
|
+
/** True when the user listed every managed agent and the explicit scope
|
|
23
|
+
* was dropped instead of recorded (design.md D3). */
|
|
24
|
+
normalizedFromFull: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
detail: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
|
|
30
|
+
/** `argv.indexOf`-style reader, same idiom as every other command's flags. */
|
|
31
|
+
export declare function parseScopeArgs(argv: readonly string[]): ScopeSelectorRaw;
|
|
32
|
+
export declare function resolveScopeSelection(raw: ScopeSelectorRaw, managed: readonly AgentId[]): ScopeSelection;
|
|
33
|
+
/** Order-insensitive equality between the scope on disk and the one about to
|
|
34
|
+
* be written, with `undefined` (no explicit scope) distinct from `[]`. */
|
|
35
|
+
export declare function sameScope(current: readonly AgentId[] | undefined, next: readonly AgentId[] | undefined): boolean;
|
|
36
|
+
export declare function describeScope(scope: readonly AgentId[] | undefined, managed: readonly AgentId[]): string;
|