Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Search algorithm: `./docs/search-algorithm.md`

**CLI `skillx use` resolution**: `author/skill` (two-part → DB slug `author-skill`) | `org/repo/skill` (three-part → DB slug `org-skill`, fallback register from GitHub) | `slug` (direct lookup, fallback search) | `"keywords"` (search mode). Flags: `--include-refs`, `--include-scripts` for extended content. Display logic split into `use-display.ts` module.

**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx import owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth (`skillx publish owner/repo` is a deprecated alias). Releases: `skillx validate|pack|publish <dir>|promote` (`skillx release pack|publish` are aliases), JSON envelope via `--json`.
**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx import owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth (`skillx publish owner/repo` is a deprecated alias). Releases: `skillx validate|pack|publish <dir>|promote|visibility get|set` (`skillx release pack|publish` are aliases), JSON envelope via `--json`.

**Styling**: Always dark theme. Use `bg-slate-900`, `text-white`, `text-mint`, `border-mint/20`. Geist Sans/Mono fonts. Lucide icons.

Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/lib/releases/ingest-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ type Kit = { bucket: R2Bucket; provenance: ReleaseProvenance | null };
*/
function assertVisibility(identity: string, requested: PackageVisibility | null, stored: string): void {
if (requested === null || requested === stored) return;
// Widening (out of private, or to public) needs a signed-in approval, so only the dashboard can do it.
const widening = stored === "private" || requested === "public";
const where = widening ? "in the dashboard (/dashboard)" : "with `skillx visibility set` or in the dashboard (/dashboard)";
throw releaseError(
409,
"release.visibility_mismatch",
`${identity} is ${stored}; publishing a release does not change the visibility of an existing package (requested ${requested})`,
`${identity} is ${stored}; publishing a release does not change the visibility of an existing package (requested ${requested}). Change it first ${where}, or publish without a visibility`,
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/lib/releases/release-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export async function readReleaseList(ctx: ReadContext, params: Params): Promise
return Response.json(
{
identity: list.identity,
visibility: list.visibility,
releases: list.releases.map((release) => ({
version: release.version,
channel: release.channel,
Expand Down
88 changes: 88 additions & 0 deletions apps/web/tests/creator/cli-visibility-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { releasePublishScope } from "~/lib/auth/api-key-scopes";
import { servePackageAction } from "~/lib/creator/owner-actions-http";
import { loader as listLoader } from "~/routes/api.v1.package-releases";
import { runVisibilityGet, runVisibilitySet } from "../../../../packages/cli/src/commands/visibility";
import { envOf, publish, useStateHarness } from "../releases/release-state-helpers";
import type { Harness } from "../releases/release-state-helpers";
import { ORIGIN, packageAudit, visibilityOf } from "./creator-helpers";

/**
* `skillx visibility get|set` against the real registry handlers: the CLI's
* fetch is routed in-process to the release list loader and the owner
* visibility action, so the request shape, the compare-and-set and the
* refusals are the registry's own. `user-a` owns `@alpha`.
*/
vi.mock("~/lib/auth/session-helpers", () => ({ getSession: async () => null }));

let h: Harness;
const REG = { registry: ORIGIN };

/** Routes the CLI's `fetch` to the registry handlers under test. */
function routeFetchToRegistry() {
vi.stubGlobal("fetch", async (input: string, init: RequestInit = {}) => {
const request = new Request(input, init);
const path = new URL(request.url).pathname;
const match = /^\/api\/v1\/packages\/(@[^/]+)\/([^/]+)\/(releases|visibility)$/.exec(path);
if (match === null) return new Response("not routed", { status: 599 });
const params = { scope: match[1]!, slug: match[2]! };
if (match[3] === "releases") return listLoader({ request, params, context: { cloudflare: { env: envOf() } } } as never);
return servePackageAction(request, envOf() as never, params, "visibility");
});
}

beforeEach(async () => {
h = await useStateHarness();
await publish();
routeFetchToRegistry();
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

describe("skillx visibility against the registry", () => {
it("narrows public -> unlisted -> private with the owner's account key, audited, and reads it back", async () => {
vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null));
expect(await runVisibilityGet("@alpha/demo", REG)).toEqual({ identity: "@alpha/demo", visibility: "public" });
expect(await runVisibilitySet("@alpha/demo", "unlisted", REG)).toMatchObject({ from: "public", to: "unlisted", visibility: "unlisted", changed: true });
expect(await runVisibilitySet("@alpha/demo", "private", REG)).toMatchObject({ from: "unlisted", to: "private", visibility: "private", changed: true });
// The owner still reads the now-private package.
expect(await runVisibilityGet("@alpha/demo", REG)).toEqual({ identity: "@alpha/demo", visibility: "private" });
expect(await runVisibilitySet("@alpha/demo", "private", REG)).toMatchObject({ changed: false });
expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private");
expect(packageAudit(h).map((row) => [row.from_value, row.to_value])).toEqual([
["public", "unlisted"],
["unlisted", "private"],
]);
});

it("refuses widening with a key and names the dashboard; nothing is written", async () => {
vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null));
await runVisibilitySet("@alpha/demo", "private", REG);
await expect(runVisibilitySet("@alpha/demo", "public", REG)).rejects.toMatchObject({
code: "auth_error",
extra: { details: { status: 403, code: "visibility.approval_required" }, hint: expect.stringContaining(`${ORIGIN}/dashboard`) },
});
expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private");
expect(packageAudit(h)).toHaveLength(1);
});

it("refuses a stale --from, a publish key, and a key of another account", async () => {
vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null));
await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "unlisted" })).rejects.toMatchObject({
code: "api_error",
extra: { details: { code: "visibility_conflict" }, hint: expect.stringContaining("skillx visibility get @alpha/demo") },
});
vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", [releasePublishScope("alpha")]));
await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "public" })).rejects.toMatchObject({
code: "auth_error",
extra: { hint: expect.stringContaining("unscoped account API key") },
});
vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-b", null));
await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "public" })).rejects.toMatchObject({ code: "not_found" });
expect(visibilityOf(h, "@alpha/demo").visibility).toBe("public");
expect(packageAudit(h)).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion apps/web/tests/releases/release-owner-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe("private package reads: the owner", () => {
expect(res.headers.get("Vary")).toBe("Authorization, Cookie");
}
const list = await (await api.list(PRIVATE, { key: ownerKey })).json();
expect(list).toMatchObject({ identity: PRIVATE, releases: [expect.objectContaining({ version: "1.0.0", releaseDigest: digest })] });
expect(list).toMatchObject({ identity: PRIVATE, visibility: "private", releases: [expect.objectContaining({ version: "1.0.0", releaseDigest: digest })] });
session.user = { id: "user-a" };
for (const res of await everyRead(PRIVATE, digest)) expect(res.status).toBe(200);
});
Expand Down
5 changes: 5 additions & 0 deletions apps/web/tests/releases/release-private-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ describe("publish visibility: never changed by a publish", () => {
const writes = h.r2.putLog.length;
const res = await publish("@alpha/notes", { visibility: requested, version: "1.1.0" }, " v2");
expect(res).toMatchObject({ status: 409, body: { error: { code: "release.visibility_mismatch" } } });
// The refusal names the next step: change the visibility first.
// Only a narrowing can be done with the CLI's key; a widening is approved in the dashboard.
expect(res.body.error.message).toContain("/dashboard");
if (requested === "private") expect(res.body.error.message).toContain("skillx visibility set");
else expect(res.body.error.message).not.toContain("skillx visibility set");
expect(h.r2.putLog.length).toBe(writes);
expect(h.releaseRows()).toHaveLength(1);
expect(storedVisibility("@alpha/notes")).toBe(stored);
Expand Down
1 change: 1 addition & 0 deletions apps/web/tests/releases/release-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("release read: happy path over real HTTP bodies", () => {
expect(res.headers.get("Cache-Control")).toBe("public, max-age=60, no-transform");
expect(await res.json()).toEqual({
identity: "@alpha/demo",
visibility: "public",
releases: [
expect.objectContaining({ version: "1.0.0", channel: "stable", state: "published", releaseDigest: digest, createdAt: "2026-10-13T12:00:00.000Z" }),
],
Expand Down
5 changes: 3 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1302,7 +1302,7 @@ Read-only, behind the same flag: unless `SKILLX_RELEASES_API` is `"1"` or

| Endpoint | Returns |
|----------|---------|
| `GET /api/v1/packages/@{publisher}/{slug}/releases` | JSON list, newest first (at most 500): `version`, `channel`, `state`, `releaseDigest`, `artifactDigest`, `artifactSize`, `createdAt`. Published and yanked releases; a release is hidden when its tree is quarantined anywhere (it would answer `403`), so the list never offers one |
| `GET /api/v1/packages/@{publisher}/{slug}/releases` | `{identity, visibility, releases}`: the package visibility (`public`, `unlisted` or `private`, as stored) and the list, newest first (at most 500): `version`, `channel`, `state`, `releaseDigest`, `artifactDigest`, `artifactSize`, `createdAt`. Published and yanked releases; a release is hidden when its tree is quarantined anywhere (it would answer `403`), so the list never offers one |
| `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}` | The signed `skillx.release/v1` or `v2` manifest bytes (new resolution: only `published`) |
| `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}/status` | The signed `skillx.release-status/v1` document of that release, in every state (see [Signed release status](#signed-release-status)) |
| `GET /api/v1/releases/{releaseDigest}/manifest[?version=&package=]` | The same manifest, by tree digest (locked clients) |
Expand Down Expand Up @@ -1751,7 +1751,8 @@ The publisher route answers `{"handle", "from", "to", "state", "changedAt"}`.
### Managing packages and kits (owner)

The owner manages a package or personal kit from the web dashboard
(`/dashboard`) or with these endpoints. All are `POST` with an
(`/dashboard`), with these endpoints, or (package visibility) with
`skillx visibility set` (see [cli-reference.md](./cli-reference.md)). All are `POST` with an
`application/json` body, behind `SKILLX_RELEASES_API` (`404 not_found` while
it is off; kits also need `SKILLX_PERSONAL_KITS_API`, else `404
kit.api_disabled`), other methods `405`. Every answer is `Cache-Control:
Expand Down
44 changes: 42 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ skillx uninstall add:pdf

---

### Release commands: `validate`, `pack`, `publish`, `promote`
### Release commands: `validate`, `pack`, `publish`, `promote`, `visibility`

These commands work on immutable, digest-pinned releases. Like every other
command they take `--json` and follow
Expand Down Expand Up @@ -458,7 +458,7 @@ not a local path (or no source at all) runs the deprecated import (see
| `--kind <kind>` | `skill`, `hook-pack` or `bundle` | `skill` |
| `--repo <https-url>` | Provenance repository URL | `origin` remote (https form) |
| `--script <path>` | Declare a packaged script (repeatable) | — |
| `--visibility <visibility>` | `public`, `unlisted` (readable by anyone who knows the name, never listed) or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`); change it in the web dashboard (`/dashboard`) instead. Publishing to a deleted package is refused with `package.deleted` | — |
| `--visibility <visibility>` | `public`, `unlisted` (readable by anyone who knows the name, never listed) or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`); the error's `hint` names the next step: change it first with `skillx visibility set` (a key may only make a package less visible) or in the web dashboard (`/dashboard`), or publish without `--visibility`. Publishing to a deleted package is refused with `package.deleted` | — |
| `--kit-origin <origin>` | Publish an `@agentkit/*` kit release (`remote-signed` or `local-dev`), signed as `skillx.release/v2`; needs a `releases:publish:@agentkit` key; the slug omits `ak-` (`@agentkit/plan`). Installing v2 releases needs skillx-sh 0.7.0 or later | — |
| `--registry <url>` | Registry base URL; `https` only (`http` only for localhost) | configured base URL |
| `--idempotency-key <key>` | Sends `Idempotency-Key`: a retry with the same key and bytes replays the first answer; other bytes are refused | — |
Expand Down Expand Up @@ -507,6 +507,46 @@ demote. Every attempt is audited.
```bash
skillx promote @acme/my-skill@1.2.0 --from beta --to stable --reason "soak passed"
```
### `skillx visibility get|set <@publisher/slug>`

Shows or changes who can read a published package: `public`, `unlisted`
(readable by anyone who knows the name, never listed) or `private`.

- `skillx visibility get <@publisher/slug>` reads it from
`GET .../releases` (`visibility` member), with the API key when one is set,
so the owner can read a private package.
- `skillx visibility set <@publisher/slug> <visibility>` posts the
compare-and-set `POST /api/v1/packages/@publisher/slug/visibility`
(`{from, to}`, audited; see the
[API reference](./api-reference.md#managing-packages-and-kits-owner)).
`from` is `--from` when given, else read first with `get`. When the target equals
`from`, the CLI confirms the stored visibility (also when `--from` was
given) and sends no write (`data.changed: false`); a stored value that
differs is `api_error` with `error.details.code` `visibility_conflict`.

It needs the owner's **unscoped** account API key (`SKILLX_API_KEY` or
`skillx config set-key`); a publish key (`releases:publish:@handle`) or any
other scoped key is refused (`auth_error`, exit `5`). A key may only make a
package less visible (`public` to `unlisted`, anything to `private`). Making it
more visible (out of `private`, or to `public`) needs a signed-in approval in
the web dashboard: the registry answers `403 visibility.approval_required`
(`auth_error`, exit `5`) and the `hint` names `<registry>/dashboard`. A
concurrent change is `409 visibility_conflict` (`api_error`, exit `1`; the hint
says to re-read with `get`), and an unknown package, or one you do not own, is
`not_found` (exit `3`).

| Flag | Description | Default |
|------|-------------|---------|
| `--from <visibility>` | `set` only: expected current visibility; the change fails with `visibility_conflict` if it differs | read with `get` |
| `--registry <url>` | Registry base URL | configured base URL |
| `--json` | Print the result envelope: `get` `{identity, visibility}`, `set` `{identity, from, to, visibility, changed, changedAt?}` | — |

```bash
skillx visibility get @acme/my-skill
skillx visibility set @acme/my-skill private # narrowing works with an account key
skillx visibility set @acme/my-skill public --json # widening: auth_error, hint names /dashboard
```

---

### `skillx library list`
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ skillx import owner/repo --scan # scan all SKILL.md files
skillx import --dry-run # preview without importing
```

### Release commands: `validate`, `pack`, `publish <dir>`, `promote`
### Release commands: `validate`, `pack`, `publish <dir>`, `promote`, `visibility`

Build and upload immutable, digest-pinned releases. With `--json` each prints one
envelope, `{"ok": true, "data": {...}}` or `{"ok": false, "error": {...}}`, and the
exit code follows it. See the [CLI reference](https://github.com/nextlevelbuilder/skillx/blob/main/docs/cli-reference.md).

Change a package's visibility with `skillx visibility set @acme/my-skill private`
(`skillx visibility get` shows it). It needs your unscoped account API key, which may
only make a package less visible; making it more visible is approved in the web
dashboard (`/dashboard`).

> **Breaking:** `skillx release publish --json` (now an alias of `skillx publish <dir>`)
> used to print the bare release object, and failures only as text on stderr with exit
> code `1`. It now prints the envelope: read the result from `.data` and a failure from
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/release-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { KIT_ORIGINS } from '@skillx/skill-tree';
import { PACKAGE_VISIBILITIES } from '@skillx/contracts';
import type { PackageVisibility } from '@skillx/contracts';
import { getBaseUrl } from '../utils/config-store.js';
import { withVisibilityMismatchHint } from '../lib/visibility-hints.js';
import { CliError, parsePackageName, publishCredential, registryFetch, runCommand, usageError } from '../lib/release-cli.js';
import { createPackCommand, packOrFail } from './pack.js';
import { packSummary } from './release-pack.js';
Expand Down Expand Up @@ -163,7 +164,9 @@ export async function runReleasePublish(input: string, options: ReleasePublishOp
}),
};
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey;
const body = await registryFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { method: 'POST', headers, body: packed.tar });
const body = await registryFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { method: 'POST', headers, body: packed.tar }).catch((error: unknown) => {
throw withVisibilityMismatchHint(error, base, name.name, visibility);
});
try {
assertRegistryDigests(packed, body);
} catch (error) {
Expand Down
Loading
Loading