@daldindev/agentic-skills 0.1.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/NOTICE.md +11 -0
- package/README.md +143 -0
- package/package.json +56 -0
- package/src/cli.mjs +166 -0
- package/src/fs-utils.mjs +76 -0
- package/src/install.mjs +200 -0
- package/src/tar.mjs +91 -0
- package/src/upstream.mjs +249 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `agentic-skills` CLI with `init`, `update`, and `status`.
|
|
14
|
+
- `init` and `update` download the current [ag-kit](https://github.com/vudovn/ag-kit) archive from GitHub and install its agent roles, skills, and workflows, plus the upstream license.
|
|
15
|
+
- `ARCHITECTURE.md` installed with only the sections that describe the installed content, falling back to the verbatim file with a warning if upstream renames a section.
|
|
16
|
+
- Hash manifest in `.agentic-skills/manifest.json` recording the upstream commit and version, so updates preserve locally modified files and never touch user-created ones.
|
|
17
|
+
- `--ref` to install a specific upstream branch, tag, or commit, and `--archive` to install from a local tarball or URL.
|
|
18
|
+
- `--path`, `--dir`, `--force`, `--dry-run`, and `--json` options.
|
|
19
|
+
- Archives are read as untrusted input: entries that would escape the target directory are rejected, downloads time out, and decompression is capped.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 daldindev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Notice
|
|
2
|
+
|
|
3
|
+
`agentic-skills` is an installer. It contains no content of its own.
|
|
4
|
+
|
|
5
|
+
When you run it, it downloads the current source archive of the **ag-kit** project (<https://github.com/vudovn/ag-kit>) by **VUDOVN**, licensed under the MIT License, and writes that project's agent roles, skills, and workflows into your project. The upstream license file is written next to them as `LICENSE`, so every installed copy carries the original copyright notice.
|
|
6
|
+
|
|
7
|
+
Everything installed is upstream work, copied byte for byte, with one exception: `ARCHITECTURE.md` is assembled from the upstream inventory by keeping whole sections and dropping the ones about tooling that this installer does not set up. The sections that remain are upstream text, unchanged.
|
|
8
|
+
|
|
9
|
+
This project is independent. It is not affiliated with, endorsed by, or maintained by the ag-kit project or by the IDE runtime that project targets. Problems with the content belong upstream; problems with the installer belong here.
|
|
10
|
+
|
|
11
|
+
The installer itself, this repository, and its documentation are © 2026 daldindev, released under the MIT License in [LICENSE](LICENSE).
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# agentic-skills
|
|
2
|
+
|
|
3
|
+
Install the [ag-kit](https://github.com/vudovn/ag-kit) **agent roles, skills, and workflows** into any project, for any model and any agent harness.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
ag-kit is a large, well-organized body of agent instructions: 20 specialist roles, 47 skills, 13 workflows. Upstream delivers it as a workspace for one IDE runtime, so using any of it means adopting that runtime and the contract it comes with.
|
|
8
|
+
|
|
9
|
+
This installer removes that condition. One command downloads the current ag-kit and writes only the instructions into your project, as plain Markdown that whatever assistant you already use can read:
|
|
10
|
+
|
|
11
|
+
- The upstream roles, skills, and workflows, **verbatim** and always current, because every install reads upstream directly.
|
|
12
|
+
- One command to install, one to update, with local edits **preserved**.
|
|
13
|
+
- The exact upstream commit recorded with every install.
|
|
14
|
+
- **No runtime dependencies**, and no `git` needed on your machine.
|
|
15
|
+
|
|
16
|
+
Everything else upstream ships sits around that content to bind it to that one runtime: hooks, an MCP configuration, the memory and rule loading, schemas, manifests, and validators. A portable install has no use for wiring, so none of it is installed.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @daldindev/agentic-skills init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or add it to the project so the installer version is pinned in `package.json`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i -D @daldindev/agentic-skills
|
|
28
|
+
npx agentic-skills init
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requirements:
|
|
32
|
+
|
|
33
|
+
- Node.js `>=20.11`
|
|
34
|
+
- Network access to `codeload.github.com`, where GitHub serves source archives, when you run `init` or `update`
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
`init` creates `.agents/` in the current project:
|
|
39
|
+
|
|
40
|
+
```plaintext
|
|
41
|
+
.agents/
|
|
42
|
+
├── ARCHITECTURE.md # Inventory of every agent, skill, and workflow
|
|
43
|
+
├── LICENSE # Upstream MIT license
|
|
44
|
+
├── agent/ # 20 role definitions
|
|
45
|
+
├── skills/ # 47 skills, each a folder with a SKILL.md
|
|
46
|
+
├── workflows/ # 13 slash-command procedures
|
|
47
|
+
└── .agentic-skills/manifest.json # File hashes and the upstream commit, so updates can protect your edits
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Point your assistant at `.agents/ARCHITECTURE.md`. It lists every component with a one-line purpose, so the assistant can open only what a task needs.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| Command | Purpose |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `agentic-skills init` | Download ag-kit and install the content. Refuses a non-empty target unless `--force` |
|
|
57
|
+
| `agentic-skills update` | Download ag-kit again and update installed files, keeping the ones you edited |
|
|
58
|
+
| `agentic-skills status` | Show what is installed, from which upstream commit, and which files were changed locally |
|
|
59
|
+
|
|
60
|
+
| Option | Purpose |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `-p, --path <dir>` | Project directory, default is the current directory |
|
|
63
|
+
| `-d, --dir <name>` | Install directory inside the project, default `.agents` |
|
|
64
|
+
| `-r, --ref <git-ref>` | Upstream branch, tag, or commit to install, default `main` |
|
|
65
|
+
| `--archive <source>` | Install from a local ag-kit `.tar.gz` or a URL instead of GitHub |
|
|
66
|
+
| `-f, --force` | Overwrite files that were modified locally |
|
|
67
|
+
| `--dry-run` | Download and print the plan without writing anything |
|
|
68
|
+
| `--json` | Print machine-readable output |
|
|
69
|
+
|
|
70
|
+
Exit code `2` means the command succeeded but skipped files you had modified. They are listed in the output.
|
|
71
|
+
|
|
72
|
+
## Updating
|
|
73
|
+
|
|
74
|
+
There is nothing to bump. `update` downloads the current upstream and applies it:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx @daldindev/agentic-skills update
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For each file it compares what is installed, what upstream now has, and the hash recorded at the last run.
|
|
81
|
+
|
|
82
|
+
| Situation | Result |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| You never touched the file | Updated, or deleted when upstream dropped it |
|
|
85
|
+
| You edited the file | Skipped and listed, unless `--force` |
|
|
86
|
+
| You created the file yourself | Never touched |
|
|
87
|
+
| Upstream added a file | Installed |
|
|
88
|
+
|
|
89
|
+
To stay on a known upstream state instead of `main`, pass a tag or commit. The same flag reproduces an earlier install exactly:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx @daldindev/agentic-skills update --ref v2026.8.31
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What gets installed
|
|
96
|
+
|
|
97
|
+
| Upstream path | Installed as |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `.agents/agent/` | `agent/` |
|
|
100
|
+
| `.agents/skills/` | `skills/` |
|
|
101
|
+
| `.agents/workflows/` | `workflows/` |
|
|
102
|
+
| `.agents/ARCHITECTURE.md` | `ARCHITECTURE.md`, see below |
|
|
103
|
+
| `LICENSE` | `LICENSE` |
|
|
104
|
+
|
|
105
|
+
Files are copied byte for byte from the upstream archive at the moment you run the command. Nothing is rewritten, summarized, or adapted, and the commit they came from goes into the manifest.
|
|
106
|
+
|
|
107
|
+
`ARCHITECTURE.md` is the one exception. Upstream's inventory also documents its own runtime tooling, so the installer keeps the sections that describe the installed content (**Agents**, **Skills**, **Workflows**, **Skill Loading Protocol**, **Quick Reference**) and drops the rest. Whole sections only, never edited lines. If upstream ever renames one of those sections, the file is installed verbatim and a warning is printed, so an upstream change can never block an install.
|
|
108
|
+
|
|
109
|
+
Some installed files still mention the upstream runtime or its folders in passing. Ignore those references, or point them at your own equivalent.
|
|
110
|
+
|
|
111
|
+
The archive is treated as untrusted input, and nothing in it is ever executed. See [`SECURITY.md`](./SECURITY.md) for what that means in practice.
|
|
112
|
+
|
|
113
|
+
## Using the Content
|
|
114
|
+
|
|
115
|
+
The content is deliberately tool-neutral:
|
|
116
|
+
|
|
117
|
+
- **Agents** are Markdown files with YAML frontmatter (`name`, `description`, `tools`, `skills`). Use them as system prompts or subagent definitions.
|
|
118
|
+
- **Skills** are folders whose `SKILL.md` frontmatter includes `when_to_use`, so an assistant can decide when to load one. Some ship Python helper scripts.
|
|
119
|
+
- **Workflows** are slash-command procedures, where `$ARGUMENTS` stands for the text after the command.
|
|
120
|
+
|
|
121
|
+
Keep the install at `.agents/` when you can, because paths inside the content are written relative to that folder. If your tool expects its own layout, copy or link the pieces it needs and still point it at `ARCHITECTURE.md` as the entry point.
|
|
122
|
+
|
|
123
|
+
## Scope and Non-Goals
|
|
124
|
+
|
|
125
|
+
In scope:
|
|
126
|
+
|
|
127
|
+
- Downloading the upstream roles, skills, and workflows and installing them anywhere
|
|
128
|
+
- Updating an install while preserving local edits
|
|
129
|
+
- Recording upstream provenance with every install
|
|
130
|
+
|
|
131
|
+
Out of scope:
|
|
132
|
+
|
|
133
|
+
- Authoring, editing, or hosting the content, which lives upstream and only upstream
|
|
134
|
+
- Rebuilding upstream's runtime wiring, which a portable install does not need
|
|
135
|
+
- Any dependency on a specific IDE, model, or agent runtime
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
The installer is small on purpose. Issues and pull requests are welcome for the installer itself; problems with an agent, skill, or workflow belong upstream in ag-kit. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for local checks and release validation.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT. The installed content is © VUDOVN under the MIT License and carries its own `LICENSE` file. See [`NOTICE.md`](./NOTICE.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@daldindev/agentic-skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install the ag-kit agent roles, skills, and workflows into any project, for any model and any agent harness.",
|
|
5
|
+
"author": "daldindev",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20.11.0"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"agentic-skills": "src/cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"NOTICE.md",
|
|
19
|
+
"README.md",
|
|
20
|
+
"package.json"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ai",
|
|
24
|
+
"agents",
|
|
25
|
+
"agent-skills",
|
|
26
|
+
"skills",
|
|
27
|
+
"workflows",
|
|
28
|
+
"prompts",
|
|
29
|
+
"cli",
|
|
30
|
+
"installer",
|
|
31
|
+
"ag-kit"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/daldindev/agentic-skills.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/daldindev/agentic-skills/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/daldindev/agentic-skills#readme",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"lint": "eslint .",
|
|
47
|
+
"test": "node tests/run.mjs",
|
|
48
|
+
"check": "npm run lint && npm test",
|
|
49
|
+
"pack:check": "npm pack --dry-run --cache .npm-cache",
|
|
50
|
+
"release:check": "npm run check && npm run pack:check",
|
|
51
|
+
"prepublishOnly": "npm run release:check"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"eslint": "^10.9.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { PACKAGE_ROOT, readJson } from "./fs-utils.mjs";
|
|
7
|
+
import { DEFAULT_INSTALL_DIR, init, status, update } from "./install.mjs";
|
|
8
|
+
import { UPSTREAM, extractPort, loadArchive, materialize } from "./upstream.mjs";
|
|
9
|
+
|
|
10
|
+
const HELP = `agentic-skills - install the ag-kit agent roles, skills, and workflows into any project
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
agentic-skills init [options] Download ag-kit and install the content into <path>/<dir>
|
|
14
|
+
agentic-skills update [options] Download ag-kit again and update installed files, preserving local edits
|
|
15
|
+
agentic-skills status [options] Show what is installed and which files were changed locally
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
-p, --path <dir> Project directory (default: current directory)
|
|
19
|
+
-d, --dir <name> Install directory inside the project (default: ${DEFAULT_INSTALL_DIR})
|
|
20
|
+
-r, --ref <git-ref> Upstream branch, tag, or commit to install (default: ${UPSTREAM.ref})
|
|
21
|
+
--archive <source> Install from a local ag-kit .tar.gz or a URL instead of GitHub
|
|
22
|
+
-f, --force Overwrite locally modified files
|
|
23
|
+
--dry-run Download and show the plan without writing anything
|
|
24
|
+
--json Print machine-readable output
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
-v, --version Show the package version
|
|
27
|
+
|
|
28
|
+
Exit codes:
|
|
29
|
+
0 success
|
|
30
|
+
1 error
|
|
31
|
+
2 completed, but some files were skipped because they were modified locally
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const { values, positionals } = parseArgs({
|
|
35
|
+
args: process.argv.slice(2),
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
options: {
|
|
38
|
+
path: { type: "string", short: "p" },
|
|
39
|
+
dir: { type: "string", short: "d" },
|
|
40
|
+
ref: { type: "string", short: "r", default: UPSTREAM.ref },
|
|
41
|
+
archive: { type: "string" },
|
|
42
|
+
force: { type: "boolean", short: "f", default: false },
|
|
43
|
+
"dry-run": { type: "boolean", default: false },
|
|
44
|
+
json: { type: "boolean", default: false },
|
|
45
|
+
help: { type: "boolean", short: "h", default: false },
|
|
46
|
+
version: { type: "boolean", short: "v", default: false },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const command = positionals[0];
|
|
51
|
+
const log = (message) => {
|
|
52
|
+
if (!values.json) console.log(message);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const list = (label, items, format = (item) => item) => {
|
|
56
|
+
if (!items.length) return;
|
|
57
|
+
console.log(`\n${label} (${items.length}):`);
|
|
58
|
+
for (const item of items) console.log(` ${format(item)}`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Download the upstream archive and lay the ported tree out in a temp dir. */
|
|
62
|
+
const fetchIncoming = async () => {
|
|
63
|
+
log(values.archive ? `Reading ${values.archive}` : `Downloading ${UPSTREAM.repository} at ${values.ref}`);
|
|
64
|
+
const buffer = await loadArchive({ ref: values.ref, archive: values.archive });
|
|
65
|
+
const port = extractPort(buffer);
|
|
66
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agentic-skills-"));
|
|
67
|
+
try {
|
|
68
|
+
await materialize(port.files, dir);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
for (const warning of port.warnings) console.error(`warning: ${warning}`);
|
|
74
|
+
return {
|
|
75
|
+
dir,
|
|
76
|
+
upstream: {
|
|
77
|
+
repository: UPSTREAM.repository,
|
|
78
|
+
ref: values.archive ? null : values.ref,
|
|
79
|
+
commit: port.commit,
|
|
80
|
+
version: port.version,
|
|
81
|
+
},
|
|
82
|
+
cleanup: () => fs.rm(dir, { recursive: true, force: true }),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const describeUpstream = (upstream) => {
|
|
87
|
+
if (!upstream) return "unknown upstream";
|
|
88
|
+
const commit = upstream.commit ? ` (${upstream.commit.slice(0, 7)})` : "";
|
|
89
|
+
return `ag-kit ${upstream.version}${commit}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const printPlan = (result) => {
|
|
93
|
+
const { plan } = result;
|
|
94
|
+
const prefix = result.dryRun ? "[dry run] " : "";
|
|
95
|
+
console.log(`${prefix}${result.mode}: ${result.installDir}`);
|
|
96
|
+
console.log(`${prefix}source: ${describeUpstream(result.upstream)}`);
|
|
97
|
+
console.log(
|
|
98
|
+
`${prefix}added ${plan.add.length}, updated ${plan.update.length}, removed ${plan.remove.length}, ` +
|
|
99
|
+
`skipped ${plan.skip.length}, kept ${plan.keep.length}, unchanged ${plan.unchanged.length}`,
|
|
100
|
+
);
|
|
101
|
+
list("Skipped (modified locally; use --force to overwrite)", plan.skip, (item) => `${item.file}: ${item.reason}`);
|
|
102
|
+
list("Kept (removed upstream but modified locally)", plan.keep, (item) => `${item.file}: ${item.reason}`);
|
|
103
|
+
if (result.mode === "update" && result.hadManifest === false) {
|
|
104
|
+
console.log("\nNo manifest was found, so files that differ from upstream were skipped. Re-run with --force to overwrite them.");
|
|
105
|
+
}
|
|
106
|
+
if (result.mode === "init" && !result.dryRun) {
|
|
107
|
+
console.log(`\nPoint your assistant at ${path.join(result.installDir, "ARCHITECTURE.md")} to get the inventory.`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const printStatus = (info) => {
|
|
112
|
+
console.log(`package: ${info.package.packageName} ${info.package.packageVersion}`);
|
|
113
|
+
console.log(`target: ${info.installDir}`);
|
|
114
|
+
if (!info.installed) {
|
|
115
|
+
console.log('status: not installed (run "agentic-skills init")');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!info.manifest) {
|
|
119
|
+
console.log("status: installed without a manifest (not managed by agentic-skills)");
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`installed: ${describeUpstream(info.manifest.upstream)} on ${info.manifest.updatedAt}`);
|
|
122
|
+
}
|
|
123
|
+
const counts = info.counts;
|
|
124
|
+
console.log(`files: ${counts.tracked} tracked, ${counts.modified} modified, ${counts.missing} missing, ${counts.untracked} untracked`);
|
|
125
|
+
console.log('run "agentic-skills update --dry-run" to see what a fresh download would change');
|
|
126
|
+
list("Modified locally", info.modified);
|
|
127
|
+
list("Missing", info.missing);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (values.version) {
|
|
132
|
+
const pkg = await readJson(path.join(PACKAGE_ROOT, "package.json"));
|
|
133
|
+
console.log(pkg.version);
|
|
134
|
+
} else if (values.help || !command || command === "help") {
|
|
135
|
+
process.stdout.write(HELP);
|
|
136
|
+
} else if (command === "init" || command === "update") {
|
|
137
|
+
const incoming = await fetchIncoming();
|
|
138
|
+
try {
|
|
139
|
+
const options = {
|
|
140
|
+
path: values.path,
|
|
141
|
+
dir: values.dir,
|
|
142
|
+
force: values.force,
|
|
143
|
+
dryRun: values["dry-run"],
|
|
144
|
+
incomingDir: incoming.dir,
|
|
145
|
+
upstream: incoming.upstream,
|
|
146
|
+
};
|
|
147
|
+
const result = command === "init" ? await init(options) : await update(options);
|
|
148
|
+
if (values.json) console.log(JSON.stringify(result, null, 2));
|
|
149
|
+
else printPlan(result);
|
|
150
|
+
if (result.plan.skip.length || result.plan.keep.length) process.exitCode = 2;
|
|
151
|
+
} finally {
|
|
152
|
+
await incoming.cleanup();
|
|
153
|
+
}
|
|
154
|
+
} else if (command === "status") {
|
|
155
|
+
const info = await status({ path: values.path, dir: values.dir });
|
|
156
|
+
if (values.json) console.log(JSON.stringify(info, null, 2));
|
|
157
|
+
else printStatus(info);
|
|
158
|
+
} else {
|
|
159
|
+
console.error(`Unknown command: ${command}\n`);
|
|
160
|
+
process.stdout.write(HELP);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error(`Error: ${error.message}`);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
package/src/fs-utils.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_ROOT = path.resolve(currentDir, "..");
|
|
9
|
+
|
|
10
|
+
const IGNORED_NAMES = new Set([".DS_Store", "Thumbs.db", "__pycache__"]);
|
|
11
|
+
const IGNORED_SUFFIXES = [".pyc"];
|
|
12
|
+
|
|
13
|
+
export const toPosix = (value) => value.split(path.sep).join("/");
|
|
14
|
+
|
|
15
|
+
export const isIgnoredName = (name) =>
|
|
16
|
+
IGNORED_NAMES.has(name) || IGNORED_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
17
|
+
|
|
18
|
+
export const exists = async (target) => {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(target);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const readJson = async (file) => JSON.parse(await fs.readFile(file, "utf8"));
|
|
28
|
+
|
|
29
|
+
export const writeJson = async (file, data) => {
|
|
30
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const hashFile = async (file) =>
|
|
35
|
+
crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map of posix-relative file path to sha256 for every file under `root`.
|
|
39
|
+
* `ignorePrefixes` are posix-relative paths to skip entirely.
|
|
40
|
+
*/
|
|
41
|
+
export async function snapshotTree(root, { ignorePrefixes = [] } = {}) {
|
|
42
|
+
const snapshot = {};
|
|
43
|
+
if (!(await exists(root))) return snapshot;
|
|
44
|
+
|
|
45
|
+
const visit = async (dir) => {
|
|
46
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (isIgnoredName(entry.name)) continue;
|
|
50
|
+
const absolute = path.join(dir, entry.name);
|
|
51
|
+
const relative = toPosix(path.relative(root, absolute));
|
|
52
|
+
if (ignorePrefixes.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`))) continue;
|
|
53
|
+
if (entry.isDirectory()) await visit(absolute);
|
|
54
|
+
else if (entry.isFile()) snapshot[relative] = await hashFile(absolute);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
await visit(root);
|
|
59
|
+
return snapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function copyFile(source, destination) {
|
|
63
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
64
|
+
await fs.copyFile(source, destination);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function removeEmptyParents(file, stopDir) {
|
|
68
|
+
const stop = path.resolve(stopDir);
|
|
69
|
+
let current = path.dirname(path.resolve(file));
|
|
70
|
+
while (current !== stop && current.startsWith(`${stop}${path.sep}`)) {
|
|
71
|
+
const entries = await fs.readdir(current).catch(() => null);
|
|
72
|
+
if (!entries || entries.length > 0) break;
|
|
73
|
+
await fs.rmdir(current);
|
|
74
|
+
current = path.dirname(current);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
PACKAGE_ROOT,
|
|
5
|
+
copyFile,
|
|
6
|
+
exists,
|
|
7
|
+
readJson,
|
|
8
|
+
removeEmptyParents,
|
|
9
|
+
snapshotTree,
|
|
10
|
+
writeJson,
|
|
11
|
+
} from "./fs-utils.mjs";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_INSTALL_DIR = ".agents";
|
|
14
|
+
export const METADATA_DIR = ".agentic-skills";
|
|
15
|
+
export const MANIFEST_FILE = "manifest.json";
|
|
16
|
+
export const MANIFEST_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export function resolveTarget({ path: projectPath, dir } = {}) {
|
|
19
|
+
const projectDir = path.resolve(projectPath || process.cwd());
|
|
20
|
+
const installDir = path.resolve(projectDir, dir || DEFAULT_INSTALL_DIR);
|
|
21
|
+
return {
|
|
22
|
+
projectDir,
|
|
23
|
+
installDir,
|
|
24
|
+
manifestPath: path.join(installDir, METADATA_DIR, MANIFEST_FILE),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadManifest(installDir) {
|
|
29
|
+
const manifestPath = path.join(installDir, METADATA_DIR, MANIFEST_FILE);
|
|
30
|
+
if (!(await exists(manifestPath))) return null;
|
|
31
|
+
try {
|
|
32
|
+
const manifest = await readJson(manifestPath);
|
|
33
|
+
if (manifest?.schemaVersion !== MANIFEST_SCHEMA_VERSION) return null;
|
|
34
|
+
if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) return null;
|
|
35
|
+
return manifest;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeManifest(installDir, files, { previous = null, upstream = null } = {}) {
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const pkg = await readJson(path.join(PACKAGE_ROOT, "package.json"));
|
|
44
|
+
const manifest = {
|
|
45
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
46
|
+
packageName: pkg.name,
|
|
47
|
+
packageVersion: pkg.version,
|
|
48
|
+
upstream,
|
|
49
|
+
installedAt: previous?.installedAt || now,
|
|
50
|
+
updatedAt: now,
|
|
51
|
+
files,
|
|
52
|
+
};
|
|
53
|
+
await writeJson(path.join(installDir, METADATA_DIR, MANIFEST_FILE), manifest);
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Three-way comparison between the installed tree, the incoming tree fetched
|
|
59
|
+
* from upstream, and the hashes recorded at the last install or update.
|
|
60
|
+
*
|
|
61
|
+
* - add: incoming, not installed
|
|
62
|
+
* - update: incoming, installed, unchanged locally since the last run (or --force)
|
|
63
|
+
* - skip: incoming, installed, modified locally (left alone)
|
|
64
|
+
* - remove: tracked before, gone from incoming, unchanged locally (or --force)
|
|
65
|
+
* - keep: tracked before, gone from incoming, modified locally
|
|
66
|
+
* - unchanged: already identical to incoming
|
|
67
|
+
*
|
|
68
|
+
* Files the user created themselves are never tracked and never touched.
|
|
69
|
+
*/
|
|
70
|
+
export async function planUpdate({ installDir, incomingDir, manifest = null, force = false }) {
|
|
71
|
+
const current = await snapshotTree(installDir, { ignorePrefixes: [METADATA_DIR] });
|
|
72
|
+
const incoming = await snapshotTree(incomingDir);
|
|
73
|
+
const previous = manifest?.files || {};
|
|
74
|
+
const files = [...new Set([...Object.keys(current), ...Object.keys(incoming), ...Object.keys(previous)])].sort();
|
|
75
|
+
|
|
76
|
+
const plan = { add: [], update: [], skip: [], remove: [], keep: [], unchanged: [], incoming };
|
|
77
|
+
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const currentHash = current[file];
|
|
80
|
+
const incomingHash = incoming[file];
|
|
81
|
+
const previousHash = previous[file];
|
|
82
|
+
|
|
83
|
+
if (incomingHash !== undefined) {
|
|
84
|
+
if (currentHash === undefined) plan.add.push(file);
|
|
85
|
+
else if (currentHash === incomingHash) plan.unchanged.push(file);
|
|
86
|
+
else if (currentHash === previousHash || force) plan.update.push(file);
|
|
87
|
+
else {
|
|
88
|
+
plan.skip.push({
|
|
89
|
+
file,
|
|
90
|
+
reason: previousHash === undefined
|
|
91
|
+
? "differs from upstream and is not tracked by a manifest"
|
|
92
|
+
: "modified locally",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (currentHash === undefined || previousHash === undefined) continue;
|
|
99
|
+
if (currentHash === previousHash || force) plan.remove.push(file);
|
|
100
|
+
else plan.keep.push({ file, reason: "removed upstream but modified locally" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return plan;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function applyPlan({ installDir, incomingDir, plan }) {
|
|
107
|
+
for (const file of [...plan.add, ...plan.update]) {
|
|
108
|
+
await copyFile(path.join(incomingDir, file), path.join(installDir, file));
|
|
109
|
+
}
|
|
110
|
+
for (const file of plan.remove) {
|
|
111
|
+
const target = path.join(installDir, file);
|
|
112
|
+
await fs.rm(target, { force: true });
|
|
113
|
+
await removeEmptyParents(target, installDir);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function hasContent(dir) {
|
|
118
|
+
if (!(await exists(dir))) return false;
|
|
119
|
+
const entries = await fs.readdir(dir);
|
|
120
|
+
return entries.length > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function init(options = {}) {
|
|
124
|
+
const { installDir } = resolveTarget(options);
|
|
125
|
+
const { incomingDir, upstream = null } = options;
|
|
126
|
+
const force = Boolean(options.force);
|
|
127
|
+
|
|
128
|
+
if (!force && (await hasContent(installDir))) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${installDir} already exists and is not empty. Run "agentic-skills update" to update it, ` +
|
|
131
|
+
"or pass --force to overwrite managed files in place.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const manifest = await loadManifest(installDir);
|
|
136
|
+
const plan = await planUpdate({ installDir, incomingDir, manifest, force });
|
|
137
|
+
if (!options.dryRun) {
|
|
138
|
+
await applyPlan({ installDir, incomingDir, plan });
|
|
139
|
+
await writeManifest(installDir, plan.incoming, { previous: manifest, upstream });
|
|
140
|
+
}
|
|
141
|
+
return { mode: "init", installDir, plan, upstream, dryRun: Boolean(options.dryRun) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function update(options = {}) {
|
|
145
|
+
const { installDir } = resolveTarget(options);
|
|
146
|
+
const { incomingDir, upstream = null } = options;
|
|
147
|
+
|
|
148
|
+
if (!(await exists(installDir))) {
|
|
149
|
+
throw new Error(`${installDir} does not exist. Run "agentic-skills init" first.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const manifest = await loadManifest(installDir);
|
|
153
|
+
const plan = await planUpdate({ installDir, incomingDir, manifest, force: Boolean(options.force) });
|
|
154
|
+
if (!options.dryRun) {
|
|
155
|
+
await applyPlan({ installDir, incomingDir, plan });
|
|
156
|
+
await writeManifest(installDir, plan.incoming, { previous: manifest, upstream });
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
mode: "update",
|
|
160
|
+
installDir,
|
|
161
|
+
plan,
|
|
162
|
+
upstream,
|
|
163
|
+
hadManifest: Boolean(manifest),
|
|
164
|
+
dryRun: Boolean(options.dryRun),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Local report only; nothing is fetched. */
|
|
169
|
+
export async function status(options = {}) {
|
|
170
|
+
const { installDir } = resolveTarget(options);
|
|
171
|
+
const pkg = await readJson(path.join(PACKAGE_ROOT, "package.json"));
|
|
172
|
+
const packageMeta = { packageName: pkg.name, packageVersion: pkg.version };
|
|
173
|
+
|
|
174
|
+
if (!(await exists(installDir))) {
|
|
175
|
+
return { installed: false, installDir, package: packageMeta };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const manifest = await loadManifest(installDir);
|
|
179
|
+
const current = await snapshotTree(installDir, { ignorePrefixes: [METADATA_DIR] });
|
|
180
|
+
const tracked = manifest?.files || {};
|
|
181
|
+
const modified = Object.keys(tracked).filter((file) => current[file] !== undefined && current[file] !== tracked[file]);
|
|
182
|
+
const missing = Object.keys(tracked).filter((file) => current[file] === undefined);
|
|
183
|
+
const untracked = Object.keys(current).filter((file) => tracked[file] === undefined);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
installed: true,
|
|
187
|
+
installDir,
|
|
188
|
+
manifest,
|
|
189
|
+
package: packageMeta,
|
|
190
|
+
counts: {
|
|
191
|
+
tracked: Object.keys(tracked).length,
|
|
192
|
+
modified: modified.length,
|
|
193
|
+
missing: missing.length,
|
|
194
|
+
untracked: untracked.length,
|
|
195
|
+
},
|
|
196
|
+
modified,
|
|
197
|
+
missing,
|
|
198
|
+
untracked,
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/tar.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal reader for the tar archives GitHub serves from codeload.
|
|
3
|
+
*
|
|
4
|
+
* Handles ustar headers with the prefix field, pax extended headers for
|
|
5
|
+
* long paths, and the pax global header that git archive uses to record the
|
|
6
|
+
* commit id. Links, devices, and other entry types are skipped.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BLOCK = 512;
|
|
10
|
+
|
|
11
|
+
const text = (buffer, offset, length) => {
|
|
12
|
+
const end = buffer.indexOf(0, offset);
|
|
13
|
+
const stop = end === -1 || end > offset + length ? offset + length : end;
|
|
14
|
+
return buffer.subarray(offset, stop).toString("utf8");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const octal = (buffer, offset, length) => {
|
|
18
|
+
const value = text(buffer, offset, length).trim();
|
|
19
|
+
return value ? parseInt(value, 8) : 0;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isZeroBlock = (block) => block.every((byte) => byte === 0);
|
|
23
|
+
|
|
24
|
+
/** Parse pax records of the form "<length> <key>=<value>\n". */
|
|
25
|
+
export function parsePax(data) {
|
|
26
|
+
const records = {};
|
|
27
|
+
let offset = 0;
|
|
28
|
+
while (offset < data.length) {
|
|
29
|
+
const space = data.indexOf(0x20, offset);
|
|
30
|
+
if (space === -1) break;
|
|
31
|
+
const length = parseInt(data.subarray(offset, space).toString("utf8"), 10);
|
|
32
|
+
if (!Number.isFinite(length) || length <= 0) break;
|
|
33
|
+
const record = data.subarray(space + 1, offset + length).toString("utf8");
|
|
34
|
+
const equals = record.indexOf("=");
|
|
35
|
+
if (equals !== -1) records[record.slice(0, equals)] = record.slice(equals + 1).replace(/\n$/, "");
|
|
36
|
+
offset += length;
|
|
37
|
+
}
|
|
38
|
+
return records;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Yield { type, name, data } for every entry. `type` is "file", "directory",
|
|
43
|
+
* or "global" (a pax global header, with its records under `pax`).
|
|
44
|
+
*/
|
|
45
|
+
export function* readTar(buffer) {
|
|
46
|
+
let offset = 0;
|
|
47
|
+
let pendingPax = null;
|
|
48
|
+
let pendingLongName = null;
|
|
49
|
+
|
|
50
|
+
while (offset + BLOCK <= buffer.length) {
|
|
51
|
+
const header = buffer.subarray(offset, offset + BLOCK);
|
|
52
|
+
if (isZeroBlock(header)) break;
|
|
53
|
+
|
|
54
|
+
const size = octal(header, 124, 12);
|
|
55
|
+
const typeflag = header[156] === 0 ? "0" : String.fromCharCode(header[156]);
|
|
56
|
+
const prefix = text(header, 345, 155);
|
|
57
|
+
let name = text(header, 0, 100);
|
|
58
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
59
|
+
|
|
60
|
+
const dataStart = offset + BLOCK;
|
|
61
|
+
const dataEnd = dataStart + size;
|
|
62
|
+
if (dataEnd > buffer.length) throw new Error("Truncated tar archive");
|
|
63
|
+
const data = buffer.subarray(dataStart, dataEnd);
|
|
64
|
+
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
|
65
|
+
|
|
66
|
+
if (typeflag === "x") {
|
|
67
|
+
pendingPax = parsePax(data);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (typeflag === "g") {
|
|
71
|
+
yield { type: "global", name, data, pax: parsePax(data) };
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// GNU tar stores paths over 100 characters in an "L" entry that describes
|
|
75
|
+
// the entry after it. "K" does the same for a link target, and we skip
|
|
76
|
+
// links, so its payload is discarded.
|
|
77
|
+
if (typeflag === "L") {
|
|
78
|
+
pendingLongName = text(data, 0, data.length);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (typeflag === "K") continue;
|
|
82
|
+
|
|
83
|
+
if (pendingLongName) name = pendingLongName;
|
|
84
|
+
if (pendingPax?.path) name = pendingPax.path;
|
|
85
|
+
pendingLongName = null;
|
|
86
|
+
pendingPax = null;
|
|
87
|
+
|
|
88
|
+
if (typeflag === "0" || typeflag === "7") yield { type: "file", name, data };
|
|
89
|
+
else if (typeflag === "5") yield { type: "directory", name, data };
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/upstream.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
import { isIgnoredName } from "./fs-utils.mjs";
|
|
5
|
+
import { readTar } from "./tar.mjs";
|
|
6
|
+
|
|
7
|
+
export const UPSTREAM = {
|
|
8
|
+
repository: "https://github.com/vudovn/ag-kit",
|
|
9
|
+
owner: "vudovn",
|
|
10
|
+
name: "ag-kit",
|
|
11
|
+
ref: "main",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Upstream path to installed path. Everything listed here is copied verbatim. */
|
|
15
|
+
export const PORTED = {
|
|
16
|
+
directories: {
|
|
17
|
+
".agents/agent": "agent",
|
|
18
|
+
".agents/skills": "skills",
|
|
19
|
+
".agents/workflows": "workflows",
|
|
20
|
+
},
|
|
21
|
+
files: {
|
|
22
|
+
".agents/ARCHITECTURE.md": "ARCHITECTURE.md",
|
|
23
|
+
LICENSE: "LICENSE",
|
|
24
|
+
},
|
|
25
|
+
version: ".agents/VERSION",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sections of the upstream ARCHITECTURE.md that describe the content. The
|
|
30
|
+
* others describe upstream's wiring into its own runtime and are dropped.
|
|
31
|
+
* Matching is by heading text after any leading emoji, so "## 🤖 Agents (20)"
|
|
32
|
+
* matches "Agents".
|
|
33
|
+
*/
|
|
34
|
+
export const ARCHITECTURE_SECTIONS = [
|
|
35
|
+
"Agents",
|
|
36
|
+
"Skills",
|
|
37
|
+
"Workflows",
|
|
38
|
+
"Skill Loading Protocol",
|
|
39
|
+
"Quick Reference",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The real upstream archive is under 1 MB compressed and a few MB expanded.
|
|
44
|
+
* The cap bounds decompression, which is where a small hostile file turns into
|
|
45
|
+
* gigabytes; the timeout bounds a server that accepts a connection and stalls.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_EXPANDED_BYTES = 128 * 1024 * 1024;
|
|
48
|
+
export const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
49
|
+
|
|
50
|
+
const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
51
|
+
|
|
52
|
+
export const archiveUrl = (ref = UPSTREAM.ref) =>
|
|
53
|
+
`https://codeload.github.com/${UPSTREAM.owner}/${UPSTREAM.name}/tar.gz/${ref.split("/").map(encodeURIComponent).join("/")}`;
|
|
54
|
+
|
|
55
|
+
const download = async (url, fetchImpl) => {
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
61
|
+
throw new Error(`Timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s downloading ${url}`);
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Could not download ${url}: ${error?.message || error}`);
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`Download failed with ${response.status} ${response.statusText} for ${url}`);
|
|
67
|
+
}
|
|
68
|
+
return Buffer.from(await response.arrayBuffer());
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the upstream archive as a gzip buffer. `archive` may be a local .tar.gz
|
|
73
|
+
* path or an http(s) URL; otherwise the GitHub archive for `ref` is fetched.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadArchive({ ref = UPSTREAM.ref, archive = null, fetchImpl = globalThis.fetch } = {}) {
|
|
76
|
+
if (archive) {
|
|
77
|
+
if (/^https?:\/\//.test(archive)) return download(archive, fetchImpl);
|
|
78
|
+
return fs.readFile(archive);
|
|
79
|
+
}
|
|
80
|
+
return download(archiveUrl(ref), fetchImpl);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Archive entries are attacker-controlled whenever --archive is used, so a
|
|
85
|
+
* path is accepted only when it cannot escape the directory it is written
|
|
86
|
+
* into: no absolute paths, no "." or ".." segments, no backslashes (a
|
|
87
|
+
* separator on Windows), and no NUL bytes.
|
|
88
|
+
*/
|
|
89
|
+
export function assertSafeRelativePath(relative, source = relative) {
|
|
90
|
+
const unsafe =
|
|
91
|
+
!relative ||
|
|
92
|
+
relative.includes("\0") ||
|
|
93
|
+
relative.includes("\\") ||
|
|
94
|
+
path.posix.isAbsolute(relative) ||
|
|
95
|
+
path.win32.isAbsolute(relative) ||
|
|
96
|
+
relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
|
|
97
|
+
|
|
98
|
+
if (unsafe) {
|
|
99
|
+
throw new Error(`The archive contains an unsafe path and was not extracted: ${JSON.stringify(source)}`);
|
|
100
|
+
}
|
|
101
|
+
return relative;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const headingTitle = (heading) =>
|
|
105
|
+
heading.replace(/^##\s*/, "").replace(/^[^\p{L}\p{N}]+/u, "").trim().toLowerCase();
|
|
106
|
+
|
|
107
|
+
const matchesSection = (heading, name) => {
|
|
108
|
+
const title = headingTitle(heading);
|
|
109
|
+
const wanted = name.toLowerCase();
|
|
110
|
+
return title === wanted || title.startsWith(`${wanted} `) || title.startsWith(`${wanted}(`);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keep only the ARCHITECTURE.md sections that describe the installed content.
|
|
115
|
+
* When upstream no longer has one of the expected sections the file is
|
|
116
|
+
* returned verbatim with a warning, so an upstream restructure never blocks
|
|
117
|
+
* an install.
|
|
118
|
+
*/
|
|
119
|
+
export function portArchitecture(markdown, { commit, version } = {}) {
|
|
120
|
+
const sections = [];
|
|
121
|
+
let current = null;
|
|
122
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
123
|
+
if (/^## /.test(line)) {
|
|
124
|
+
current = { heading: line, lines: [] };
|
|
125
|
+
sections.push(current);
|
|
126
|
+
} else if (current) {
|
|
127
|
+
current.lines.push(line);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const picked = [];
|
|
132
|
+
for (const name of ARCHITECTURE_SECTIONS) {
|
|
133
|
+
const section = sections.find((candidate) => matchesSection(candidate.heading, name));
|
|
134
|
+
if (!section) {
|
|
135
|
+
return {
|
|
136
|
+
text: markdown,
|
|
137
|
+
warning: `Upstream ARCHITECTURE.md has no "${name}" section, so it was installed verbatim.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
picked.push(section);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const trim = (lines) => {
|
|
144
|
+
const body = [...lines];
|
|
145
|
+
while (body.length && (body.at(-1).trim() === "" || body.at(-1).trim() === "---")) body.pop();
|
|
146
|
+
while (body.length && body[0].trim() === "") body.shift();
|
|
147
|
+
return body;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const source = [version ? `\`${version}\`` : null, commit ? `commit \`${commit.slice(0, 7)}\`` : null]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join(", ");
|
|
153
|
+
const header = [
|
|
154
|
+
"# ag-kit Architecture",
|
|
155
|
+
"",
|
|
156
|
+
`> Inventory of the agents, skills, and workflows installed from [ag-kit](${UPSTREAM.repository})${source ? ` (${source})` : ""}.`,
|
|
157
|
+
"> Sections about upstream tooling that is not part of this install were omitted; the sections below are upstream text, unchanged.",
|
|
158
|
+
].join("\n");
|
|
159
|
+
|
|
160
|
+
const body = picked
|
|
161
|
+
.map((section) => [section.heading, "", ...trim(section.lines)].join("\n"))
|
|
162
|
+
.join("\n\n---\n\n");
|
|
163
|
+
|
|
164
|
+
return { text: `${header}\n\n---\n\n${body}\n`, warning: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Turn the upstream archive into the tree to install.
|
|
169
|
+
* Returns { files: Map<installedPath, Buffer>, commit, version, warnings }.
|
|
170
|
+
*/
|
|
171
|
+
export function extractPort(archive, { filterArchitecture = true } = {}) {
|
|
172
|
+
let tar;
|
|
173
|
+
try {
|
|
174
|
+
tar = zlib.gunzipSync(archive, { maxOutputLength: MAX_EXPANDED_BYTES });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error?.code === "ERR_BUFFER_TOO_LARGE") {
|
|
177
|
+
throw new Error(`The archive expands to more than ${megabytes(MAX_EXPANDED_BYTES)}, which is far bigger than ag-kit. Refusing to extract it.`);
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`The archive could not be read as a .tar.gz: ${error?.message || error}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const raw = new Map();
|
|
183
|
+
let commit = null;
|
|
184
|
+
|
|
185
|
+
for (const entry of readTar(tar)) {
|
|
186
|
+
if (entry.type === "global") {
|
|
187
|
+
const comment = entry.pax?.comment;
|
|
188
|
+
if (comment && /^[0-9a-f]{40}$/.test(comment)) commit = comment;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (entry.type !== "file") continue;
|
|
192
|
+
const slash = entry.name.indexOf("/");
|
|
193
|
+
if (slash === -1) continue;
|
|
194
|
+
raw.set(entry.name.slice(slash + 1), entry.data);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const layoutHint = "Upstream may have changed its layout. Try --ref with a known-good tag, or update agentic-skills.";
|
|
198
|
+
const files = new Map();
|
|
199
|
+
|
|
200
|
+
for (const [from, to] of Object.entries(PORTED.directories)) {
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const [name, data] of raw) {
|
|
203
|
+
if (!name.startsWith(`${from}/`)) continue;
|
|
204
|
+
const relative = name.slice(from.length + 1);
|
|
205
|
+
if (relative.split("/").some(isIgnoredName)) continue;
|
|
206
|
+
files.set(assertSafeRelativePath(`${to}/${relative}`, name), data);
|
|
207
|
+
count += 1;
|
|
208
|
+
}
|
|
209
|
+
if (count === 0) throw new Error(`The archive has no files under ${from}. ${layoutHint}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const [from, to] of Object.entries(PORTED.files)) {
|
|
213
|
+
if (!raw.has(from)) throw new Error(`The archive has no ${from}. ${layoutHint}`);
|
|
214
|
+
files.set(to, raw.get(from));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let version = "unknown";
|
|
218
|
+
if (raw.has(PORTED.version)) {
|
|
219
|
+
version = raw.get(PORTED.version).toString("utf8").trim() || version;
|
|
220
|
+
} else if (raw.has("package.json")) {
|
|
221
|
+
try {
|
|
222
|
+
version = JSON.parse(raw.get("package.json").toString("utf8")).version || version;
|
|
223
|
+
} catch {
|
|
224
|
+
/* keep "unknown" */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const warnings = [];
|
|
229
|
+
if (filterArchitecture) {
|
|
230
|
+
const ported = portArchitecture(files.get("ARCHITECTURE.md").toString("utf8"), { commit, version });
|
|
231
|
+
files.set("ARCHITECTURE.md", Buffer.from(ported.text, "utf8"));
|
|
232
|
+
if (ported.warning) warnings.push(ported.warning);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { files, commit, version, warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Write an extracted tree to a directory, never outside it. */
|
|
239
|
+
export async function materialize(files, dir) {
|
|
240
|
+
const root = path.resolve(dir);
|
|
241
|
+
for (const [relative, data] of files) {
|
|
242
|
+
const target = path.resolve(root, ...relative.split("/"));
|
|
243
|
+
if (!target.startsWith(root + path.sep)) {
|
|
244
|
+
throw new Error(`Refusing to write ${JSON.stringify(relative)} outside ${root}`);
|
|
245
|
+
}
|
|
246
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
247
|
+
await fs.writeFile(target, data);
|
|
248
|
+
}
|
|
249
|
+
}
|