Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/operator/skill-pack-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Skill pack sources

Administrators can register an HTTPS Git repository or upload an offline ZIP from
Admin → Skills. Both sources use the same skill preview, scope selection, validation,
import, and materialization pipeline. Uploading a ZIP alone does not install skills.
Open **Browse skills…**, review eligibility, select target scopes, and import.

## Offline ZIPs

A ZIP must contain at least one `SKILL.md`. A common enclosing directory is removed.
UTF-8 instructions, configuration files, scripts, and text assets are accepted.
Binary attachments, Git history, encrypted entries, links, unsafe paths, duplicate
paths, and corrupt entries are rejected. macOS metadata is ignored. The limits are
16 MiB compressed, 32 MiB expanded, and 5,000 entries.

The archive SHA-256 identifies its version. Source contents persist in the same
Postgres-backed artifact store as other skill state. No repository connection is
needed for preview, import, or an application restart.

Use **Upload new ZIP…** on an existing pack to stage a replacement. The installed
skills remain at their current version until **Apply uploaded version** refreshes
already-imported skills. New skills still require Browse. The immediately previous
selected archive is retained: **Select previous version** selects it, and applying
that version updates the installed skills. This is a one-version rollback, not a
complete archive history. Keep original ZIPs if more history is needed.

## Repository download cache

A successful repository fetch saves a durable source snapshot. Branch and tag
previews reuse it for five minutes; an explicit full commit hash remains reusable.
Preview responses include the snapshot commit, and imports from the admin UI use
that exact snapshot without downloading it again. The current and immediately
previous snapshots can satisfy a previewed import. An expired or invalidated preview
returns a conflict and must be opened again.

Manual sync and tracked synchronization request a fresh repository download.
Download failures remain visible as failures and leave installed skills intact;
previously saved source contents are not silently presented as a successful sync.
Changing the URL, ref, owner, or credential identity invalidates snapshot reuse.
Removing a pack removes its saved source snapshot.

## API

Stage ZIP bytes through the existing signed `POST /v1/blobs` endpoint. Then submit
`{ "blobId": "...", "name": "skills.zip" }` to
`POST /v1/admin/skill-packs/upload`, or
`POST /v1/admin/skill-packs/:id/upload` for an archive replacement. Both require
organization administrator access. Archives retain third-party trust by default.

`GET /v1/admin/skill-packs/:id/catalog` returns a `commit` alongside the plan.
Pass it as `expectedCommit` to the existing import endpoint to bind the import to
that preview. Legacy callers that omit it continue to request a fresh fetch.
42 changes: 41 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"porter-sandbox": "^0.1.41",
"tar-stream": "^3.2.0",
"typebox": "^1.1.38",
"yauzl": "^3.2.0",
"zod": "4.4.3"
},
"overrides": {
Expand All @@ -101,6 +102,7 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.11.10",
"@types/tar-stream": "^3.1.4",
"@types/yauzl": "^2.10.3",
"@yc-software/qm": "file:./cli",
"eslint": "^10.4.1",
"globals": "^17.6.0",
Expand Down
130 changes: 106 additions & 24 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10013,7 +10013,12 @@ <h2 id="governance-review-title">Confirm governance change</h2>
dataCache.clear();
renderData();
};
row.append(url, advToggle, btn, status);
const upload = document.createElement("button");
upload.type = "button";
upload.textContent = "Upload ZIP";
upload.title = "Upload a text-only skill pack, up to 16 MiB. No repository connection is needed.";
upload.onclick = () => chooseSkillPackArchive(null, status);
row.append(url, advToggle, btn, upload, status);
box.append(row, adv);

const rows = packs.map((s) => {
Expand Down Expand Up @@ -10062,12 +10067,13 @@ <h2 id="governance-review-title">Confirm governance change</h2>
"Upstream advanced past the imported commit — Sync now (or turn on Auto-sync) to apply.",
);
else setStatusText(li ? "up to date" : "not imported", "muted", delta);
const isTracked = s.syncMode === "tracked";
const isArchive = s.kind === "archive";
const isTracked = !isArchive && s.syncMode === "tracked";
let busy = false;
const menu = overflowMenu([
{ label: "Browse skills…", onClick: () => browsePack(s, detail) },
{
label: "Sync now",
label: isArchive ? "Apply uploaded version" : "Sync now",
title:
"Pull the pack's latest commit and refresh already-imported skills (update changed, archive removed). Does NOT add new skills — use Browse for that.",
onClick: async () => {
Expand Down Expand Up @@ -10096,26 +10102,51 @@ <h2 id="governance-review-title">Confirm governance change</h2>
}, 1200);
},
},
{
label: isTracked ? "Turn off auto-sync" : "Turn on auto-sync",
title: isTracked
? "Auto-sync is ON — re-imports when the upstream advances."
: "Auto-sync is OFF — manual Sync only.",
onClick: async () => {
if (busy) return;
busy = true;
const r = await api("PATCH", "/api/skill-packs/" + encodeURIComponent(s.id), {
syncMode: isTracked ? "pinned" : "tracked",
});
busy = false;
if (!r.ok) {
alert(r.data?.message || "Failed (" + r.status + ").");
return;
}
dataCache.clear();
renderData();
},
},
...(isArchive
? [
{
label: "Upload new ZIP…",
onClick: () => chooseSkillPackArchive(s, st),
},
...(s.previousRef
? [
{
label: "Select previous version",
onClick: async () => {
const r = await api("PATCH", "/api/skill-packs/" + encodeURIComponent(s.id), {
ref: s.previousRef,
});
if (!r.ok)
return setStatusText(r.data?.message || "Could not select the previous version.", "err");
dataCache.clear();
renderData();
},
},
]
: []),
]
: [
{
label: isTracked ? "Turn off auto-sync" : "Turn on auto-sync",
title: isTracked
? "Auto-sync is ON — re-imports when the upstream advances."
: "Auto-sync is OFF — manual Sync only.",
onClick: async () => {
if (busy) return;
busy = true;
const r = await api("PATCH", "/api/skill-packs/" + encodeURIComponent(s.id), {
syncMode: isTracked ? "pinned" : "tracked",
});
busy = false;
if (!r.ok) {
alert(r.data?.message || "Failed (" + r.status + ").");
return;
}
dataCache.clear();
renderData();
},
},
]),
{
label: "Remove pack",
danger: true,
Expand All @@ -10135,7 +10166,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
return [
{ node: packNode },
{ node: skillsNode, cls: "num" },
{ text: s.trustTier + (isTracked ? " · auto-sync" : ""), cls: "subline" },
{ text: s.trustTier + (isArchive ? " · offline" : isTracked ? " · auto-sync" : ""), cls: "subline" },
{ node: st },
{ node: menu, cls: "num" },
];
Expand Down Expand Up @@ -10317,6 +10348,56 @@ <h2 id="governance-review-title">Confirm governance change</h2>
renderChips();
return pick;
}
let skillPackUploadBusy = false;
function chooseSkillPackArchive(pack, status) {
if (skillPackUploadBusy) return;
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".zip,application/zip";
picker.hidden = true;
picker.oncancel = () => picker.remove();
picker.onchange = async () => {
const file = picker.files?.[0];
picker.remove();
if (!file) return;
if (!/\.zip$/i.test(file.name) || file.size > 16 * 1024 * 1024) {
status.className = "status err";
status.textContent = "Choose a ZIP file up to 16 MiB.";
return;
}
skillPackUploadBusy = true;
status.className = "status";
status.textContent = "Uploading and validating ZIP…";
try {
const path = "/api/skill-packs/" + (pack ? encodeURIComponent(pack.id) + "/" : "") + "upload";
const response = await fetch(API_BASE + path, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/zip",
"x-file-name": encodeURIComponent(file.name),
"x-content-sha256": await fileSha256(file),
},
body: file,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || "ZIP upload failed (" + response.status + ").");
if (data.pack?.lastImport?.status === "error" && !pack)
throw new Error(data.pack.lastImport.error || "ZIP validation failed.");
status.className = "status ok";
status.textContent = "ZIP ready. Browse skills to choose where to import them.";
dataCache.clear();
renderData();
} catch (error) {
status.className = "status err";
status.textContent = error.message || "ZIP upload failed.";
} finally {
skillPackUploadBusy = false;
}
};
document.body.appendChild(picker);
picker.click();
}
async function browsePack(s, holder) {
holder.textContent = "";
const loading = document.createElement("div");
Expand Down Expand Up @@ -10461,6 +10542,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const r2 = await api("POST", "/api/skill-packs/" + encodeURIComponent(s.id) + "/import", {
selected: allEligible ? "all" : names,
scopeIds: scopes,
expectedCommit: plan.commit,
});
importButtons.forEach((b) => {
b.disabled = false;
Expand Down
Loading