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
|
@@ -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
|
+
}
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
1083
|
+
## `trellis rollback` — undoing a managed write run
|
|
1016
1084
|
|
|
1017
|
-
Every real
|
|
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.
|
|
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",
|