agent-trellis 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/cli.js +94 -1
- package/dist/commands/remoteSkill.d.ts +90 -0
- package/dist/commands/remoteSkill.js +392 -0
- package/dist/commands/rollback.js +36 -5
- package/dist/commands/skill.d.ts +2 -0
- package/dist/commands/skill.js +6 -2
- package/dist/core/canonical.d.ts +17 -1
- package/dist/core/canonical.js +54 -0
- package/dist/lib/backup.d.ts +17 -5
- package/dist/lib/backup.js +22 -4
- package/dist/lib/dirDigest.d.ts +11 -0
- package/dist/lib/dirDigest.js +52 -0
- package/dist/lib/remoteSkillLock.d.ts +26 -0
- package/dist/lib/remoteSkillLock.js +88 -0
- package/dist/lib/remoteSkillSource.d.ts +45 -0
- package/dist/lib/remoteSkillSource.js +206 -0
- package/docs/getting-started.md +71 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ npm install -g agent-trellis
|
|
|
22
22
|
trellis onboard --dry-run # preview: migration source, managed agents, MCP, memory
|
|
23
23
|
trellis onboard # apply — every write is backed up automatically
|
|
24
24
|
trellis doctor # verify state, any time
|
|
25
|
+
|
|
26
|
+
# import a GitHub Skill into Trellis canonical storage, then sync it
|
|
27
|
+
npx trellis add mattpocock/skills --skill loop-me --dry-run
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
Something went wrong? `trellis rollback` inverts the last run.
|
package/dist/cli.js
CHANGED
|
@@ -16,12 +16,13 @@ import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
|
16
16
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
17
17
|
import { runRollback } from "./commands/rollback.js";
|
|
18
18
|
import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
19
|
+
import { runRemoteSkillAdd, runRemoteSkillList, runRemoteSkillUpdate } from "./commands/remoteSkill.js";
|
|
19
20
|
import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./commands/memory.js";
|
|
20
21
|
import { parseManageArgs, runManage } from "./commands/manage.js";
|
|
21
22
|
import { runKimi } from "./commands/kimi.js";
|
|
22
23
|
import { TRELLIS_VERSION } from "./lib/cliMetadata.js";
|
|
23
24
|
import { parseSyncArgs } from "./lib/syncArgs.js";
|
|
24
|
-
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "mcp-runtime", "skill", "memory", "manage", "kimi", "secrets", "rollback"];
|
|
25
|
+
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "mcp-runtime", "skill", "add", "update", "memory", "manage", "kimi", "secrets", "rollback"];
|
|
25
26
|
function printUsage() {
|
|
26
27
|
console.log(`trellis - a single source of capability for every coding agent
|
|
27
28
|
|
|
@@ -90,6 +91,22 @@ Commands:
|
|
|
90
91
|
Distribute skills/instructions to each agent (omit target for both)
|
|
91
92
|
--dry-run preview the plan, write nothing
|
|
92
93
|
--json machine-readable output, no report text
|
|
94
|
+
add <github-source> --skill <name>
|
|
95
|
+
Import one GitHub-hosted Skill into ~/.trellis/skills/, record
|
|
96
|
+
immutable provenance, then sync it only to managed Agents.
|
|
97
|
+
--skill, -s <name> required unless --list
|
|
98
|
+
--branch <ref> Git branch, tag, or ref; default is
|
|
99
|
+
the source repository's default branch
|
|
100
|
+
--agent, -a <ids|*> restrict delivery to managed Agent ids
|
|
101
|
+
--list show discovered Skills without import
|
|
102
|
+
--global, --yes accepted skills-CLI compatibility flags
|
|
103
|
+
--dry-run fetch and preview; write nothing
|
|
104
|
+
--json machine-readable output
|
|
105
|
+
update [skills...]
|
|
106
|
+
Safely refresh remotely tracked Skills. Refuses to overwrite a
|
|
107
|
+
canonical Skill edited since its recorded import.
|
|
108
|
+
--dry-run preview writes only
|
|
109
|
+
--json machine-readable output
|
|
93
110
|
mcp sync Distribute MCP servers to each agent's native config
|
|
94
111
|
(create/repair, plus ownership-ledger-gated removal — an
|
|
95
112
|
entry is only ever removed when it's still exactly what
|
|
@@ -203,6 +220,31 @@ See docs/roadmap.md for what's built vs. planned.`);
|
|
|
203
220
|
function printVersion() {
|
|
204
221
|
console.log(TRELLIS_VERSION);
|
|
205
222
|
}
|
|
223
|
+
function remoteOptionValues(args, flags) {
|
|
224
|
+
const values = [];
|
|
225
|
+
const positions = new Set();
|
|
226
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
227
|
+
const arg = args[index];
|
|
228
|
+
const matching = flags.find((flag) => arg === flag || arg.startsWith(`${flag}=`));
|
|
229
|
+
if (!matching)
|
|
230
|
+
continue;
|
|
231
|
+
positions.add(index);
|
|
232
|
+
if (arg.startsWith(`${matching}=`)) {
|
|
233
|
+
values.push(arg.slice(matching.length + 1));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const value = args[index + 1];
|
|
237
|
+
if (!value || value.startsWith("-"))
|
|
238
|
+
return { values, positions, error: `${matching} needs a value` };
|
|
239
|
+
positions.add(index + 1);
|
|
240
|
+
values.push(value);
|
|
241
|
+
index += 1;
|
|
242
|
+
}
|
|
243
|
+
return { values, positions };
|
|
244
|
+
}
|
|
245
|
+
function remotePositionals(args, consumed) {
|
|
246
|
+
return args.filter((arg, index) => !consumed.has(index) && !arg.startsWith("-"));
|
|
247
|
+
}
|
|
206
248
|
async function main(argv) {
|
|
207
249
|
const [command, ...rest] = argv;
|
|
208
250
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -311,6 +353,57 @@ async function main(argv) {
|
|
|
311
353
|
process.exitCode = exitCode;
|
|
312
354
|
return;
|
|
313
355
|
}
|
|
356
|
+
if (command === "add") {
|
|
357
|
+
const skill = remoteOptionValues(rest, ["--skill", "-s"]);
|
|
358
|
+
const branch = remoteOptionValues(rest, ["--branch"]);
|
|
359
|
+
const agents = remoteOptionValues(rest, ["--agent", "-a"]);
|
|
360
|
+
const consumed = new Set([...skill.positions, ...branch.positions, ...agents.positions]);
|
|
361
|
+
const positionals = remotePositionals(rest, consumed);
|
|
362
|
+
const json = rest.includes("--json");
|
|
363
|
+
const prohibited = rest.find((arg) => /^(--copy|--project|--local)(?:=|$)/.test(arg));
|
|
364
|
+
if (prohibited) {
|
|
365
|
+
const detail = "Trellis remote Skills are always imported into ~/.trellis/skills; --copy and project-local installation are not supported.";
|
|
366
|
+
if (json)
|
|
367
|
+
console.log(JSON.stringify({ action: "invalid-options", detail }, null, 2));
|
|
368
|
+
else
|
|
369
|
+
console.error(detail);
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (skill.error || branch.error || agents.error || positionals.length !== 1) {
|
|
374
|
+
const detail = skill.error ?? branch.error ?? agents.error ?? "Usage: trellis add <github-source> --skill <name>";
|
|
375
|
+
if (json)
|
|
376
|
+
console.log(JSON.stringify({ action: "invalid-options", detail }, null, 2));
|
|
377
|
+
else
|
|
378
|
+
console.error(detail);
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const source = positionals[0];
|
|
383
|
+
if (rest.includes("--list")) {
|
|
384
|
+
process.exitCode = (await runRemoteSkillList(source, { branch: branch.values[0], json })).exitCode;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (skill.values.length !== 1 || branch.values.length > 1) {
|
|
388
|
+
const detail = skill.values.length !== 1 ? "Use exactly one --skill value" : "Use at most one --branch value";
|
|
389
|
+
if (json)
|
|
390
|
+
console.log(JSON.stringify({ action: "invalid-options", detail }, null, 2));
|
|
391
|
+
else
|
|
392
|
+
console.error(detail);
|
|
393
|
+
process.exitCode = 1;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
process.exitCode = (await runRemoteSkillAdd(source, skill.values[0], {
|
|
397
|
+
branch: branch.values[0], agents: agents.values, dryRun: rest.includes("--dry-run"), json,
|
|
398
|
+
})).exitCode;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (command === "update") {
|
|
402
|
+
const json = rest.includes("--json");
|
|
403
|
+
const names = rest.filter((arg) => !arg.startsWith("-"));
|
|
404
|
+
process.exitCode = (await runRemoteSkillUpdate(names, { dryRun: rest.includes("--dry-run"), json })).exitCode;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
314
407
|
if (command === "mcp") {
|
|
315
408
|
const [subcommand, ...mcpRest] = rest;
|
|
316
409
|
const json = mcpRest.includes("--json");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis add` and `trellis update`: GitHub-hosted Skill import with
|
|
3
|
+
* Trellis-owned canonical storage, provenance, scope, backup, and sync.
|
|
4
|
+
*/
|
|
5
|
+
import type { Scope } from "../core/types.js";
|
|
6
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
7
|
+
import { type RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
|
|
8
|
+
import { type FetchedRemoteRepository } from "../lib/remoteSkillSource.js";
|
|
9
|
+
import type { SyncReport } from "./sync.js";
|
|
10
|
+
export type RemoteSkillAction = "create" | "already-present" | "conflict" | "invalid-source" | "invalid-options";
|
|
11
|
+
export interface RemoteSkillAddPlan {
|
|
12
|
+
name: string;
|
|
13
|
+
action: RemoteSkillAction;
|
|
14
|
+
detail: string;
|
|
15
|
+
source?: string;
|
|
16
|
+
requestedRef?: string;
|
|
17
|
+
commit?: string;
|
|
18
|
+
subdirectory?: string;
|
|
19
|
+
digest?: string;
|
|
20
|
+
canonicalPath?: string;
|
|
21
|
+
scope?: Scope;
|
|
22
|
+
/** Present only in process memory; never included in CLI output. */
|
|
23
|
+
sourceDir?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RemoteSkillAddOptions {
|
|
26
|
+
branch?: string;
|
|
27
|
+
agents?: readonly string[];
|
|
28
|
+
homeDir?: string;
|
|
29
|
+
dryRun?: boolean;
|
|
30
|
+
json?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Builds an import plan from an already-fetched source. Keeping fetching
|
|
33
|
+
* outside this function makes the real Git transport testable with a local
|
|
34
|
+
* bare repository while public CLI input remains GitHub-only. */
|
|
35
|
+
export declare function collectRemoteSkillAddPlan(fetched: FetchedRemoteRepository, name: string | undefined, opts?: Omit<RemoteSkillAddOptions, "branch" | "dryRun" | "json">): RemoteSkillAddPlan;
|
|
36
|
+
export interface RemoteSkillAddOutcome {
|
|
37
|
+
plan: Omit<RemoteSkillAddPlan, "sourceDir">;
|
|
38
|
+
sync?: SyncReport;
|
|
39
|
+
}
|
|
40
|
+
export declare function applyRemoteSkillAddPlan(plan: RemoteSkillAddPlan, opts?: {
|
|
41
|
+
homeDir?: string;
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
backupSession?: BackupSession;
|
|
44
|
+
}): Promise<RemoteSkillAddOutcome>;
|
|
45
|
+
/** CLI-facing remote add. The only source accepted here is normalized
|
|
46
|
+
* GitHub input; tests use collectRemoteSkillAddPlan with a local fetch. */
|
|
47
|
+
export declare function runRemoteSkillAdd(sourceInput: string | undefined, name: string | undefined, opts?: RemoteSkillAddOptions): Promise<{
|
|
48
|
+
exitCode: number;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function runRemoteSkillList(sourceInput: string | undefined, opts?: {
|
|
51
|
+
branch?: string;
|
|
52
|
+
json?: boolean;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
exitCode: number;
|
|
55
|
+
}>;
|
|
56
|
+
export type RemoteSkillUpdateAction = "already-current" | "update" | "conflict" | "not-tracked" | "invalid-source";
|
|
57
|
+
export interface RemoteSkillUpdatePlan {
|
|
58
|
+
name: string;
|
|
59
|
+
action: RemoteSkillUpdateAction;
|
|
60
|
+
detail: string;
|
|
61
|
+
source?: string;
|
|
62
|
+
requestedRef?: string;
|
|
63
|
+
previousCommit?: string;
|
|
64
|
+
commit?: string;
|
|
65
|
+
previousDigest?: string;
|
|
66
|
+
digest?: string;
|
|
67
|
+
canonicalPath?: string;
|
|
68
|
+
sourceDir?: string;
|
|
69
|
+
replaceDirectory?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export declare function collectRemoteSkillUpdatePlan(name: string, fetched: FetchedRemoteRepository | undefined, homeDir?: string): RemoteSkillUpdatePlan;
|
|
72
|
+
export interface RemoteSkillUpdateOutcome {
|
|
73
|
+
plans: readonly Omit<RemoteSkillUpdatePlan, "sourceDir">[];
|
|
74
|
+
sync?: SyncReport;
|
|
75
|
+
}
|
|
76
|
+
export declare function applyRemoteSkillUpdatePlans(plans: readonly RemoteSkillUpdatePlan[], opts?: {
|
|
77
|
+
homeDir?: string;
|
|
78
|
+
dryRun?: boolean;
|
|
79
|
+
backupSession?: BackupSession;
|
|
80
|
+
}): Promise<RemoteSkillUpdateOutcome>;
|
|
81
|
+
export declare function runRemoteSkillUpdate(names: readonly string[], opts?: {
|
|
82
|
+
homeDir?: string;
|
|
83
|
+
dryRun?: boolean;
|
|
84
|
+
json?: boolean;
|
|
85
|
+
}): Promise<{
|
|
86
|
+
exitCode: number;
|
|
87
|
+
}>;
|
|
88
|
+
/** Used by Skill listing to distinguish remote provenance from local content
|
|
89
|
+
* without making a malformed bookkeeping file crash a read-only list. */
|
|
90
|
+
export declare function remoteSkillProvenance(homeDir: string): Record<string, RemoteSkillLockEntry>;
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis add` and `trellis update`: GitHub-hosted Skill import with
|
|
3
|
+
* Trellis-owned canonical storage, provenance, scope, backup, and sync.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { validateSkillScopeYaml, writeSkillScopeYaml } from "../core/canonical.js";
|
|
9
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
10
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
11
|
+
import { decideDirImport } from "../lib/dirEquals.js";
|
|
12
|
+
import { directoryDigest } from "../lib/dirDigest.js";
|
|
13
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
14
|
+
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
15
|
+
import { emptyRemoteSkillLock, readRemoteSkillLock, writeRemoteSkillLock } from "../lib/remoteSkillLock.js";
|
|
16
|
+
import { fetchRemoteRepository, normalizeGitHubRepository } from "../lib/remoteSkillSource.js";
|
|
17
|
+
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
18
|
+
function safeSkillName(name) {
|
|
19
|
+
return typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) && name !== "." && name !== "..";
|
|
20
|
+
}
|
|
21
|
+
function cloneLock(lock) {
|
|
22
|
+
return { version: lock.version, skills: Object.fromEntries(Object.entries(lock.skills).map(([name, entry]) => [name, { ...entry }])) };
|
|
23
|
+
}
|
|
24
|
+
function lockEntryFor(source, requestedRef, commit, selected) {
|
|
25
|
+
return { source, requestedRef, commit, subdirectory: selected.subdirectory, digest: selected.digest };
|
|
26
|
+
}
|
|
27
|
+
function sameLockEntry(a, b) {
|
|
28
|
+
return a !== undefined
|
|
29
|
+
&& a.source === b.source
|
|
30
|
+
&& a.requestedRef === b.requestedRef
|
|
31
|
+
&& a.commit === b.commit
|
|
32
|
+
&& a.subdirectory === b.subdirectory
|
|
33
|
+
&& a.digest === b.digest;
|
|
34
|
+
}
|
|
35
|
+
function scopeForRequest(homeDir, agents) {
|
|
36
|
+
let canonical;
|
|
37
|
+
try {
|
|
38
|
+
canonical = loadCanonicalSource(homeDir);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
42
|
+
}
|
|
43
|
+
if (!agents || agents.length === 0)
|
|
44
|
+
return { scope: undefined };
|
|
45
|
+
const values = agents.flatMap((value) => value.split(",").map((id) => id.trim()).filter(Boolean));
|
|
46
|
+
if (values.length === 0)
|
|
47
|
+
return { error: "--agent needs at least one Agent id" };
|
|
48
|
+
if (values.includes("*")) {
|
|
49
|
+
if (values.length !== 1)
|
|
50
|
+
return { error: '--agent "*" cannot be combined with named Agents' };
|
|
51
|
+
return { scope: [...canonical.managedAgents] };
|
|
52
|
+
}
|
|
53
|
+
const resolved = [];
|
|
54
|
+
for (const id of values) {
|
|
55
|
+
if (!ALL_AGENTS.includes(id))
|
|
56
|
+
return { error: `Unknown Agent id: ${id}` };
|
|
57
|
+
const agent = id;
|
|
58
|
+
if (!canonical.managedAgents.includes(agent))
|
|
59
|
+
return { error: `${agent} is not managed. Add it with \`trellis manage add ${agent}\` before targeting it.` };
|
|
60
|
+
if (!resolved.includes(agent))
|
|
61
|
+
resolved.push(agent);
|
|
62
|
+
}
|
|
63
|
+
return { scope: resolved };
|
|
64
|
+
}
|
|
65
|
+
/** Builds an import plan from an already-fetched source. Keeping fetching
|
|
66
|
+
* outside this function makes the real Git transport testable with a local
|
|
67
|
+
* bare repository while public CLI input remains GitHub-only. */
|
|
68
|
+
export function collectRemoteSkillAddPlan(fetched, name, opts = {}) {
|
|
69
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
70
|
+
if (!safeSkillName(name))
|
|
71
|
+
return { name: name ?? "", action: "invalid-options", detail: "--skill must be a safe Skill directory name" };
|
|
72
|
+
if (isBuiltinSkillName(name))
|
|
73
|
+
return { name, action: "conflict", detail: `"${name}" is a Trellis package-owned Skill and cannot be replaced` };
|
|
74
|
+
const scopeResult = scopeForRequest(homeDir, opts.agents);
|
|
75
|
+
if (scopeResult.error)
|
|
76
|
+
return { name, action: "invalid-options", detail: scopeResult.error };
|
|
77
|
+
let selected;
|
|
78
|
+
try {
|
|
79
|
+
selected = fetched.select(name);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return { name, action: "invalid-source", detail: error instanceof Error ? error.message : String(error) };
|
|
83
|
+
}
|
|
84
|
+
const lockRead = readRemoteSkillLock(homeDir);
|
|
85
|
+
if (!lockRead.ok)
|
|
86
|
+
return { name, action: "conflict", detail: lockRead.error };
|
|
87
|
+
const canonicalPath = join(homeDir, ".trellis", "skills", name);
|
|
88
|
+
const incoming = lockEntryFor(fetched.repository.url, fetched.requestedRef, fetched.commit, selected);
|
|
89
|
+
const existingLock = lockRead.lock.skills[name];
|
|
90
|
+
const decision = decideDirImport(selected.directory, canonicalPath);
|
|
91
|
+
if (decision === "conflict") {
|
|
92
|
+
return { name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve it by hand`, canonicalPath, scope: scopeResult.scope };
|
|
93
|
+
}
|
|
94
|
+
if (decision === "already-present") {
|
|
95
|
+
if (!sameLockEntry(existingLock, incoming)) {
|
|
96
|
+
return { name, action: "conflict", detail: `canonical skills/${name}/ already exists without matching remote provenance — resolve it by hand`, canonicalPath, scope: scopeResult.scope };
|
|
97
|
+
}
|
|
98
|
+
return { name, action: "already-present", detail: "canonical content and remote provenance are already current", source: incoming.source, requestedRef: incoming.requestedRef, commit: incoming.commit, subdirectory: incoming.subdirectory, digest: incoming.digest, canonicalPath, scope: scopeResult.scope };
|
|
99
|
+
}
|
|
100
|
+
if (existingLock) {
|
|
101
|
+
return { name, action: "conflict", detail: `provenance already tracks ${name} but its canonical directory is absent — resolve it by hand`, canonicalPath, scope: scopeResult.scope };
|
|
102
|
+
}
|
|
103
|
+
return { name, action: "create", detail: `will import ${incoming.subdirectory} at ${incoming.commit}`, source: incoming.source, requestedRef: incoming.requestedRef, commit: incoming.commit, subdirectory: incoming.subdirectory, digest: incoming.digest, canonicalPath, scope: scopeResult.scope, sourceDir: selected.directory };
|
|
104
|
+
}
|
|
105
|
+
function successfulAdd(plan) {
|
|
106
|
+
return plan.action === "create" || plan.action === "already-present";
|
|
107
|
+
}
|
|
108
|
+
function displayAddPlan(plan) {
|
|
109
|
+
const { sourceDir: _sourceDir, ...safe } = plan;
|
|
110
|
+
return safe;
|
|
111
|
+
}
|
|
112
|
+
export async function applyRemoteSkillAddPlan(plan, opts = {}) {
|
|
113
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
114
|
+
if (!successfulAdd(plan))
|
|
115
|
+
return { plan: displayAddPlan(plan) };
|
|
116
|
+
if (opts.dryRun)
|
|
117
|
+
return { plan: displayAddPlan(plan) };
|
|
118
|
+
const ownSession = !opts.backupSession ? openBackupSession(homeDir, "remote-skill-add") : undefined;
|
|
119
|
+
const session = opts.backupSession ?? ownSession;
|
|
120
|
+
if (plan.action === "create") {
|
|
121
|
+
const scopePath = join(homeDir, ".trellis", "scope.yaml");
|
|
122
|
+
const scopeValidation = validateSkillScopeYaml(scopePath);
|
|
123
|
+
if (!scopeValidation.ok)
|
|
124
|
+
throw new Error(scopeValidation.error);
|
|
125
|
+
const lockRead = readRemoteSkillLock(homeDir);
|
|
126
|
+
if (!lockRead.ok)
|
|
127
|
+
throw new Error(lockRead.error);
|
|
128
|
+
session.createDirFromSource(plan.canonicalPath, plan.sourceDir);
|
|
129
|
+
const scopeWrite = writeSkillScopeYaml(scopePath, plan.name, plan.scope, session);
|
|
130
|
+
if (!scopeWrite.ok)
|
|
131
|
+
throw new Error(scopeWrite.error);
|
|
132
|
+
const lock = cloneLock(lockRead.lock);
|
|
133
|
+
lock.skills[plan.name] = {
|
|
134
|
+
source: plan.source, requestedRef: plan.requestedRef, commit: plan.commit, subdirectory: plan.subdirectory, digest: plan.digest,
|
|
135
|
+
};
|
|
136
|
+
writeRemoteSkillLock(homeDir, lock, session);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// An idempotent add may still deliberately change scope. The writer is
|
|
140
|
+
// no-op when it already matches, so it does not create a needless backup.
|
|
141
|
+
const scopeWrite = writeSkillScopeYaml(join(homeDir, ".trellis", "scope.yaml"), plan.name, plan.scope, session);
|
|
142
|
+
if (!scopeWrite.ok)
|
|
143
|
+
throw new Error(scopeWrite.error);
|
|
144
|
+
}
|
|
145
|
+
ownSession?.finalize();
|
|
146
|
+
let sync;
|
|
147
|
+
if (session.hasOperations() && existsSync(join(homeDir, ".trellis"))) {
|
|
148
|
+
sync = await collectSyncReport({ target: "skills", homeDir });
|
|
149
|
+
}
|
|
150
|
+
return { plan: displayAddPlan(plan), ...(sync ? { sync } : {}) };
|
|
151
|
+
}
|
|
152
|
+
/** CLI-facing remote add. The only source accepted here is normalized
|
|
153
|
+
* GitHub input; tests use collectRemoteSkillAddPlan with a local fetch. */
|
|
154
|
+
export async function runRemoteSkillAdd(sourceInput, name, opts = {}) {
|
|
155
|
+
let repository;
|
|
156
|
+
try {
|
|
157
|
+
repository = normalizeGitHubRepository(sourceInput ?? "");
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const plan = { name: name ?? "", action: "invalid-source", detail: error instanceof Error ? error.message : String(error) };
|
|
161
|
+
if (opts.json)
|
|
162
|
+
console.log(JSON.stringify(displayAddPlan(plan), null, 2));
|
|
163
|
+
else
|
|
164
|
+
console.error(plan.detail);
|
|
165
|
+
return { exitCode: 1 };
|
|
166
|
+
}
|
|
167
|
+
let fetched;
|
|
168
|
+
try {
|
|
169
|
+
fetched = fetchRemoteRepository(repository, { branch: opts.branch });
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const plan = { name: name ?? "", action: "invalid-source", detail: error instanceof Error ? error.message : String(error), source: repository.url };
|
|
173
|
+
if (opts.json)
|
|
174
|
+
console.log(JSON.stringify(displayAddPlan(plan), null, 2));
|
|
175
|
+
else
|
|
176
|
+
console.error(plan.detail);
|
|
177
|
+
return { exitCode: 1 };
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const plan = collectRemoteSkillAddPlan(fetched, name, opts);
|
|
181
|
+
const outcome = await applyRemoteSkillAddPlan(plan, { homeDir: opts.homeDir, dryRun: opts.dryRun });
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}remote skill add ${plan.name}`);
|
|
187
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
188
|
+
if (plan.source)
|
|
189
|
+
console.log(` source: ${plan.source}@${plan.commit} (${plan.subdirectory})`);
|
|
190
|
+
if (plan.canonicalPath)
|
|
191
|
+
console.log(` canonical: ${plan.canonicalPath}`);
|
|
192
|
+
if (plan.scope !== undefined)
|
|
193
|
+
console.log(` scope: ${plan.scope.join(", ") || "(no managed agents)"}`);
|
|
194
|
+
if (outcome.sync)
|
|
195
|
+
printSyncReport(outcome.sync, false);
|
|
196
|
+
}
|
|
197
|
+
const syncConflict = outcome.sync?.reports.some((report) => report.items.some((item) => item.action === "conflict")) ?? false;
|
|
198
|
+
return { exitCode: successfulAdd(plan) && !syncConflict ? 0 : 1 };
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
fetched.dispose();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function runRemoteSkillList(sourceInput, opts = {}) {
|
|
205
|
+
let repository;
|
|
206
|
+
try {
|
|
207
|
+
repository = normalizeGitHubRepository(sourceInput ?? "");
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
211
|
+
if (opts.json)
|
|
212
|
+
console.log(JSON.stringify({ action: "invalid-source", detail }, null, 2));
|
|
213
|
+
else
|
|
214
|
+
console.error(detail);
|
|
215
|
+
return { exitCode: 1 };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const fetched = fetchRemoteRepository(repository, { branch: opts.branch });
|
|
219
|
+
try {
|
|
220
|
+
const report = { source: repository.url, requestedRef: fetched.requestedRef, commit: fetched.commit, skills: fetched.candidates.map(({ name, subdirectory }) => ({ name, subdirectory })) };
|
|
221
|
+
if (opts.json)
|
|
222
|
+
console.log(JSON.stringify(report, null, 2));
|
|
223
|
+
else if (report.skills.length === 0)
|
|
224
|
+
console.log("No exact-case SKILL.md entries found in the remote source.");
|
|
225
|
+
else
|
|
226
|
+
for (const skill of report.skills)
|
|
227
|
+
console.log(`${skill.name} — ${skill.subdirectory}`);
|
|
228
|
+
return { exitCode: 0 };
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
fetched.dispose();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
236
|
+
if (opts.json)
|
|
237
|
+
console.log(JSON.stringify({ action: "invalid-source", detail, source: repository.url }, null, 2));
|
|
238
|
+
else
|
|
239
|
+
console.error(detail);
|
|
240
|
+
return { exitCode: 1 };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function displayUpdatePlan(plan) {
|
|
244
|
+
const { sourceDir: _sourceDir, ...safe } = plan;
|
|
245
|
+
return safe;
|
|
246
|
+
}
|
|
247
|
+
export function collectRemoteSkillUpdatePlan(name, fetched, homeDir = homedir()) {
|
|
248
|
+
const lockRead = readRemoteSkillLock(homeDir);
|
|
249
|
+
if (!lockRead.ok)
|
|
250
|
+
return { name, action: "conflict", detail: lockRead.error };
|
|
251
|
+
const entry = lockRead.lock.skills[name];
|
|
252
|
+
if (!entry)
|
|
253
|
+
return { name, action: "not-tracked", detail: `${name} is not a remotely tracked Skill` };
|
|
254
|
+
const canonicalPath = join(homeDir, ".trellis", "skills", name);
|
|
255
|
+
if (!existsSync(canonicalPath))
|
|
256
|
+
return { name, action: "conflict", detail: `canonical skills/${name}/ is missing while provenance remains`, canonicalPath };
|
|
257
|
+
let currentDigest;
|
|
258
|
+
try {
|
|
259
|
+
currentDigest = directoryDigest(canonicalPath);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
return { name, action: "conflict", detail: `could not read canonical skills/${name}/ safely: ${error instanceof Error ? error.message : String(error)}`, canonicalPath };
|
|
263
|
+
}
|
|
264
|
+
if (currentDigest !== entry.digest) {
|
|
265
|
+
return { name, action: "conflict", detail: `canonical skills/${name}/ has local edits and will not be overwritten`, source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, previousDigest: entry.digest, canonicalPath };
|
|
266
|
+
}
|
|
267
|
+
if (!fetched)
|
|
268
|
+
return { name, action: "invalid-source", detail: "Remote source was not fetched", source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, previousDigest: entry.digest, canonicalPath };
|
|
269
|
+
let selected;
|
|
270
|
+
try {
|
|
271
|
+
selected = fetched.select(name);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
return { name, action: "invalid-source", detail: error instanceof Error ? error.message : String(error), source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, previousDigest: entry.digest, canonicalPath };
|
|
275
|
+
}
|
|
276
|
+
if (selected.subdirectory !== entry.subdirectory) {
|
|
277
|
+
return { name, action: "conflict", detail: `remote Skill ${name} moved from ${entry.subdirectory} to ${selected.subdirectory}; resolve provenance by hand`, source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, previousDigest: entry.digest, canonicalPath };
|
|
278
|
+
}
|
|
279
|
+
if (fetched.commit === entry.commit && selected.digest === entry.digest) {
|
|
280
|
+
return { name, action: "already-current", detail: "remote commit and canonical content are already current", source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, commit: fetched.commit, previousDigest: entry.digest, digest: selected.digest, canonicalPath };
|
|
281
|
+
}
|
|
282
|
+
return { name, action: "update", detail: `will update ${entry.commit} -> ${fetched.commit}`, source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, commit: fetched.commit, previousDigest: entry.digest, digest: selected.digest, canonicalPath, sourceDir: selected.directory, replaceDirectory: selected.digest !== entry.digest };
|
|
283
|
+
}
|
|
284
|
+
export async function applyRemoteSkillUpdatePlans(plans, opts = {}) {
|
|
285
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
286
|
+
const updates = plans.filter((plan) => plan.action === "update");
|
|
287
|
+
if (opts.dryRun || updates.length === 0)
|
|
288
|
+
return { plans: plans.map(displayUpdatePlan) };
|
|
289
|
+
const ownSession = !opts.backupSession ? openBackupSession(homeDir, "remote-skill-update") : undefined;
|
|
290
|
+
const session = opts.backupSession ?? ownSession;
|
|
291
|
+
const lockRead = readRemoteSkillLock(homeDir);
|
|
292
|
+
if (!lockRead.ok)
|
|
293
|
+
throw new Error(lockRead.error);
|
|
294
|
+
const lock = cloneLock(lockRead.lock);
|
|
295
|
+
// Recheck all mutable inputs before the first replacement. The plans may
|
|
296
|
+
// have been previewed some time before application, so no remote update
|
|
297
|
+
// should proceed if either the lock or canonical directory changed.
|
|
298
|
+
for (const plan of updates) {
|
|
299
|
+
const current = lock.skills[plan.name];
|
|
300
|
+
if (!current || current.commit !== plan.previousCommit || current.digest !== plan.previousDigest) {
|
|
301
|
+
throw new Error(`Remote Skill ${plan.name} provenance changed after planning; run \`trellis update\` again.`);
|
|
302
|
+
}
|
|
303
|
+
if (directoryDigest(plan.canonicalPath) !== plan.previousDigest) {
|
|
304
|
+
throw new Error(`Canonical Skill ${plan.name} changed after planning; run \`trellis update\` again.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const plan of updates) {
|
|
308
|
+
if (plan.replaceDirectory)
|
|
309
|
+
session.replaceDirFromSource(plan.canonicalPath, plan.sourceDir);
|
|
310
|
+
lock.skills[plan.name] = { source: plan.source, requestedRef: plan.requestedRef, commit: plan.commit, subdirectory: lock.skills[plan.name].subdirectory, digest: plan.digest };
|
|
311
|
+
}
|
|
312
|
+
writeRemoteSkillLock(homeDir, lock, session);
|
|
313
|
+
ownSession?.finalize();
|
|
314
|
+
let sync;
|
|
315
|
+
if (session.hasOperations() && existsSync(join(homeDir, ".trellis")))
|
|
316
|
+
sync = await collectSyncReport({ target: "skills", homeDir });
|
|
317
|
+
return { plans: plans.map(displayUpdatePlan), ...(sync ? { sync } : {}) };
|
|
318
|
+
}
|
|
319
|
+
export async function runRemoteSkillUpdate(names, opts = {}) {
|
|
320
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
321
|
+
const lockRead = readRemoteSkillLock(homeDir);
|
|
322
|
+
const selectedNames = names.length > 0 ? [...new Set(names)] : lockRead.ok ? Object.keys(lockRead.lock.skills).sort() : [];
|
|
323
|
+
if (!lockRead.ok) {
|
|
324
|
+
const report = [{ name: "", action: "conflict", detail: lockRead.error }];
|
|
325
|
+
if (opts.json)
|
|
326
|
+
console.log(JSON.stringify({ plans: report }, null, 2));
|
|
327
|
+
else
|
|
328
|
+
console.error(lockRead.error);
|
|
329
|
+
return { exitCode: 1 };
|
|
330
|
+
}
|
|
331
|
+
if (selectedNames.length === 0) {
|
|
332
|
+
const report = { plans: [] };
|
|
333
|
+
if (opts.json)
|
|
334
|
+
console.log(JSON.stringify(report, null, 2));
|
|
335
|
+
else
|
|
336
|
+
console.log("No remotely tracked Skills to update.");
|
|
337
|
+
return { exitCode: 0 };
|
|
338
|
+
}
|
|
339
|
+
const fetched = new Map();
|
|
340
|
+
const plans = [];
|
|
341
|
+
try {
|
|
342
|
+
for (const name of selectedNames) {
|
|
343
|
+
const entry = lockRead.lock.skills[name];
|
|
344
|
+
if (!entry) {
|
|
345
|
+
plans.push(collectRemoteSkillUpdatePlan(name, undefined, homeDir));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
// Avoid network work when the canonical tree has been edited: its plan
|
|
349
|
+
// is already a safe conflict and must never be overwritten.
|
|
350
|
+
const preliminary = collectRemoteSkillUpdatePlan(name, undefined, homeDir);
|
|
351
|
+
if (preliminary.action === "conflict") {
|
|
352
|
+
plans.push(preliminary);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const repository = fetchRemoteRepository({ url: entry.source }, { branch: entry.requestedRef });
|
|
357
|
+
fetched.set(name, repository);
|
|
358
|
+
plans.push(collectRemoteSkillUpdatePlan(name, repository, homeDir));
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
plans.push({ name, action: "invalid-source", detail: error instanceof Error ? error.message : String(error), source: entry.source, requestedRef: entry.requestedRef, previousCommit: entry.commit, previousDigest: entry.digest, canonicalPath: join(homeDir, ".trellis", "skills", name) });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Multiple requested updates behave as a safe batch: a bad tracked Skill
|
|
365
|
+
// does not leave the rest unexpectedly changed in the same command.
|
|
366
|
+
const blocked = plans.some((plan) => plan.action === "conflict" || plan.action === "not-tracked" || plan.action === "invalid-source");
|
|
367
|
+
const outcome = blocked ? { plans: plans.map(displayUpdatePlan) } : await applyRemoteSkillUpdatePlans(plans, { homeDir, dryRun: opts.dryRun });
|
|
368
|
+
if (opts.json) {
|
|
369
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
if (opts.dryRun)
|
|
373
|
+
console.log("[dry run]");
|
|
374
|
+
for (const plan of plans)
|
|
375
|
+
console.log(`update ${plan.name}: [${plan.action}] ${plan.detail}`);
|
|
376
|
+
if (outcome.sync)
|
|
377
|
+
printSyncReport(outcome.sync, false);
|
|
378
|
+
}
|
|
379
|
+
const syncConflict = outcome.sync?.reports.some((report) => report.items.some((item) => item.action === "conflict")) ?? false;
|
|
380
|
+
return { exitCode: blocked || syncConflict ? 1 : 0 };
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
for (const repository of fetched.values())
|
|
384
|
+
repository.dispose();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** Used by Skill listing to distinguish remote provenance from local content
|
|
388
|
+
* without making a malformed bookkeeping file crash a read-only list. */
|
|
389
|
+
export function remoteSkillProvenance(homeDir) {
|
|
390
|
+
const lock = readRemoteSkillLock(homeDir);
|
|
391
|
+
return lock.ok ? lock.lock.skills : emptyRemoteSkillLock().skills;
|
|
392
|
+
}
|