Compare commits

..

4 Commits

Author SHA1 Message Date
nntrivi2001 bc44cbc8b7 chore: v2.8.45 — Share again updates a shared skill; a same-named upload updates it too 2026-10-30 14:20:51 +07:00
nntrivi2001 e7060a5e42 feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one
uploaded from a browser (or living in a plugin) stayed at its first snapshot
for good. Share again (a skill's ⋯ menu and detail sheet) now takes the
owner's copy on the server — its source folder, else the same-named skill of
a plugin they installed at user scope — and shares it; with no copy it asks
for the folder and uploads it onto that row (`id`). An upload whose skill is
named like one the uploader already has replaces that row's version instead
of adding a twin. Installers are then offered Update, as before.
2026-10-30 14:20:18 +07:00
nntrivi2001 4b8f59695b chore: v2.8.44 — Discard in the Changes panel takes only the ticked files and blocks 2026-10-30 13:05:48 +07:00
nntrivi2001 da7b86d5c8 fix(changes): Discard takes only the ticked files and blocks (#87)
The Changes panel's "Discard all" threw away every changed file, ticked or
not. It is now "Discard (n)": the same selection Commit sends — whole ticked
files as `paths`, a partly ticked file's ticked blocks as `hunks` — and is
disabled with nothing ticked. The server's `discard` accepts `hunks`
(`selectionPatch` in reverse, `git apply -R`), building every patch before
applying any so a stale selection undoes nothing.

Closes #87
2026-10-30 13:05:13 +07:00
22 changed files with 617 additions and 72 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
@@ -1470,25 +1470,40 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
// Discard selected: exactly what Commit would take — the ticked files, and
// only the ticked blocks of a partly ticked one (#87: it used to discard
// every file, ticked or not) — in one `discard`, asked once in a dialog
// that names how many untracked files it will delete.
const discardTicked = async () => {
if (!tickedPaths.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
title: t("changes.discardTickedTitle"),
message: t("changes.discardTickedConfirm", {
count: tickedPaths.length,
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
}),
confirmLabel: t("changes.discardAll"),
confirmLabel: t("changes.discardTicked"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
if (!ok) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const { paths, hunks } = await selection();
await api.lanes.gitAction(target, "discard", {
paths,
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
confirm: true,
});
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
@@ -2067,8 +2082,7 @@ export function LaneChangesPanel({
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
@@ -2863,13 +2877,13 @@ export function LaneChangesPanel({
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
disabled={busy || tickedPaths.length === 0}
onClick={() => void discardTicked()}
title={t("changes.discardTickedTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
{t("changes.discardTicked", { count: tickedPaths.length })}
</button>
</div>
</div>
@@ -837,10 +837,12 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
await user.click(tickOf("docs/API.md"));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
@@ -849,10 +851,14 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
paths: ["server/lib/git-ops.js"],
confirm: true,
})
);
// Nothing ticked: nothing to discard.
await user.click(tickOf("server/lib/git-ops.js"));
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
@@ -1673,6 +1679,25 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
const user = userEvent.setup();
await openReview(user);
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
confirm: true,
})
);
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
@@ -28,6 +28,12 @@
"unshare": "Unshare",
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"reshare": "Share again",
"reshareDone": "New version shared — people who installed it are offered Update",
"reshareSame": "Already the newest version — shared",
"reshareAskTitle": "Choose the {{name}} folder again",
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
"resharePick": "Choose folder",
"open": "Open",
"copyLink": "Copy link",
"preview": "Preview",
+3 -3
View File
@@ -532,9 +532,9 @@
"changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push",
"changes.discardAll": "Discard all",
"changes.discardAllTitle": "Discard all changes?",
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
"changes.discardTicked": "Discard ({{count}})",
"changes.discardTickedTitle": "Discard the ticked changes?",
"changes.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
"changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit",
@@ -28,6 +28,12 @@
"unshare": "Bỏ chia sẻ",
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"reshare": "Share lại",
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
"reshareSame": "Đã là bản mới nhất — đã share",
"reshareAskTitle": "Chọn lại thư mục {{name}}",
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
"resharePick": "Chọn thư mục",
"open": "Mở",
"copyLink": "Sao chép link",
"preview": "Xem",
+3 -3
View File
@@ -532,9 +532,9 @@
"changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push",
"changes.discardAll": "Bỏ tất cả",
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
"changes.discardTicked": "Bỏ ({{count}})",
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
"changes.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
"changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit",
+12 -5
View File
@@ -3324,16 +3324,17 @@ export const api = {
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
/** `id`: Share again — the upload replaces that skill row's version. */
fields: { name?: string; title?: string; description?: string; id?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
"/artifacts/skill/upload",
{ method: "POST", body: form }
);
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
@@ -3372,6 +3373,12 @@ export const api = {
method: "PATCH",
body: JSON.stringify(body),
}),
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
* the server becomes the new version (409 ENOSOURCE when it has none). */
reshare: (id: number) =>
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
method: "POST",
}),
/** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
+74 -1
View File
@@ -28,7 +28,9 @@
* **Installed** instead of the Install button; **Uninstall** (a skill's
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
* change there as a new snapshot when the Hub is listed); an installed copy
* change there as a new snapshot when the Hub is listed; **Share again**
* takes its folder on the server — or a plugin's copy — on a click, else
* asks for the folder and uploads it onto the same row); an installed copy
* that differs from the newest version shows **Update** (a pack: "Update N"),
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
* edits its SKILL.md (name, description, instructions — a new snapshot);
@@ -277,6 +279,53 @@ export function Artifacts() {
}
};
// Share again: the server's copy of the skill, else the folder picked again
// (uploaded onto the same row). Either way installers are offered Update.
const reshareInput = useRef<HTMLInputElement>(null);
const reshareRow = useRef<Artifact | null>(null);
useEffect(() => {
reshareInput.current?.setAttribute("webkitdirectory", "");
}, []);
const reshared = (changed: boolean) => {
toast.success(t(changed ? "reshareDone" : "reshareSame"));
load();
};
const reshare = async (a: Artifact) => {
try {
const r = await api.artifacts.reshare(a.id);
reshared(r.changed);
} catch (err) {
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
if (
!(await confirmDialog({
title: t("reshareAskTitle", { name: a.source ?? a.title }),
message: t("reshareAskBody", { name: a.source ?? a.title }),
confirmLabel: t("resharePick"),
}))
)
return;
reshareRow.current = a;
reshareInput.current?.click();
}
};
const reshareUpload = async (list: FileList | null) => {
const a = reshareRow.current;
reshareRow.current = null;
if (!a || !list?.length) return;
try {
const r = await api.artifacts.uploadSkills(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
})),
{ id: String(a.id) }
);
reshared(r.items.some((i) => i.changed !== false));
} catch (err) {
toast.error(err);
}
};
const [renaming, setRenaming] = useState<Artifact | null>(null);
const remove = async (a: Artifact) => {
@@ -442,6 +491,7 @@ export function Artifacts() {
const actionsFor = (a: Artifact): CardActions => ({
onOpen: () => setDetailId(a.id),
onShare: () => void share(a),
onReshare: () => void reshare(a),
onRename: () => {
setDetailId(null);
setRenaming(a);
@@ -572,6 +622,17 @@ export function Artifacts() {
return (
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
<input
ref={reshareInput}
type="file"
multiple
hidden
data-testid="artifact-reshare-folder"
onChange={(e) => {
void reshareUpload(e.target.files);
e.target.value = "";
}}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
@@ -1066,6 +1127,8 @@ function EmptyState({
type CardActions = {
onOpen: () => void;
onShare: () => void;
/** A skill of your own: its newest version, shared (installers get Update). */
onReshare: () => void;
onRename: () => void;
onDelete: () => void;
onInstall: () => void;
@@ -1237,6 +1300,7 @@ function ArtifactMenu({
menu,
onOpen,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1297,6 +1361,9 @@ function ArtifactMenu({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</MenuItem>
)}
{server && a.kind === "skill" && (
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
{!hubOnly && (
<MenuItem danger onSelect={pick(onDelete)}>
@@ -1447,6 +1514,7 @@ function DetailDialog({
canInstall,
onClose,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1611,6 +1679,11 @@ function DetailDialog({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</button>
)}
{server && a.kind === "skill" && (
<button type="button" className={secondary} onClick={onReshare}>
{t("reshare")}
</button>
)}
<button type="button" className={secondary} onClick={onRename}>
{t("rename")}
</button>
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
addPlugin: vi.fn(),
inspectMarket: vi.fn(),
uploadSkills: vi.fn(),
reshare: vi.fn(),
writeSkill: vi.fn(),
uploadFile: vi.fn(),
candidates: vi.fn(),
@@ -394,6 +395,47 @@ describe("Artifacts", () => {
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
vi.mocked(api.artifacts.uploadSkills).mockClear();
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: true,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
});
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
item: item({ id: 2 }),
changed: true,
});
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
await menuAction(user, "Share again", card);
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
// No copy on the server: the folder is asked for and lands on THIS row.
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
items: [{ ...item({ id: 2 }), changed: true }],
});
await menuAction(user, "Share again", card);
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
await waitFor(() =>
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
id: "2",
})
);
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.43 (rebuild client to match)
UI build v2.8.45 (rebuild client to match)
</span>
</p>
</div>
+4 -2
View File
@@ -1063,7 +1063,7 @@ cannot collide on `index.lock`.
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
@@ -3156,7 +3156,7 @@ POST /api/artifacts/page {url, title?, description?}
POST /api/artifacts/file {laneId, path, title?, sessionId?}
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
POST /api/artifacts/skill {name, laneId?, title?, description?}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
@@ -3166,6 +3166,7 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
@@ -3177,6 +3178,7 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.43",
"version": "2.8.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.43",
"version": "2.8.45",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.43",
"version": "2.8.45",
"bin": {
"agenthub": "bin/agenthub.js"
},
+99
View File
@@ -665,6 +665,105 @@ describe("skills from files or written", () => {
assert.equal(bad.body.error.code, "EBADNAME");
});
it("updates the uploader's same-named skill instead of adding a twin", async () => {
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
assert.equal(first.status, 201, JSON.stringify(first.body));
const id = first.body.items[0].id;
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(again.status, 201, JSON.stringify(again.body));
assert.equal(again.body.items[0].id, id);
assert.equal(again.body.items[0].replaced, true);
assert.equal(again.body.items[0].changed, true);
assert.equal(again.body.items[0].description, "version 2");
assert.match(snapText(again.body.items[0]), /Body 2\./);
const rows = db
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
.get();
assert.equal(rows.n, 1);
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(same.body.items[0].changed, false);
});
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const no = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(no.status, 409);
assert.equal(no.body.error.code, "ENOSOURCE");
// The folder picked again, under any folder name: onto THIS row.
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
id: String(id),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.items[0].id, id);
assert.equal(r.body.items[0].source, "sg-up");
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
const two = await upload(
"/api/artifacts/skill/upload",
[
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
],
{ id: String(id) }
);
assert.equal(two.body.error.code, "EBADUPLOAD");
});
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
const home = process.env.CLAUDE_HOME;
// An uploaded skill whose name a user-scope plugin also ships.
const first = await upload("/api/artifacts/skill/upload", [
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
]);
const id = first.body.items[0].id;
const writePlugin = (ver, text) => {
const at = path.join(home, "plugins", "cache", "m", "p", ver);
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
return at;
};
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
"proj@m": [
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
],
},
})
);
const r = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(r.status, 200, r.text);
assert.equal(r.body.changed, true);
assert.match(snapText(r.body.item), /Plugin two\./);
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
const own = path.join(home, "skills", "sg-plug");
fs.mkdirSync(own, { recursive: true });
fs.writeFileSync(
path.join(own, "SKILL.md"),
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
);
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(mine.body.changed, true);
assert.match(snapText(mine.body.item), /Mine\./);
// Recorded as the folder's state: the 15 s sync does not take it twice.
assert.deepEqual(
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
[]
);
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
fs.rmSync(own, { recursive: true, force: true });
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
title: "Report",
+45
View File
@@ -851,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
});
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
const edited = threeBlocks();
write(LANE, "keep.txt", "untouched by discard\n");
write(LANE, "drop.txt", "gone\n");
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
}),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
confirm: true,
});
const want = [...edited];
want.splice(11, 0, "line 11");
want.splice(3, 2, "line 4", "line 5");
assert.deepEqual(read(), [...want, ""]);
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
});
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
const edited = threeBlocks();
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
confirm: true,
}),
{ code: "EHUNKGONE" }
);
assert.deepEqual(read(), [...edited, ""]);
await runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
confirm: true,
});
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
});
it("refuses a block the hunk does not have", async () => {
threeBlocks();
const [hunk] = await hunkOf();
+155 -9
View File
@@ -1125,8 +1125,20 @@ function saveSkillFiles(req, { name, files, title, description }) {
* `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes.
*/
function uploadSkills(req, { uploads, name, title, description }) {
function uploadSkills(req, { uploads, name, title, description, id }) {
// `id`: Share again for a skill the server has no copy of — the upload
// replaces THAT row (one skill, named as the row is).
let onto = null;
if (id != null && id !== "") {
onto = load(req, id);
if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
name = onto.source;
}
const found = skillsFromUpload(uploads || [], { name, description });
if (onto && found.length !== 1) {
throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill");
}
const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
@@ -1140,16 +1152,46 @@ function uploadSkills(req, { uploads, name, title, description }) {
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files);
}
return prepared.map((p) =>
saveSkillFiles(req, {
return prepared.map((p) => {
// A skill its uploader already has (same name, not a workspace's) gets
// the new version on that row — installers see Update — never a twin.
const own = onto || ownSkillRow(req, p.name);
if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : "");
return saveSkillFiles(req, {
name: p.name,
files: p.files,
title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "",
})
});
});
}
/** The caller's newest skill row of that name taken from no workspace, or null. */
function ownSkillRow(req, name) {
const owner = req?.user ? req.user.id : null;
return (
sql(
`SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL
AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1`
).get(name, owner) || null
);
}
/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */
function replaceUploaded(req, row, { name, files }, description) {
const md = files.find((f) => f.path === "SKILL.md");
const text = md.data.toString("utf8");
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
checkSkillFiles(all);
const changed = contentHash(all) !== snapshotVersion(row);
// The owner's folder as it is now (like `storeSkill`): only a LATER change
// to it replaces this upload.
if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash);
return { ...shareNow(req, row.id, changed), replaced: true, changed };
}
/** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name);
@@ -1936,6 +1978,23 @@ function syncOne(row) {
} catch {
return false;
}
try {
replaceSnapshot(row, files, st.hash);
} catch {
return false;
}
return true;
}
/**
* Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already
* checked), then drop the old one — never a write into the snapshot being
* read. Installers then see Update (`updateCheck`). `sourceHash` is the
* source folder's state to record, so `syncOne` takes only a LATER change.
* The description follows SKILL.md's only while it still says what the old
* one did.
*/
function replaceSnapshot(row, files, sourceHash) {
const snap = newSnapshotDir();
let total = 0;
const next = [];
@@ -1947,11 +2006,10 @@ function syncOne(row) {
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch {
} catch (err) {
dropSnapshot(snap.id);
return false;
throw err;
}
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = "";
try {
@@ -1970,9 +2028,96 @@ function syncOne(row) {
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
).run(
snap.id,
JSON.stringify(next),
total,
contentHash(files),
sourceHash ?? null,
description,
row.id
);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true;
}
/**
* The owner's skill folder on THIS server as it is now, for Share again:
* the row's own source folder (`sourceDirOf`), else — a skill not taken from
* a workspace — the same-named skill of a plugin the owner installed at user
* scope (`installed_plugins.json`; the most recently updated when several
* have it), never outside the owner's `~/.claude/plugins/`. Answers
* `{st, fromSource}` (`folderState`) or null when the server has no copy (an
* uploaded skill: its owner picks the folder again).
*/
function machineSkill(row) {
const src = sourceDirOf(row);
const st = src && folderState(src.dir, src.top);
if (st) return { st, fromSource: true };
if (!src || row.lane_id != null) return null;
const top = path.join(src.top, "plugins");
let installed = {};
try {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
const file = path.join(top, "installed_plugins.json");
const text =
user && ownAccount({ user }).acct
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
: fs.readFileSync(file, "utf8");
installed = JSON.parse(text)?.plugins || {};
} catch {
return null;
}
let best = null;
for (const entries of Object.values(installed)) {
for (const e of Array.isArray(entries) ? entries : []) {
if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue;
const st2 = folderState(path.join(e.installPath, "skills", row.source), top);
const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0;
if (st2 && (!best || at > best.at)) best = { st: st2, at };
}
}
return best ? { st: best.st, fromSource: false } : null;
}
/**
* Share again: take the owner's skill folder on this server as the row's new
* snapshot (`machineSkill`) and share it (server mode), so installers are
* offered Update. ENOSOURCE when the server has no copy — the client then
* asks for the folder or .zip and uploads it onto this row (`uploadSkills`
* with `id`). Answers `{item, changed}`.
*/
function reshareSkill(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
const found = machineSkill(row);
if (!found) {
throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip");
}
const files = found.st.read();
checkSkillFiles(files);
const changed = found.st.hash !== snapshotVersion(row);
if (changed) {
replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash);
} else if (found.fromSource) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id);
}
return { item: shareNow(req, row.id, changed), changed };
}
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
function shareNow(req, id, changed) {
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
if (isServer() && row.shared !== 1) {
sql(
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(id);
audit(req, row, "shared");
} else if (changed && row.shared === 1) {
audit(req, row, "updated");
}
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
}
const SYNC_EVERY_MS = 15 * 1000;
@@ -2215,6 +2360,7 @@ module.exports = {
availableSkills,
addSkill,
uploadSkills,
reshareSkill,
writeSkill,
uploadFile,
skillSlug,
+5 -3
View File
@@ -1241,16 +1241,18 @@ async function diffHunks(dir, file, context) {
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
* it for `apply -R` off the working tree instead (the partial discard, #87),
* where the NEW side is the one checked and every start stays git's own.
*/
async function selectionPatch(dir, file, context, picks) {
async function selectionPatch(dir, file, context, picks, reverse = false) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
if (!body) return null;
bodies.push(body);
}
+56 -14
View File
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
if (action === "discard") {
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
// Ticked change blocks of partly ticked files (#87) go first: each patch
// is built before any is applied, so a stale one refuses before the
// irreversible whole-file part below has run.
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
return done(action, partial);
}
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's ORIGINAL path rides along: restoring only the new one would
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
const tracked = paths
.filter((p) => p.code !== "??")
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
const out = [];
const out = partial ? [partial] : [];
// Untracked files are deleted (git has no copy to restore); tracked ones
// go back to HEAD in both gutters, so a staged-then-edited file lands in
// one state rather than half-reverted.
@@ -825,18 +832,11 @@ function hasHunks(body) {
const MAX_PARTIAL_FILES = 300;
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
* `body.hunks` checked for shape, and its files resolved as changed paths
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
* conflict, and not also named whole in `body.paths`.
*/
async function commitSelection(dir, args, body) {
async function partialPicks(dir, body, verb) {
const picks = Object.entries(body.hunks);
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
for (const [, list] of picks) {
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
for (const p of part) {
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
if (whole.some((w) => w.path === p.path)) {
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
}
}
return { picks, whole, part };
}
/**
* The partial discard (#87): per file in `hunks`, only the ticked change
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
* own fresh text, never the request's — exactly what one `discard-hunk` per
* block would do, in one step. Every patch is built before the first is
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
*/
async function discardSelection(dir, body) {
const { picks } = await partialPicks(dir, body, "discard");
const patches = [];
for (const [p, list] of picks) {
const patch = await selectionPatch(dir, p, body.context, list, true);
if (!patch)
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
patches.push(patch);
}
// The patch names paths from the repo root, so it is applied there.
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
const out = [];
for (const patch of patches)
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
return mergeResults(out);
}
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
*/
async function commitSelection(dir, args, body) {
const { picks, whole, part } = await partialPicks(dir, body, "commit");
const wholePaths = whole.flatMap((p) =>
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
);
+2 -1
View File
@@ -238,7 +238,7 @@ const paths = {
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
operationId: "uploadArtifactSkills",
description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace's) becomes that row's new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row's name.",
requestBody: multipart(
{
files: { type: "array", items: { type: "string", format: "binary" } },
@@ -246,6 +246,7 @@ const paths = {
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
id: { type: "string", description: "Share again: the skill row the upload replaces." },
},
["files"]
),
+20
View File
@@ -1113,6 +1113,26 @@ const paths = {
},
},
},
"/api/artifacts/{id}/reshare": {
post: {
tags: ["Artifacts"],
summary: "Share again: a skill's newest version from its owner's folder on this server",
operationId: "reshareArtifact",
description:
"The owner's skill folder (the row's source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Taken.", {
item: { type: "object" },
changed: { type: "boolean" },
}),
400: errs("EBADKIND."),
403: errs("EFORBIDDEN."),
404: errs("ENOTFOUND."),
409: errs("ENOSOURCE."),
},
},
},
"/api/artifacts/{id}/claim": {
post: {
tags: ["Artifacts"],
+15
View File
@@ -39,6 +39,7 @@ const STATUS = {
ENEEDLOGIN: 502,
ETOOBIG: 413,
EOWNED: 409,
ENOSOURCE: 409,
};
function sendError(res, err) {
@@ -229,6 +230,7 @@ router.post(
name: b.name,
title: b.title,
description: b.description,
id: b.id,
});
for (const item of items) changed(item);
res.status(201).json({ items });
@@ -284,6 +286,19 @@ router.patch(
})
);
// Share again: the owner's skill folder on this server becomes the new
// version (installers see Update); 409 ENOSOURCE when the server has none —
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
router.post(
"/:id/reshare",
sameOriginGuard,
wrap((req, res) => {
const out = artifacts.reshareSkill(req, req.params.id);
changed(out.item);
res.json(out);
})
);
router.delete(
"/:id",
sameOriginGuard,