agent-trellis 0.8.2 → 0.9.1

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.
Files changed (178) hide show
  1. package/README.md +3 -0
  2. package/dist/cli.js +94 -1
  3. package/dist/commands/remoteSkill.d.ts +90 -0
  4. package/dist/commands/remoteSkill.js +392 -0
  5. package/dist/commands/rollback.js +36 -5
  6. package/dist/commands/skill.d.ts +2 -0
  7. package/dist/commands/skill.js +6 -2
  8. package/dist/core/canonical.d.ts +17 -1
  9. package/dist/core/canonical.js +54 -0
  10. package/dist/lib/backup.d.ts +17 -5
  11. package/dist/lib/backup.js +22 -4
  12. package/dist/lib/dirDigest.d.ts +11 -0
  13. package/dist/lib/dirDigest.js +52 -0
  14. package/dist/lib/remoteSkillLock.d.ts +26 -0
  15. package/dist/lib/remoteSkillLock.js +88 -0
  16. package/dist/lib/remoteSkillSource.d.ts +45 -0
  17. package/dist/lib/remoteSkillSource.js +206 -0
  18. package/docs/getting-started.md +71 -2
  19. package/package.json +4 -2
  20. package/docs/video/README.md +0 -76
  21. package/docs/video/build/audio/01-opening.aiff +0 -0
  22. package/docs/video/build/audio/01-opening.mp3 +0 -0
  23. package/docs/video/build/audio/02-pain.aiff +0 -0
  24. package/docs/video/build/audio/02-pain.mp3 +0 -0
  25. package/docs/video/build/audio/03-solution.aiff +0 -0
  26. package/docs/video/build/audio/03-solution.mp3 +0 -0
  27. package/docs/video/build/audio/04-install.aiff +0 -0
  28. package/docs/video/build/audio/04-install.mp3 +0 -0
  29. package/docs/video/build/audio/04-setup.mp3 +0 -0
  30. package/docs/video/build/audio/05-dryrun.mp3 +0 -0
  31. package/docs/video/build/audio/05-init.aiff +0 -0
  32. package/docs/video/build/audio/05-init.mp3 +0 -0
  33. package/docs/video/build/audio/05-interactive.mp3 +0 -0
  34. package/docs/video/build/audio/06-dryrun.aiff +0 -0
  35. package/docs/video/build/audio/06-dryrun.mp3 +0 -0
  36. package/docs/video/build/audio/06-onboard.mp3 +0 -0
  37. package/docs/video/build/audio/06-run.mp3 +0 -0
  38. package/docs/video/build/audio/07-onboard.aiff +0 -0
  39. package/docs/video/build/audio/07-onboard.mp3 +0 -0
  40. package/docs/video/build/audio/07-verify.mp3 +0 -0
  41. package/docs/video/build/audio/08-closing.mp3 +0 -0
  42. package/docs/video/build/audio/08-doctor.aiff +0 -0
  43. package/docs/video/build/audio/08-doctor.mp3 +0 -0
  44. package/docs/video/build/audio/09-rollback.aiff +0 -0
  45. package/docs/video/build/audio/09-rollback.mp3 +0 -0
  46. package/docs/video/build/audio/10-principles.aiff +0 -0
  47. package/docs/video/build/audio/10-principles.mp3 +0 -0
  48. package/docs/video/build/audio/11-closing.aiff +0 -0
  49. package/docs/video/build/audio/11-closing.mp3 +0 -0
  50. package/docs/video/build/frames/01-opening-0.png +0 -0
  51. package/docs/video/build/frames/02-pain-1.png +0 -0
  52. package/docs/video/build/frames/02-pain-2.png +0 -0
  53. package/docs/video/build/frames/03-solution-3.png +0 -0
  54. package/docs/video/build/frames/04-install-4.png +0 -0
  55. package/docs/video/build/frames/04-setup-4.png +0 -0
  56. package/docs/video/build/frames/04-setup-5.png +0 -0
  57. package/docs/video/build/frames/05-dryrun-6.png +0 -0
  58. package/docs/video/build/frames/05-init-5.png +0 -0
  59. package/docs/video/build/frames/05-interactive-5.png +0 -0
  60. package/docs/video/build/frames/05-interactive-6.png +0 -0
  61. package/docs/video/build/frames/06-dryrun-6.png +0 -0
  62. package/docs/video/build/frames/06-dryrun-7.png +0 -0
  63. package/docs/video/build/frames/06-onboard-7.png +0 -0
  64. package/docs/video/build/frames/06-onboard-8.png +0 -0
  65. package/docs/video/build/frames/06-run-7.png +0 -0
  66. package/docs/video/build/frames/06-run-8.png +0 -0
  67. package/docs/video/build/frames/07-onboard-10.png +0 -0
  68. package/docs/video/build/frames/07-onboard-7.png +0 -0
  69. package/docs/video/build/frames/07-onboard-8.png +0 -0
  70. package/docs/video/build/frames/07-onboard-9.png +0 -0
  71. package/docs/video/build/frames/07-verify-10.png +0 -0
  72. package/docs/video/build/frames/07-verify-8.png +0 -0
  73. package/docs/video/build/frames/07-verify-9.png +0 -0
  74. package/docs/video/build/frames/08-closing-10.png +0 -0
  75. package/docs/video/build/frames/08-closing-11.png +0 -0
  76. package/docs/video/build/frames/08-doctor-11.png +0 -0
  77. package/docs/video/build/frames/08-doctor-9.png +0 -0
  78. package/docs/video/build/frames/09-rollback-10.png +0 -0
  79. package/docs/video/build/frames/09-rollback-12.png +0 -0
  80. package/docs/video/build/frames/10-principles-11.png +0 -0
  81. package/docs/video/build/frames/10-principles-13.png +0 -0
  82. package/docs/video/build/frames/11-closing-12.png +0 -0
  83. package/docs/video/build/frames/11-closing-14.png +0 -0
  84. package/docs/video/build/segments/01-opening.mp4 +0 -0
  85. package/docs/video/build/segments/01-opening.txt +0 -3
  86. package/docs/video/build/segments/02-pain.mp4 +0 -0
  87. package/docs/video/build/segments/02-pain.txt +0 -5
  88. package/docs/video/build/segments/03-solution.mp4 +0 -0
  89. package/docs/video/build/segments/03-solution.txt +0 -3
  90. package/docs/video/build/segments/04-install.mp4 +0 -0
  91. package/docs/video/build/segments/04-install.txt +0 -3
  92. package/docs/video/build/segments/04-setup.mp4 +0 -0
  93. package/docs/video/build/segments/04-setup.txt +0 -3
  94. package/docs/video/build/segments/05-dryrun.mp4 +0 -0
  95. package/docs/video/build/segments/05-dryrun.txt +0 -3
  96. package/docs/video/build/segments/05-init.mp4 +0 -0
  97. package/docs/video/build/segments/05-init.txt +0 -3
  98. package/docs/video/build/segments/05-interactive.mp4 +0 -0
  99. package/docs/video/build/segments/05-interactive.txt +0 -5
  100. package/docs/video/build/segments/06-dryrun.mp4 +0 -0
  101. package/docs/video/build/segments/06-dryrun.txt +0 -3
  102. package/docs/video/build/segments/06-onboard.mp4 +0 -0
  103. package/docs/video/build/segments/06-onboard.txt +0 -5
  104. package/docs/video/build/segments/06-run.mp4 +0 -0
  105. package/docs/video/build/segments/06-run.txt +0 -3
  106. package/docs/video/build/segments/07-onboard.mp4 +0 -0
  107. package/docs/video/build/segments/07-onboard.txt +0 -5
  108. package/docs/video/build/segments/07-verify.mp4 +0 -0
  109. package/docs/video/build/segments/07-verify.txt +0 -5
  110. package/docs/video/build/segments/08-closing.mp4 +0 -0
  111. package/docs/video/build/segments/08-closing.txt +0 -3
  112. package/docs/video/build/segments/08-doctor.mp4 +0 -0
  113. package/docs/video/build/segments/08-doctor.txt +0 -3
  114. package/docs/video/build/segments/09-rollback.mp4 +0 -0
  115. package/docs/video/build/segments/09-rollback.txt +0 -3
  116. package/docs/video/build/segments/10-principles.mp4 +0 -0
  117. package/docs/video/build/segments/10-principles.txt +0 -3
  118. package/docs/video/build/segments/11-closing.mp4 +0 -0
  119. package/docs/video/build/segments/11-closing.txt +0 -3
  120. package/docs/video/build/segments/final.txt +0 -8
  121. package/docs/video/build-ai/audio/01-hook.mp3 +0 -0
  122. package/docs/video/build-ai/audio/02-pain.mp3 +0 -0
  123. package/docs/video/build-ai/audio/03-solution.mp3 +0 -0
  124. package/docs/video/build-ai/audio/04-how.mp3 +0 -0
  125. package/docs/video/build-ai/audio/05-safe.mp3 +0 -0
  126. package/docs/video/build-ai/audio/06-closing.mp3 +0 -0
  127. package/docs/video/build-ai/clips/01-hook.mp4 +0 -0
  128. package/docs/video/build-ai/clips/02-pain.mp4 +0 -0
  129. package/docs/video/build-ai/clips/03-solution.mp4 +0 -0
  130. package/docs/video/build-ai/clips/04-how.mp4 +0 -0
  131. package/docs/video/build-ai/clips/05-safe.mp4 +0 -0
  132. package/docs/video/build-ai/clips/06-closing.mp4 +0 -0
  133. package/docs/video/build-ai/first-frame-02-pain.png +0 -0
  134. package/docs/video/build-ai/first-frame-03-solution.png +0 -0
  135. package/docs/video/build-ai/first-frame-04-how.png +0 -0
  136. package/docs/video/build-ai/first-frame-05-safe.png +0 -0
  137. package/docs/video/build-ai/segments/01-hook.caption.txt +0 -2
  138. package/docs/video/build-ai/segments/01-hook.mp4 +0 -0
  139. package/docs/video/build-ai/segments/02-pain.mp4 +0 -0
  140. package/docs/video/build-ai/segments/03-solution.mp4 +0 -0
  141. package/docs/video/build-ai/segments/04-how.mp4 +0 -0
  142. package/docs/video/build-ai/segments/05-safe.mp4 +0 -0
  143. package/docs/video/build-ai/segments/06-closing.mp4 +0 -0
  144. package/docs/video/build-ai/segments/cap-01-hook.png +0 -0
  145. package/docs/video/build-ai/segments/cap-02-pain.png +0 -0
  146. package/docs/video/build-ai/segments/cap-03-solution.png +0 -0
  147. package/docs/video/build-ai/segments/cap-04-how.png +0 -0
  148. package/docs/video/build-ai/segments/cap-05-safe.png +0 -0
  149. package/docs/video/build-ai/segments/cap-06-closing.png +0 -0
  150. package/docs/video/build-ai/segments/final.txt +0 -6
  151. package/docs/video/build-posters/audio/01-hook.mp3 +0 -0
  152. package/docs/video/build-posters/audio/02-pain.mp3 +0 -0
  153. package/docs/video/build-posters/audio/03-solution.mp3 +0 -0
  154. package/docs/video/build-posters/audio/04-how.mp3 +0 -0
  155. package/docs/video/build-posters/audio/05-safe.mp3 +0 -0
  156. package/docs/video/build-posters/audio/06-closing.mp3 +0 -0
  157. package/docs/video/build-posters/cap-01-hook.png +0 -0
  158. package/docs/video/build-posters/cap-02-pain.png +0 -0
  159. package/docs/video/build-posters/cap-03-solution.png +0 -0
  160. package/docs/video/build-posters/cap-04-how.png +0 -0
  161. package/docs/video/build-posters/cap-05-safe.png +0 -0
  162. package/docs/video/build-posters/cap-06-closing.png +0 -0
  163. package/docs/video/build-posters/card-01-hook.png +0 -0
  164. package/docs/video/build-posters/card-06-closing.png +0 -0
  165. package/docs/video/build-posters/segments/01-hook.mp4 +0 -0
  166. package/docs/video/build-posters/segments/02-pain.mp4 +0 -0
  167. package/docs/video/build-posters/segments/03-solution.mp4 +0 -0
  168. package/docs/video/build-posters/segments/04-how.mp4 +0 -0
  169. package/docs/video/build-posters/segments/05-safe.mp4 +0 -0
  170. package/docs/video/build-posters/segments/06-closing.mp4 +0 -0
  171. package/docs/video/build-posters/segments/final.txt +0 -6
  172. package/docs/video/generate-video-ai.mjs +0 -334
  173. package/docs/video/generate-video-posters.mjs +0 -204
  174. package/docs/video/generate-video.mjs +0 -426
  175. package/docs/video/outline.zh-CN.md +0 -102
  176. package/docs/video/trellis-demo-video-ai.mp4 +0 -0
  177. package/docs/video/trellis-demo-video.mp4 +0 -0
  178. package/docs/video/voiceover.zh-CN.md +0 -49
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
+ }