avenic 1.0.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/LICENSE +21 -0
- package/README.md +234 -0
- package/package.json +30 -0
- package/scripts/skills.mjs +13 -0
- package/scripts/watchdog.mjs +50 -0
- package/src/cli/dispatcher.mjs +439 -0
- package/src/cli/self-update.mjs +27 -0
- package/src/cli/skills-cli.mjs +1060 -0
- package/src/cli/watchdog.mjs +30 -0
- package/vendor/core-src/index.mjs +143 -0
- package/vendor/core-src/runtime/adapters/claude.mjs +81 -0
- package/vendor/core-src/runtime/adapters/codex.mjs +142 -0
- package/vendor/core-src/runtime/adapters/index.mjs +9 -0
- package/vendor/core-src/runtime/adapters/opencode.mjs +76 -0
- package/vendor/core-src/runtime/agents.mjs +39 -0
- package/vendor/core-src/runtime/config.mjs +227 -0
- package/vendor/core-src/runtime/gitignore.mjs +69 -0
- package/vendor/core-src/runtime/process.mjs +41 -0
- package/vendor/core-src/runtime/project-root.mjs +42 -0
- package/vendor/core-src/runtime/sessions.mjs +312 -0
- package/vendor/core-src/skills/catalog.mjs +130 -0
- package/vendor/core-src/skills/direct.mjs +209 -0
- package/vendor/core-src/skills/git.mjs +93 -0
- package/vendor/core-src/skills/ids.mjs +40 -0
- package/vendor/core-src/skills/install.mjs +357 -0
- package/vendor/core-src/skills/packs.mjs +256 -0
- package/vendor/core-src/skills/paths.mjs +92 -0
- package/vendor/core-src/skills/sources.mjs +246 -0
- package/vendor/core-src/skills/ui.mjs +21 -0
- package/vendor/core-src/skills/vendor.mjs +57 -0
- package/vendor/core-src/util/fail.mjs +3 -0
- package/vendor/core-src/util/fs.mjs +14 -0
- package/vendor/core-src/util/json.mjs +14 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fail } from "../util/fail.mjs";
|
|
6
|
+
import { readJson } from "../util/json.mjs";
|
|
7
|
+
import { git, normalizeRepositoryInput, repositoryIdentity } from "./git.mjs";
|
|
8
|
+
import { loadPacks } from "./packs.mjs";
|
|
9
|
+
import { catalogCacheRoot, defaultCatalogFile, deprecatedEnvironmentValue, knownCatalogsFile } from "./paths.mjs";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CATALOG_SPEC = "Echo-Kang-hub/avenic-catalog#main";
|
|
12
|
+
|
|
13
|
+
export function parseCatalogSpec(spec) {
|
|
14
|
+
if (typeof spec !== "string" || spec.length === 0) {
|
|
15
|
+
fail(`Invalid catalog spec: ${spec}`);
|
|
16
|
+
}
|
|
17
|
+
const hashIndex = spec.lastIndexOf("#");
|
|
18
|
+
const repository = hashIndex === -1 ? spec : spec.slice(0, hashIndex);
|
|
19
|
+
const ref = hashIndex === -1 ? "main" : spec.slice(hashIndex + 1) || "main";
|
|
20
|
+
return { repository: normalizeRepositoryInput(repository), ref };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadDefaultCatalogSpec(environment = process.env) {
|
|
24
|
+
const catalogSpec = deprecatedEnvironmentValue(environment, "AVENIC_CATALOG_SPEC", "AGENTHOME_CATALOG_SPEC");
|
|
25
|
+
if (catalogSpec) {
|
|
26
|
+
return catalogSpec;
|
|
27
|
+
}
|
|
28
|
+
const file = defaultCatalogFile(environment);
|
|
29
|
+
if (existsSync(file)) {
|
|
30
|
+
const config = await readJson(file);
|
|
31
|
+
if (typeof config.spec === "string" && config.spec.length > 0) {
|
|
32
|
+
return config.spec;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return DEFAULT_CATALOG_SPEC;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function setDefaultCatalogSpec(environment = process.env, spec) {
|
|
39
|
+
const file = defaultCatalogFile(environment);
|
|
40
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
41
|
+
await writeFile(file, `${JSON.stringify({ schemaVersion: 1, spec }, null, 2)}\n`, "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Short label for a catalog spec: "owner/repo" for remotes, the directory
|
|
45
|
+
// name for local paths. Preserves the original spelling for display.
|
|
46
|
+
export function catalogDisplayName(spec) {
|
|
47
|
+
const { repository } = parseCatalogSpec(spec);
|
|
48
|
+
if (/^(https?:\/\/|ssh:\/\/)/i.test(repository)) {
|
|
49
|
+
const clean = repository.replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
50
|
+
return clean.split("/").slice(-2).join("/");
|
|
51
|
+
}
|
|
52
|
+
if (/^git@/i.test(repository)) {
|
|
53
|
+
return repository.replace(/\.git$/i, "").split(":").at(-1);
|
|
54
|
+
}
|
|
55
|
+
return repository.split(/[\\/]/).filter(Boolean).at(-1) ?? repository;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function loadKnownCatalogs(environment = process.env) {
|
|
59
|
+
const file = knownCatalogsFile(environment);
|
|
60
|
+
if (!existsSync(file)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const config = await readJson(file);
|
|
64
|
+
const catalogs = Array.isArray(config.catalogs) ? config.catalogs : [];
|
|
65
|
+
return catalogs.filter((entry) => typeof entry.spec === "string" && entry.spec.length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Record a catalog in the registry (most recently used first, deduplicated by
|
|
69
|
+
// spec). The registry drives `catalog select`; the current spec stays in the
|
|
70
|
+
// default catalog file.
|
|
71
|
+
export async function registerKnownCatalog(environment = process.env, spec) {
|
|
72
|
+
const known = await loadKnownCatalogs(environment);
|
|
73
|
+
const next = [{ name: catalogDisplayName(spec), spec }, ...known.filter((entry) => entry.spec !== spec)];
|
|
74
|
+
const file = knownCatalogsFile(environment);
|
|
75
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
+
await writeFile(file, `${JSON.stringify({ schemaVersion: 1, catalogs: next }, null, 2)}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cacheDirectory(environment, repository) {
|
|
80
|
+
const identity = repositoryIdentity(repository).replace(/\\/g, "/");
|
|
81
|
+
const parts = identity.split("/").filter(Boolean);
|
|
82
|
+
const owner = parts.at(-2) ?? "catalog";
|
|
83
|
+
const name = parts.at(-1) ?? "catalog";
|
|
84
|
+
const slug = `${owner}-${name}`
|
|
85
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
86
|
+
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "");
|
|
87
|
+
return path.join(catalogCacheRoot(environment), slug);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function ensureCatalog(spec, options = {}) {
|
|
91
|
+
const environment = options.environment ?? process.env;
|
|
92
|
+
const { repository, ref } = parseCatalogSpec(spec);
|
|
93
|
+
const directory = cacheDirectory(environment, repository);
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(path.join(directory, ".git"))) {
|
|
96
|
+
git(["-C", directory, "fetch", "--depth", "1", "origin", ref], { capture: true });
|
|
97
|
+
} else {
|
|
98
|
+
await mkdir(directory, { recursive: true });
|
|
99
|
+
git(["-C", directory, "init", "--quiet"], { capture: true });
|
|
100
|
+
git(["-C", directory, "remote", "add", "origin", repository], { capture: true });
|
|
101
|
+
git(["-C", directory, "fetch", "--depth", "1", "origin", ref], { capture: true });
|
|
102
|
+
}
|
|
103
|
+
git(["-C", directory, "checkout", "--quiet", "--detach", "FETCH_HEAD"], { capture: true });
|
|
104
|
+
const revision = git(["-C", directory, "rev-parse", "HEAD"], { capture: true });
|
|
105
|
+
return { catalogRoot: directory, repository, ref, revision, spec };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
fail(
|
|
108
|
+
`${error.message}\nUnable to fetch catalog: ${spec}\n` +
|
|
109
|
+
"Check your GitHub authentication (gh auth login, SSH key, or credential helper) and the catalog spec.\n" +
|
|
110
|
+
"To point Avenic at your own catalog: avenic catalog add <owner/repo>",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Register a catalog: save the default spec and the known-catalog entry
|
|
116
|
+
// first, then try to fetch and preview its Packs. The spec stays configured
|
|
117
|
+
// even when the preview fails (offline, missing credentials, no Packs yet).
|
|
118
|
+
export async function registerCatalog(spec, options = {}) {
|
|
119
|
+
const io = options.io ?? console;
|
|
120
|
+
parseCatalogSpec(spec);
|
|
121
|
+
await setDefaultCatalogSpec(options.environment, spec);
|
|
122
|
+
await registerKnownCatalog(options.environment, spec);
|
|
123
|
+
try {
|
|
124
|
+
const catalogInfo = await ensureCatalog(spec, { environment: options.environment, io });
|
|
125
|
+
const packs = [...(await loadPacks(catalogInfo.catalogRoot)).values()];
|
|
126
|
+
return { spec, catalogInfo, packs, previewFailed: false };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { spec, packs: [], previewFailed: true, error };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fail } from "../util/fail.mjs";
|
|
5
|
+
import { isInside, removeEmptyDirectory } from "../util/fs.mjs";
|
|
6
|
+
import { readJson, writeJson } from "../util/json.mjs";
|
|
7
|
+
import { cloneHead, deriveSourceId, git, normalizeRepositoryInput } from "./git.mjs";
|
|
8
|
+
import { assertSafeSkillName } from "./ids.mjs";
|
|
9
|
+
import { previousManagedState, removeSkillDirectories } from "./install.mjs";
|
|
10
|
+
import { stateRoot } from "./paths.mjs";
|
|
11
|
+
import { detectSkillRoot, discoverSourceSkills } from "./sources.mjs";
|
|
12
|
+
|
|
13
|
+
export function directRoot(context) {
|
|
14
|
+
return context.global
|
|
15
|
+
? path.join(stateRoot(context.environment), "direct")
|
|
16
|
+
: path.join(context.root, ".agents", "direct");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function directLicensesRoot(context) {
|
|
20
|
+
return context.global
|
|
21
|
+
? path.join(stateRoot(context.environment), "licenses")
|
|
22
|
+
: path.join(context.root, ".agents", "licenses");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readDirectState(context) {
|
|
26
|
+
if (!existsSync(context.lockFile)) {
|
|
27
|
+
return { directSources: [] };
|
|
28
|
+
}
|
|
29
|
+
const lock = await readJson(context.lockFile);
|
|
30
|
+
return { directSources: lock.directSources ?? [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function writeDirectState(context, state) {
|
|
34
|
+
await mkdir(path.dirname(context.lockFile), { recursive: true });
|
|
35
|
+
const previousLock = existsSync(context.lockFile) ? await readJson(context.lockFile) : {};
|
|
36
|
+
const lock = { ...previousLock, schemaVersion: 3, directSources: state.directSources };
|
|
37
|
+
await writeJson(context.lockFile, lock);
|
|
38
|
+
const previousConfig = existsSync(context.configFile) ? await readJson(context.configFile) : {};
|
|
39
|
+
const config = {
|
|
40
|
+
...previousConfig,
|
|
41
|
+
schemaVersion: 3,
|
|
42
|
+
direct: state.directSources.map((source) => ({
|
|
43
|
+
source: source.repository,
|
|
44
|
+
skills: source.skills,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
await writeJson(context.configFile, config);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureDirectClone(repository, directory) {
|
|
51
|
+
if (existsSync(path.join(directory, ".git"))) {
|
|
52
|
+
git(["-C", directory, "fetch", "--depth", "1", "origin"], { capture: true });
|
|
53
|
+
git(["-C", directory, "checkout", "--quiet", "--detach", "FETCH_HEAD"], { capture: true });
|
|
54
|
+
} else {
|
|
55
|
+
await cloneHead({ repository }, directory);
|
|
56
|
+
}
|
|
57
|
+
return git(["-C", directory, "rev-parse", "HEAD"], { capture: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function findLicenseFile(cloneDirectory) {
|
|
61
|
+
const entries = await readdir(cloneDirectory, { withFileTypes: true });
|
|
62
|
+
return (
|
|
63
|
+
entries.find((entry) => entry.isFile() && /^licen[cs]e(?:\.|$)/i.test(entry.name))?.name ?? null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function addDirectSkills(context, sourceReference, skillNames, options = {}) {
|
|
68
|
+
const io = options.io ?? console;
|
|
69
|
+
if (sourceReference.includes("#")) {
|
|
70
|
+
fail(
|
|
71
|
+
`Refs are not supported for direct sources: ${sourceReference}. Add the source to the catalog to pin a revision.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const repository = normalizeRepositoryInput(sourceReference);
|
|
75
|
+
const sourceId = deriveSourceId(repository);
|
|
76
|
+
const state = await readDirectState(context);
|
|
77
|
+
const existing = state.directSources.find((source) => source.id === sourceId);
|
|
78
|
+
const directory = path.join(directRoot(context), sourceId);
|
|
79
|
+
const revision = await ensureDirectClone(repository, directory);
|
|
80
|
+
|
|
81
|
+
const source = {
|
|
82
|
+
id: sourceId,
|
|
83
|
+
name: sourceReference.replace(/\.git$/i, ""),
|
|
84
|
+
repository,
|
|
85
|
+
revision,
|
|
86
|
+
skillRoot: existing?.skillRoot,
|
|
87
|
+
};
|
|
88
|
+
if (!source.skillRoot) {
|
|
89
|
+
source.skillRoot = await detectSkillRoot(directory);
|
|
90
|
+
}
|
|
91
|
+
const discovered = await discoverSourceSkills(source, directory);
|
|
92
|
+
const requestedNames = [...new Set(skillNames.length > 0 ? skillNames : discovered.names)];
|
|
93
|
+
requestedNames.forEach(assertSafeSkillName);
|
|
94
|
+
for (const name of requestedNames) {
|
|
95
|
+
if (!discovered.names.includes(name)) {
|
|
96
|
+
fail(`Skill not found upstream: ${name}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const managed = await previousManagedState(context);
|
|
101
|
+
const managedNames = requestedNames.filter((name) => managed.has(name));
|
|
102
|
+
if (managedNames.length > 0) {
|
|
103
|
+
fail(
|
|
104
|
+
`Managed by configured Packs: ${managedNames.join(", ")}. Uninstall the Pack or remove the Skill from the Catalog`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
for (const other of state.directSources.filter((item) => item.id !== sourceId)) {
|
|
108
|
+
const conflicts = requestedNames.filter((name) => other.skills.includes(name));
|
|
109
|
+
if (conflicts.length > 0) {
|
|
110
|
+
fail(`Skill ${conflicts.join(", ")} already belongs to source ${other.id}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
existing &&
|
|
116
|
+
existing.revision === revision &&
|
|
117
|
+
requestedNames.every((name) => existing.skills.includes(name))
|
|
118
|
+
) {
|
|
119
|
+
io.log(
|
|
120
|
+
`Already installed: ${sourceId} (${requestedNames.length} Skill${requestedNames.length === 1 ? "" : "s"})`,
|
|
121
|
+
);
|
|
122
|
+
return { names: requestedNames, sourceId, revision, alreadyInstalled: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const targetConfig of context.targets) {
|
|
126
|
+
const destination = targetConfig.destination;
|
|
127
|
+
await mkdir(destination, { recursive: true });
|
|
128
|
+
for (const name of requestedNames) {
|
|
129
|
+
const target = path.join(destination, name);
|
|
130
|
+
if (!isInside(destination, target)) {
|
|
131
|
+
fail(`Install path escaped its target: ${target}`);
|
|
132
|
+
}
|
|
133
|
+
await rm(target, { recursive: true, force: true });
|
|
134
|
+
const upstreamPath = source.skillPaths?.[name] ?? name;
|
|
135
|
+
await cp(path.join(directory, source.skillRoot, upstreamPath), target, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
io.log(`✓ ${targetConfig.label}`);
|
|
138
|
+
io.log(` Path: ${destination}`);
|
|
139
|
+
io.log(` Added ${requestedNames.length} direct Skill${requestedNames.length === 1 ? "" : "s"}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const licenseName = await findLicenseFile(directory);
|
|
143
|
+
if (licenseName) {
|
|
144
|
+
const licenseDirectory = path.join(directLicensesRoot(context), sourceId);
|
|
145
|
+
await mkdir(licenseDirectory, { recursive: true });
|
|
146
|
+
await cp(path.join(directory, licenseName), path.join(licenseDirectory, "LICENSE"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const combined = {
|
|
150
|
+
...source,
|
|
151
|
+
skillRoot: source.skillRoot.replace(/\\/g, "/"),
|
|
152
|
+
skills: [...new Set([...(existing?.skills ?? []), ...requestedNames])],
|
|
153
|
+
};
|
|
154
|
+
if (source.skillPaths) {
|
|
155
|
+
combined.skillPaths = source.skillPaths;
|
|
156
|
+
}
|
|
157
|
+
const directSources = [...state.directSources.filter((item) => item.id !== sourceId), combined];
|
|
158
|
+
await writeDirectState(context, { directSources });
|
|
159
|
+
return { names: requestedNames, sourceId, revision };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function removeDirectSkills(context, skillNames) {
|
|
163
|
+
if (skillNames.length === 0) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
const state = await readDirectState(context);
|
|
167
|
+
const removedNames = new Set();
|
|
168
|
+
const keptSources = [];
|
|
169
|
+
const removedSources = [];
|
|
170
|
+
for (const source of state.directSources) {
|
|
171
|
+
const removed = source.skills.filter((name) => skillNames.includes(name));
|
|
172
|
+
removed.forEach((name) => removedNames.add(name));
|
|
173
|
+
const remaining = source.skills.filter((name) => !skillNames.includes(name));
|
|
174
|
+
if (remaining.length > 0) {
|
|
175
|
+
keptSources.push({ ...source, skills: remaining });
|
|
176
|
+
} else if (removed.length > 0) {
|
|
177
|
+
removedSources.push(source);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (removedNames.size === 0) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
await writeDirectState(context, { directSources: keptSources });
|
|
184
|
+
for (const source of removedSources) {
|
|
185
|
+
await rm(path.join(directRoot(context), source.id), { recursive: true, force: true });
|
|
186
|
+
await rm(path.join(directLicensesRoot(context), source.id), { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
await removeEmptyDirectory(directRoot(context));
|
|
189
|
+
await removeEmptyDirectory(directLicensesRoot(context));
|
|
190
|
+
return [...removedNames];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove externally installed Skills: managed Skills are rejected (they
|
|
194
|
+
// belong to Packs), then the direct records and the target directories go.
|
|
195
|
+
export async function removeExternalSkills(context, skillNames, options = {}) {
|
|
196
|
+
const io = options.io ?? console;
|
|
197
|
+
const uniqueNames = [...new Set(skillNames)];
|
|
198
|
+
uniqueNames.forEach(assertSafeSkillName);
|
|
199
|
+
const managed = await previousManagedState(context);
|
|
200
|
+
const managedNames = uniqueNames.filter((skillName) => managed.has(skillName));
|
|
201
|
+
if (managedNames.length > 0) {
|
|
202
|
+
fail(
|
|
203
|
+
`Managed by configured Packs: ${managedNames.join(", ")}. Uninstall the Pack or remove the Skill from the Catalog`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const directRemoved = await removeDirectSkills(context, uniqueNames);
|
|
207
|
+
const removedDirectories = await removeSkillDirectories(context, uniqueNames, io);
|
|
208
|
+
return { directRemoved, removedDirectories };
|
|
209
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fail } from "../util/fail.mjs";
|
|
5
|
+
|
|
6
|
+
export { fail };
|
|
7
|
+
|
|
8
|
+
export function run(command, argumentsList, options = {}) {
|
|
9
|
+
const result = spawnSync(command, argumentsList, {
|
|
10
|
+
cwd: options.cwd,
|
|
11
|
+
encoding: options.capture ? "utf8" : undefined,
|
|
12
|
+
env: options.env ?? process.env,
|
|
13
|
+
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
14
|
+
});
|
|
15
|
+
if (result.error) {
|
|
16
|
+
fail(`Unable to run ${command}: ${result.error.message}`);
|
|
17
|
+
}
|
|
18
|
+
if (result.status !== 0) {
|
|
19
|
+
const details = options.capture ? result.stderr.trim() : "";
|
|
20
|
+
fail(`${command} failed${details ? `: ${details}` : ""}`);
|
|
21
|
+
}
|
|
22
|
+
return options.capture ? result.stdout.trim() : "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function git(argumentsList, options = {}) {
|
|
26
|
+
return run("git", argumentsList, options);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeRepositoryInput(repository) {
|
|
30
|
+
if (/^[^\s/:@]+\/[^\s/]+$/.test(repository)) {
|
|
31
|
+
return `https://github.com/${repository.replace(/\.git$/i, "")}.git`;
|
|
32
|
+
}
|
|
33
|
+
return repository;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function repositoryIdentity(repository) {
|
|
37
|
+
return normalizeRepositoryInput(repository)
|
|
38
|
+
.replace(/^git\+/, "")
|
|
39
|
+
.replace(/^git@github\.com:/i, "https://github.com/")
|
|
40
|
+
.replace(/\.git$/i, "")
|
|
41
|
+
.replace(/\/$/, "")
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deriveSourceId(repository) {
|
|
46
|
+
const parts = repositoryIdentity(repository).split(/[/:]/).filter(Boolean);
|
|
47
|
+
const owner = parts.at(-2) ?? "source";
|
|
48
|
+
const name = parts.at(-1) ?? "skills";
|
|
49
|
+
return `${owner}-${name}`
|
|
50
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
51
|
+
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function currentRepositoryState(catalogRoot) {
|
|
55
|
+
let repository = null;
|
|
56
|
+
let revision = null;
|
|
57
|
+
let dirty = null;
|
|
58
|
+
try {
|
|
59
|
+
const remotes = git(["-C", catalogRoot, "remote"], { capture: true })
|
|
60
|
+
.split(/\r?\n/)
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
if (remotes.length > 0) {
|
|
63
|
+
repository = git(["-C", catalogRoot, "remote", "get-url", remotes[0]], { capture: true });
|
|
64
|
+
}
|
|
65
|
+
revision = git(["-C", catalogRoot, "rev-parse", "HEAD"], { capture: true });
|
|
66
|
+
dirty = Boolean(git(["-C", catalogRoot, "status", "--porcelain"], { capture: true }));
|
|
67
|
+
} catch {
|
|
68
|
+
// npm's cache copy is intentionally not a Git working tree.
|
|
69
|
+
}
|
|
70
|
+
return { repository, revision, dirty };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function cloneHead(source, destination) {
|
|
74
|
+
git(["clone", "--depth", "1", source.repository, destination]);
|
|
75
|
+
return git(["-C", destination, "rev-parse", "HEAD"], { capture: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function cloneRevision(source, destination) {
|
|
79
|
+
await mkdir(destination, { recursive: true });
|
|
80
|
+
git(["-C", destination, "init", "--quiet"]);
|
|
81
|
+
git(["-C", destination, "remote", "add", "origin", source.repository]);
|
|
82
|
+
git(["-C", destination, "fetch", "--depth", "1", "origin", source.revision]);
|
|
83
|
+
git(["-C", destination, "checkout", "--quiet", "--detach", "FETCH_HEAD"]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function remoteHead(source) {
|
|
87
|
+
const output = git(["ls-remote", source.repository, "HEAD"], { capture: true });
|
|
88
|
+
const revision = output.split(/\s+/)[0];
|
|
89
|
+
if (!/^[0-9a-f]{40}$/i.test(revision)) {
|
|
90
|
+
fail(`Unable to read upstream HEAD: ${source.id}`);
|
|
91
|
+
}
|
|
92
|
+
return revision;
|
|
93
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fail } from "../util/fail.mjs";
|
|
3
|
+
|
|
4
|
+
export function assertSafeId(value, label) {
|
|
5
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(value)) {
|
|
6
|
+
fail(`${label} must contain only lowercase letters, numbers, dots, underscores, or hyphens: ${value}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function assertSafeSkillName(value) {
|
|
11
|
+
if (!value || value === "." || value === ".." || path.basename(value) !== value) {
|
|
12
|
+
fail(`Invalid Skill name: ${value}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertSafeRelativePath(value, label) {
|
|
17
|
+
const normalized = value?.replace(/\\/g, "/");
|
|
18
|
+
if (
|
|
19
|
+
!normalized ||
|
|
20
|
+
path.isAbsolute(normalized) ||
|
|
21
|
+
normalized.split("/").some((part) => !part || part === "." || part === "..")
|
|
22
|
+
) {
|
|
23
|
+
fail(`Invalid ${label}: ${value}`);
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertSafeSkillRoot(value) {
|
|
29
|
+
if (value === ".") {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return assertSafeRelativePath(value, "Skill root");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertSafeSkillPath(value, label) {
|
|
36
|
+
if (value === ".") {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
return assertSafeRelativePath(value, label);
|
|
40
|
+
}
|