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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Remote Skill source handling. The public command accepts only GitHub
3
+ * identities, while the lower-level fetch function intentionally accepts a
4
+ * repository URL so unit and sandbox tests can use a local bare Git remote.
5
+ * No command in this module evaluates checked-out content.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { basename, join, relative, resolve, sep } from "node:path";
11
+ import { directoryDigest } from "./dirDigest.js";
12
+ function git(args, cwd) {
13
+ try {
14
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
15
+ }
16
+ catch (error) {
17
+ const detail = error instanceof Error ? error.message : String(error);
18
+ throw new Error(`Git could not read the remote Skill source: ${detail}`);
19
+ }
20
+ }
21
+ /** Accept the exact two source syntaxes Trellis documents, with no URL state
22
+ * that could put a credential in the provenance lock. */
23
+ export function normalizeGitHubRepository(source) {
24
+ const shorthand = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})?)\/([A-Za-z0-9_.-]+)$/.exec(source);
25
+ if (shorthand) {
26
+ const [, owner, repository] = shorthand;
27
+ return { source, owner: owner, repository: repository, url: `https://github.com/${owner}/${repository}.git` };
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(source);
32
+ }
33
+ catch {
34
+ throw new Error(`Remote Skill source must be GitHub owner/repository or an HTTPS github.com URL: ${source}`);
35
+ }
36
+ const parts = parsed.pathname.split("/").filter(Boolean);
37
+ if (parsed.protocol !== "https:"
38
+ || parsed.hostname.toLowerCase() !== "github.com"
39
+ || parsed.username
40
+ || parsed.password
41
+ || parsed.search
42
+ || parsed.hash
43
+ || parts.length !== 2
44
+ || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})?$/.test(parts[0])
45
+ || !/^[A-Za-z0-9_.-]+(?:\.git)?$/.test(parts[1])) {
46
+ throw new Error(`Remote Skill source must be a credential-free HTTPS github.com repository URL: ${source}`);
47
+ }
48
+ const owner = parts[0];
49
+ const repository = parts[1].replace(/\.git$/i, "");
50
+ return { source, owner, repository, url: `https://github.com/${owner}/${repository}.git` };
51
+ }
52
+ function parseLsRemote(output) {
53
+ return output.split("\n").flatMap((line) => {
54
+ const match = /^([0-9a-f]{40})\t(.+)$/.exec(line.trim());
55
+ return match ? [{ hash: match[1], ref: match[2] }] : [];
56
+ });
57
+ }
58
+ function resolveDefaultRef(url) {
59
+ const output = git(["ls-remote", "--symref", url, "HEAD"]);
60
+ const branch = /^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/m.exec(output)?.[1];
61
+ const commit = parseLsRemote(output).find((entry) => entry.ref === "HEAD")?.hash;
62
+ if (!branch || !commit)
63
+ throw new Error(`Could not resolve the default branch for ${url}`);
64
+ return { requestedRef: branch, commit };
65
+ }
66
+ function resolveRequestedRef(url, requestedRef) {
67
+ if (!requestedRef || /[\0\r\n]/.test(requestedRef))
68
+ throw new Error("--branch must be a non-empty Git ref");
69
+ const entries = parseLsRemote(git(["ls-remote", url, requestedRef]));
70
+ const peeled = entries.find((entry) => entry.ref.endsWith("^{}"));
71
+ const direct = entries.find((entry) => !entry.ref.endsWith("^{}"));
72
+ const selected = peeled ?? direct;
73
+ if (!selected)
74
+ throw new Error(`Remote source has no ref named ${requestedRef}`);
75
+ return { requestedRef, commit: selected.hash };
76
+ }
77
+ function isInside(root, target) {
78
+ const rel = relative(root, target);
79
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith("..\/"));
80
+ }
81
+ function walkDirectories(root) {
82
+ const dirs = [];
83
+ const walk = (current) => {
84
+ dirs.push(current);
85
+ for (const name of readdirSync(current).sort((a, b) => a.localeCompare(b))) {
86
+ // The Git administration directory is implementation state, never
87
+ // source content to inspect for a Skill candidate.
88
+ if (current === root && name === ".git")
89
+ continue;
90
+ const entry = join(current, name);
91
+ const stat = lstatSync(entry);
92
+ if (stat.isDirectory())
93
+ walk(entry);
94
+ }
95
+ };
96
+ walk(root);
97
+ return dirs;
98
+ }
99
+ function candidatesIn(root) {
100
+ return walkDirectories(root).flatMap((directory) => {
101
+ const names = readdirSync(directory);
102
+ const exact = names.find((name) => name === "SKILL.md");
103
+ if (!exact)
104
+ return [];
105
+ const entry = join(directory, exact);
106
+ // A non-file `SKILL.md` is deliberately not advertised as a valid
107
+ // candidate; selecting this directory produces a precise error below.
108
+ if (!lstatSync(entry).isFile())
109
+ return [];
110
+ return [{ name: basename(directory), directory, subdirectory: relative(root, directory).split(sep).join("/") }];
111
+ });
112
+ }
113
+ function validateSelectedDirectory(root, directory, expectedName) {
114
+ if (basename(directory) !== expectedName)
115
+ throw new Error(`Selected remote Skill directory is not named ${expectedName}`);
116
+ const rootReal = realpathSync(root);
117
+ const walk = (current) => {
118
+ const stat = lstatSync(current);
119
+ if (stat.isSymbolicLink())
120
+ throw new Error(`Remote Skill ${expectedName} contains a symbolic link: ${current}`);
121
+ const resolved = realpathSync(current);
122
+ if (!isInside(rootReal, resolved))
123
+ throw new Error(`Remote Skill ${expectedName} resolves outside the fetched repository`);
124
+ if (!stat.isDirectory())
125
+ return;
126
+ for (const name of readdirSync(current))
127
+ walk(join(current, name));
128
+ };
129
+ const entry = join(directory, "SKILL.md");
130
+ const entryStat = lstatSync(entry);
131
+ if (entryStat.isSymbolicLink()) {
132
+ const resolvedEntry = realpathSync(entry);
133
+ if (!isInside(rootReal, resolvedEntry))
134
+ throw new Error(`Remote Skill ${expectedName} entry file resolves outside the fetched repository`);
135
+ throw new Error(`Remote Skill ${expectedName} entry file must not be a symbolic link`);
136
+ }
137
+ if (!entryStat.isFile())
138
+ throw new Error(`Remote Skill ${expectedName} has no regular, exact-case SKILL.md`);
139
+ walk(directory);
140
+ return {
141
+ name: expectedName,
142
+ directory,
143
+ subdirectory: relative(root, directory).split(sep).join("/"),
144
+ digest: directoryDigest(directory),
145
+ };
146
+ }
147
+ /**
148
+ * Fetches a single immutable commit into a system temporary directory. It is
149
+ * exported with a URL-shaped repository argument to let tests exercise the
150
+ * real Git protocol against a local bare repo; CLI callers first use
151
+ * normalizeGitHubRepository and therefore cannot pass arbitrary hosts.
152
+ */
153
+ export function fetchRemoteRepository(repository, opts = {}) {
154
+ const resolvedRef = opts.branch === undefined ? resolveDefaultRef(repository.url) : resolveRequestedRef(repository.url, opts.branch);
155
+ const temp = mkdtempSync(join(tmpdir(), "trellis-remote-skill-"));
156
+ let disposed = false;
157
+ try {
158
+ git(["init", "--quiet"], temp);
159
+ git(["remote", "add", "origin", repository.url], temp);
160
+ git(["fetch", "--quiet", "--depth=1", "origin", resolvedRef.commit], temp);
161
+ git(["checkout", "--quiet", "--detach", "FETCH_HEAD"], temp);
162
+ const actualCommit = git(["rev-parse", "HEAD"], temp).trim();
163
+ if (!/^[0-9a-f]{40}$/.test(actualCommit))
164
+ throw new Error("Git did not produce an immutable commit identifier");
165
+ const root = resolve(temp);
166
+ const candidates = candidatesIn(root);
167
+ return {
168
+ repository: { url: repository.url },
169
+ requestedRef: resolvedRef.requestedRef,
170
+ commit: actualCommit,
171
+ root,
172
+ candidates,
173
+ select(name) {
174
+ const matchingDirectories = walkDirectories(root).filter((directory) => basename(directory) === name);
175
+ if (matchingDirectories.length === 0) {
176
+ const available = candidates.map((candidate) => candidate.name).sort().join(", ") || "none";
177
+ throw new Error(`Remote source has no Skill named ${name}. Available Skills: ${available}`);
178
+ }
179
+ const withEntry = matchingDirectories.filter((directory) => {
180
+ try {
181
+ return readdirSync(directory).includes("SKILL.md");
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ });
187
+ if (withEntry.length !== 1) {
188
+ if (withEntry.length === 0)
189
+ throw new Error(`Remote Skill ${name} has no regular, exact-case SKILL.md`);
190
+ throw new Error(`Remote source contains multiple Skills named ${name}; select a repository with one matching Skill`);
191
+ }
192
+ return validateSelectedDirectory(root, withEntry[0], name);
193
+ },
194
+ dispose() {
195
+ if (disposed)
196
+ return;
197
+ disposed = true;
198
+ rmSync(temp, { recursive: true, force: true });
199
+ },
200
+ };
201
+ }
202
+ catch (error) {
203
+ rmSync(temp, { recursive: true, force: true });
204
+ throw error;
205
+ }
206
+ }
@@ -614,6 +614,74 @@ trellis skill update-builtin
614
614
  The update is backed up and updates the canonical file in place; native Agent
615
615
  symlinks and Runtime providers then consume the same refreshed content.
616
616
 
617
+ ### Importing a GitHub Skill with Trellis ownership
618
+
619
+ If you would normally run `npx skills add`, use Trellis's top-level `add`
620
+ command instead. It is a remote-source importer, while `trellis skill add`
621
+ remains the command for a local directory:
622
+
623
+ ```sh
624
+ # Review exact-case SKILL.md candidates before selecting one.
625
+ npx trellis add mattpocock/skills --list
626
+
627
+ # Fetch and inspect the exact plan without writing canonical or Agent files.
628
+ npx trellis add mattpocock/skills --skill loop-me --dry-run
629
+
630
+ # Import to the user-level canonical store, then sync to every managed Agent.
631
+ npx trellis add mattpocock/skills --skill loop-me --yes
632
+ ```
633
+
634
+ The source may be `owner/repository` or a credential-free HTTPS
635
+ `github.com` repository URL. Trellis fetches the selected ref into a system
636
+ temporary directory, discovers a directory containing an exact-case
637
+ `SKILL.md`, and copies that directory into
638
+ `~/.trellis/skills/<name>/`. It does not run repository scripts, install
639
+ dependencies, or write into `.agents/skills`, `~/.agents/skills`, or any
640
+ Agent-specific Skill directory during import. Review the imported
641
+ `SKILL.md` before relying on it in an Agent session.
642
+
643
+ By default the imported Skill is unscoped, which means every Agent currently
644
+ listed in `~/.trellis/managed.yaml` receives it on sync. Restrict delivery to
645
+ managed Agents with `--agent`; comma-separated values and repeated flags are
646
+ accepted. `--agent '*'` writes the current managed set as an explicit scope.
647
+ An Agent that is installed but unmanaged is refused before any canonical or
648
+ native write. Add it to Trellis's managed boundary first:
649
+
650
+ ```sh
651
+ trellis manage add zcode
652
+ npx trellis add mattpocock/skills --skill loop-me --agent zcode --yes
653
+ ```
654
+
655
+ `--branch <ref>` selects a Git branch, tag, or ref. Without it, Trellis asks
656
+ Git for the repository's default branch. `--global` is accepted for
657
+ `skills`-CLI compatibility and still means Trellis's existing user-level
658
+ canonical store; `--copy` and project-local modes are refused because they
659
+ would create another Skill source outside Trellis.
660
+
661
+ Each successful remote import writes
662
+ `~/.trellis/skills.lock.json` in the same backup session as its canonical
663
+ Skill directory. The lock records the normalized GitHub URL, requested ref,
664
+ resolved immutable commit, source subdirectory, and deterministic content
665
+ digest. It never records credentials or a temporary path. `trellis skill
666
+ list` marks remote Skills with their source and short locked commit; JSON
667
+ output includes the complete credential-free provenance record.
668
+
669
+ Refresh tracked Skills with:
670
+
671
+ ```sh
672
+ trellis update loop-me --dry-run
673
+ trellis update loop-me
674
+ trellis update # check every tracked remote Skill
675
+ ```
676
+
677
+ Before replacement, `trellis update` compares the current canonical directory
678
+ to its locked digest. A local canonical edit is a conflict and is never
679
+ overwritten. A successful update preserves the Skill's scope, replaces its
680
+ directory and provenance record in one backup session, and then runs Skill
681
+ sync. Use `trellis rollback --list` to find a `remote-skill-add` or
682
+ `remote-skill-update` run; rolling it back restores the previous directory
683
+ and matching lock record when neither has changed since the run.
684
+
617
685
  ```
618
686
  $ trellis mcp list
619
687
  tanka (http) — codex, pi
@@ -1012,9 +1080,10 @@ scripts/agent-sandbox.sh --status kiro
1012
1080
  Git,也不会自动读取或删除宿主机凭据。清理时请明确执行
1013
1081
  `docker volume rm <volume-name>`。
1014
1082
 
1015
- ## `trellis rollback` — undoing a `sync`/`mcp sync`/`onboard` run
1083
+ ## `trellis rollback` — undoing a managed write run
1016
1084
 
1017
- Every real write those three commands perform is recorded, before it
1085
+ Every real `sync`, `mcp sync`, `onboard`, remote Skill import, and remote Skill
1086
+ update write is recorded, before it
1018
1087
  happens, to a structured run directory under `~/.trellis/backups/` — no
1019
1088
  flag needed, this is always on for any run that actually writes
1020
1089
  something. `--dry-run` never creates one, since nothing was written.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",