diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts
index be9492d54..39698771c 100644
--- a/apps/web/src/agent-source-read.ts
+++ b/apps/web/src/agent-source-read.ts
@@ -11,11 +11,10 @@ import {
import { type } from "arktype";
import { fetchSourceFile } from "./git-fetch";
+import { withGitToken } from "./git-token";
export class AgentSourceReadError extends Error {}
-const GitTokenMintShape = type({ id: "string", secret: "string" });
-
const ToolPackagePinShape = type({ name: "string", version: "string" });
const AgentWorkflowJsonShape = type({
@@ -34,14 +33,6 @@ const AgentWorkflowJsonShape = type({
const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
-async function readErrorBody(response: Response): Promise {
- const body: unknown = await response.json().catch(() => undefined);
- const envelope = type({
- error: { code: "string", userMessage: "string", refId: "string" },
- })(body);
- return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
-}
-
/** An existing agent's deploy source, in the shape `deployAgentSource`'s
* `NewAgentInput` needs plus the sources it declares for inference. */
export type AgentSource = {
@@ -63,53 +54,36 @@ async function readAgentWorkflowStep(
assetName: string,
fetchImpl: typeof fetch,
): Promise {
- const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`;
- const minted = await fetchImpl(tokensPath, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- name: `agent-read-${crypto.randomUUID()}`,
- resource: `asset:${assetId}`,
- refPattern: "refs/heads/main",
- actions: ["can_read"],
- expiresAt: new Date(Date.now() + READ_TOKEN_LIFETIME_MS).toISOString(),
- }),
- });
- if (!minted.ok) {
- throw new AgentSourceReadError(`minting a read token failed: ${await readErrorBody(minted)}`);
- }
- const token = GitTokenMintShape(await minted.json());
- if (token instanceof type.errors) {
- throw new AgentSourceReadError(
- `the read token came back an unexpected shape: ${token.summary}`,
- );
- }
-
const url = new URL(
`/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`,
globalThis.location.origin,
).toString();
- try {
- const definitionFile = await fetchSourceFile({
- url,
- token: token.secret,
- filepath: WORKFLOW_SOURCE_DEFINITION_PATH,
- });
- const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId);
- const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson));
- if (parsed instanceof type.errors) {
- throw new AgentSourceReadError(
- `this agent's source came back an unexpected shape: ${parsed.summary}`,
- );
- }
- const step = Object.values(parsed.steps)[0];
- if (step === undefined) {
- throw new AgentSourceReadError("this agent's source has no steps to read a prompt from");
- }
- return step;
- } finally {
- await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" });
- }
+ return withGitToken({
+ tenantId,
+ assetId,
+ actions: ["can_read"],
+ lifetimeMs: READ_TOKEN_LIFETIME_MS,
+ fetchImpl,
+ use: async (token) => {
+ const definitionFile = await fetchSourceFile({
+ url,
+ token,
+ filepath: WORKFLOW_SOURCE_DEFINITION_PATH,
+ });
+ const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId);
+ const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson));
+ if (parsed instanceof type.errors) {
+ throw new AgentSourceReadError(
+ `this agent's source came back an unexpected shape: ${parsed.summary}`,
+ );
+ }
+ const step = Object.values(parsed.steps)[0];
+ if (step === undefined) {
+ throw new AgentSourceReadError("this agent's source has no steps to read a prompt from");
+ }
+ return step;
+ },
+ });
}
/** Mints a read-only token, fetches the asset's `main`, and parses out the
diff --git a/apps/web/src/git-fetch.ts b/apps/web/src/git-fetch.ts
index ce8988d0d..ade7bd0e1 100644
--- a/apps/web/src/git-fetch.ts
+++ b/apps/web/src/git-fetch.ts
@@ -4,7 +4,7 @@
// pkt-lines fine, so no hand-rolled wire code is needed here.
import LightningFS from "@isomorphic-git/lightning-fs";
import { Buffer } from "buffer";
-import git from "isomorphic-git";
+import git, { Errors } from "isomorphic-git";
import http from "isomorphic-git/http/web";
globalThis.Buffer ??= Buffer;
@@ -13,29 +13,37 @@ export class GitFetchError extends Error {}
const MAIN_REF = "refs/heads/main";
-/** Fetches `main` and returns `filepath`'s contents as text. */
-export async function fetchSourceFile(args: {
+async function cloneAndFetchMain(args: {
url: string;
token: string;
- filepath: string;
-}): Promise {
+}): Promise<{ fs: InstanceType; dir: string }> {
const fs = new LightningFS(`workbench-fetch-${crypto.randomUUID()}`, { wipe: true });
const dir = "/repo";
await fs.promises.mkdir(dir);
await git.init({ fs, dir, defaultBranch: "main" });
+ await git.fetch({
+ fs,
+ http,
+ dir,
+ url: args.url,
+ ref: MAIN_REF,
+ // The hub's git server advertises no `shallow` capability, so a
+ // depth-limited fetch is rejected outright; fetch the full branch.
+ singleBranch: true,
+ tags: false,
+ headers: { Authorization: `Bearer ${args.token}` },
+ });
+ return { fs, dir };
+}
+
+/** Fetches `main` and returns `filepath`'s contents as text. */
+export async function fetchSourceFile(args: {
+ url: string;
+ token: string;
+ filepath: string;
+}): Promise {
try {
- await git.fetch({
- fs,
- http,
- dir,
- url: args.url,
- ref: MAIN_REF,
- // The hub's git server advertises no `shallow` capability, so a
- // depth-limited fetch is rejected outright; fetch the full branch.
- singleBranch: true,
- tags: false,
- headers: { Authorization: `Bearer ${args.token}` },
- });
+ const { fs, dir } = await cloneAndFetchMain(args);
const oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" });
const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath });
return new TextDecoder().decode(blob);
@@ -43,3 +51,29 @@ export async function fetchSourceFile(args: {
throw new GitFetchError(cause instanceof Error ? cause.message : String(cause));
}
}
+
+/** Fetches `main` and returns `filepath`'s contents as text, or `""` when
+ * the branch has no commits yet or the file isn't in it — the two shapes
+ * a freshly created, still-empty asset takes. Any other failure (auth,
+ * network, a malformed repo) still throws. */
+export async function fetchSourceFileOrEmpty(args: {
+ url: string;
+ token: string;
+ filepath: string;
+}): Promise {
+ try {
+ const { fs, dir } = await cloneAndFetchMain(args);
+ let oid: string;
+ try {
+ oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" });
+ } catch (cause) {
+ if (cause instanceof Errors.NotFoundError) return "";
+ throw cause;
+ }
+ const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath });
+ return new TextDecoder().decode(blob);
+ } catch (cause) {
+ if (cause instanceof Errors.NotFoundError) return "";
+ throw new GitFetchError(cause instanceof Error ? cause.message : String(cause));
+ }
+}
diff --git a/apps/web/src/git-token.ts b/apps/web/src/git-token.ts
new file mode 100644
index 000000000..cc91cb133
--- /dev/null
+++ b/apps/web/src/git-token.ts
@@ -0,0 +1,55 @@
+// Short-lived git tokens scoped to one asset — the mint/use/revoke shape
+// every reader and pusher of an asset's smart-HTTP git remote needs.
+// Pulled out of `agent-source-read.ts` and `agent-deploy.ts`'s copies so a
+// new caller (skill content) mints and revokes through the same helper
+// rather than a third copy of this dance.
+import { type } from "arktype";
+
+export class GitTokenError extends Error {}
+
+const GitTokenMintShape = type({ id: "string", secret: "string" });
+
+async function readErrorBody(response: Response): Promise {
+ const body: unknown = await response.json().catch(() => undefined);
+ const envelope = type({
+ error: { code: "string", userMessage: "string", refId: "string" },
+ })(body);
+ return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
+}
+
+/** Mints a token scoped to `assetId` on `refs/heads/main`, runs `use` with
+ * its secret, and revokes it afterward whether `use` succeeds or throws. */
+export async function withGitToken(args: {
+ tenantId: string;
+ assetId: string;
+ actions: readonly ("can_read" | "can_push")[];
+ lifetimeMs: number;
+ fetchImpl?: typeof fetch;
+ use: (token: string) => Promise;
+}): Promise {
+ const fetchImpl = args.fetchImpl ?? fetch;
+ const tokensPath = `/api/tenants/${encodeURIComponent(args.tenantId)}/git-tokens`;
+ const minted = await fetchImpl(tokensPath, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ name: `git-token-${crypto.randomUUID()}`,
+ resource: `asset:${args.assetId}`,
+ refPattern: "refs/heads/main",
+ actions: args.actions,
+ expiresAt: new Date(Date.now() + args.lifetimeMs).toISOString(),
+ }),
+ });
+ if (!minted.ok) {
+ throw new GitTokenError(`minting a git token failed: ${await readErrorBody(minted)}`);
+ }
+ const token = GitTokenMintShape(await minted.json());
+ if (token instanceof type.errors) {
+ throw new GitTokenError(`the git token came back an unexpected shape: ${token.summary}`);
+ }
+ try {
+ return await args.use(token.secret);
+ } finally {
+ await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" });
+ }
+}
diff --git a/apps/web/src/pages/skill-detail-page.tsx b/apps/web/src/pages/skill-detail-page.tsx
index 03accecb7..58410c2b4 100644
--- a/apps/web/src/pages/skill-detail-page.tsx
+++ b/apps/web/src/pages/skill-detail-page.tsx
@@ -1,33 +1,45 @@
// The skill detail page at `/skills/`.
//
-// this used to be a full editor over `@corbits/skills`' own
-// registry — description/body editing with diff review, restore-by-
-// version off that asset's git history, a "pinned by" list, and a
-// private/shared visibility toggle. That registry (and the workflow
-// routes it served) was deleted: skills are native `kind:"skill"` hub
-// assets now, and the stock asset routes (`@intx/hub-api`'s
-// `routes/assets.ts`) carry only metadata — id, name, displayName,
-// creator, timestamps. There is no stock route yet to read a skill's
-// SKILL.md content, its version history, who has it pinned, or a
-// scope/visibility flag, so none of that can be rendered here without
-// vendoring a replacement surface. This page is scoped down to what the
-// stock routes actually carry until one exists — see the PR body
-// for the gap.
-import { PageShell, RichEmptyState, Section, formatRelativeTime } from "@corbits/react-ui";
+// this used to be a full editor over `@corbits/skills`' own registry —
+// description/body editing with diff review, restore-by-version off that
+// asset's git history, a "pinned by" list, and a private/shared visibility
+// toggle. That registry (and the workflow routes it served) was deleted:
+// skills are native `kind:"skill"` hub assets now, and the stock asset
+// routes (`@intx/hub-api`'s `routes/assets.ts`) carry only metadata — id,
+// name, displayName, creator, timestamps. There is still no stock route
+// for a skill's version history, pinned-by list, or scope/visibility
+// flag, so those stay out of this page. Its `SKILL.md` content, though,
+// is readable and writable the same way agent source is: over the
+// asset's own smart-HTTP git remote with a short-lived token (see
+// `skill-source.ts`).
+import {
+ Button,
+ PageShell,
+ RichEmptyState,
+ Section,
+ Textarea,
+ formatRelativeTime,
+ toast,
+} from "@corbits/react-ui";
import { Lightning } from "@/lib/icons";
import { WorkbenchLoadingState } from "@/chat";
import { ApiQueryError, describeApiError } from "@/lib/api-query";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useCallback, type ReactNode } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useState, type ReactNode } from "react";
import { tenantKeys } from "../query-client";
import { useBench } from "../bench-context";
import { SKILLS_PATH_PREFIX, skillIdFromPath } from "../path-ids";
import { skillDisplayName } from "../skill-display-name";
+import { readSkillSource, writeSkillSource } from "../skill-source";
import { StageTopBar } from "../shell/stage-top-bar";
import { loadSkill, type SkillSummary } from "../skills-api";
+function errorText(cause: unknown): string {
+ return cause instanceof Error ? cause.message : String(cause);
+}
+
type PageState =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly skill: SkillSummary }
@@ -128,20 +140,98 @@ export function SkillDetailPage({
-
-
- This skill's description, instructions, version history, and pinned-by list lived in the
- workbench-specific skill registry removed by. They will return once a stock route for
- reading and writing skill content exists.
-
-
+
,
);
}
+/** SKILL.md's editor: a read `useQuery`, handed off to a `SkillDraftEditor`
+ * keyed on the fetched content so its draft state seeds once per value
+ * read rather than through an effect. */
+function SkillSourceEditor({
+ tenantId,
+ skill,
+}: {
+ readonly tenantId: string;
+ readonly skill: SkillSummary;
+}) {
+ const queryClient = useQueryClient();
+ const sourceKey = [...tenantKeys.skills(tenantId), skill.assetId, "source"] as const;
+ const source = useQuery({
+ queryKey: sourceKey,
+ queryFn: () => readSkillSource(tenantId, skill.assetId, skill.name),
+ });
+
+ const save = useMutation({
+ mutationFn: (content: string) => writeSkillSource(tenantId, skill.assetId, skill.name, content),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: sourceKey });
+ toast("Saved SKILL.md");
+ },
+ onError: (cause) => toast(`Couldn't save SKILL.md: ${errorText(cause)}`),
+ });
+
+ if (source.isError) {
+ return (
+
+ }
+ title="Couldn't load SKILL.md"
+ description={errorText(source.error)}
+ actions={[{ label: "Retry", onClick: () => void source.refetch() }]}
+ />
+
+ );
+ }
+
+ return (
+
+ {source.data === undefined ? (
+
+ ) : (
+ save.mutate(content)}
+ />
+ )}
+
+ );
+}
+
+/** The draft textarea and Save button for one fetched `SKILL.md` value.
+ * Keyed by its caller on that value, so `useState(initial)` seeds once
+ * per fetch/save cycle rather than through an effect. */
+function SkillDraftEditor({
+ initial,
+ saving,
+ onSave,
+}: {
+ readonly initial: string;
+ readonly saving: boolean;
+ readonly onSave: (content: string) => void;
+}) {
+ const [draft, setDraft] = useState(initial);
+ const dirty = draft !== initial;
+
+ return (
+
+ );
+}
+
/**
* Mount at `/skills/:name`: resolves the workbench this skill is read from
* and the name the route carries. The page owns its own stage chrome.
diff --git a/apps/web/src/skill-source.ts b/apps/web/src/skill-source.ts
new file mode 100644
index 000000000..093b84765
--- /dev/null
+++ b/apps/web/src/skill-source.ts
@@ -0,0 +1,69 @@
+// A skill asset's SKILL.md content, read and written over its stock
+// smart-HTTP git remote — the same path `agent-source-read.ts` and
+// `agent-deploy.ts` use for workflow assets, mirrored here for `kind:
+// "skill"` assets since the stock asset routes carry only metadata.
+import { fetchSourceFileOrEmpty } from "./git-fetch";
+import { pushSourceTree } from "./git-push";
+import { withGitToken } from "./git-token";
+
+export const SKILL_SOURCE_PATH = "SKILL.md";
+
+const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
+const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
+
+function skillAssetUrl(tenantId: string, assetName: string): string {
+ return new URL(
+ `/api/tenants/${encodeURIComponent(tenantId)}/assets/skill/${assetName}.git`,
+ globalThis.location.origin,
+ ).toString();
+}
+
+/** Mints a read-only token and fetches `SKILL.md` off the asset's `main`.
+ * Returns `""` for a fresh asset (no commits yet) or one whose `main`
+ * doesn't carry `SKILL.md` — neither is an error, both render an empty
+ * editor. */
+export async function readSkillSource(
+ tenantId: string,
+ assetId: string,
+ assetName: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ return withGitToken({
+ tenantId,
+ assetId,
+ actions: ["can_read"],
+ lifetimeMs: READ_TOKEN_LIFETIME_MS,
+ fetchImpl,
+ use: (token) =>
+ fetchSourceFileOrEmpty({
+ url: skillAssetUrl(tenantId, assetName),
+ token,
+ filepath: SKILL_SOURCE_PATH,
+ }),
+ });
+}
+
+/** Mints a push token and commits `content` as `SKILL.md` on the asset's
+ * `main`. Returns the new commit sha. */
+export async function writeSkillSource(
+ tenantId: string,
+ assetId: string,
+ assetName: string,
+ content: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ return withGitToken({
+ tenantId,
+ assetId,
+ actions: ["can_read", "can_push"],
+ lifetimeMs: PUSH_TOKEN_LIFETIME_MS,
+ fetchImpl,
+ use: (token) =>
+ pushSourceTree({
+ url: skillAssetUrl(tenantId, assetName),
+ token,
+ tree: { [SKILL_SOURCE_PATH]: content },
+ message: "Update SKILL.md",
+ }),
+ });
+}