diff --git a/CLAUDE.md b/CLAUDE.md
index d6761b6..41a59b3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
|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 |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.
diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts
index dd84a27..fe6c9d7 100644
--- a/apps/web/app/lib/releases/ingest-release.ts
+++ b/apps/web/app/lib/releases/ingest-release.ts
@@ -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`,
);
}
diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts
index f19605b..33efc2b 100644
--- a/apps/web/app/lib/releases/release-read.ts
+++ b/apps/web/app/lib/releases/release-read.ts
@@ -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,
diff --git a/apps/web/tests/creator/cli-visibility-roundtrip.test.ts b/apps/web/tests/creator/cli-visibility-roundtrip.test.ts
new file mode 100644
index 0000000..a88f668
--- /dev/null
+++ b/apps/web/tests/creator/cli-visibility-roundtrip.test.ts
@@ -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([]);
+ });
+});
diff --git a/apps/web/tests/releases/release-owner-access.test.ts b/apps/web/tests/releases/release-owner-access.test.ts
index c0825de..ef654be 100644
--- a/apps/web/tests/releases/release-owner-access.test.ts
+++ b/apps/web/tests/releases/release-owner-access.test.ts
@@ -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);
});
diff --git a/apps/web/tests/releases/release-private-publish.test.ts b/apps/web/tests/releases/release-private-publish.test.ts
index 562c27f..d88c6ff 100644
--- a/apps/web/tests/releases/release-private-publish.test.ts
+++ b/apps/web/tests/releases/release-private-publish.test.ts
@@ -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);
diff --git a/apps/web/tests/releases/release-read.test.ts b/apps/web/tests/releases/release-read.test.ts
index 42d497c..89511a6 100644
--- a/apps/web/tests/releases/release-read.test.ts
+++ b/apps/web/tests/releases/release-read.test.ts
@@ -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" }),
],
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 40e47e3..6e37e6e 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -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) |
@@ -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:
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index bea3b44..098840a 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -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
@@ -458,7 +458,7 @@ not a local path (or no source at all) runs the deprecated import (see
| `--kind ` | `skill`, `hook-pack` or `bundle` | `skill` |
| `--repo ` | Provenance repository URL | `origin` remote (https form) |
| `--script ` | Declare a packaged script (repeatable) | — |
-| `--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 ` | `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 ` | 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 ` | Registry base URL; `https` only (`http` only for localhost) | configured base URL |
| `--idempotency-key ` | Sends `Idempotency-Key`: a retry with the same key and bytes replays the first answer; other bytes are refused | — |
@@ -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> ` 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 `/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 ` | `set` only: expected current visibility; the change fails with `visibility_conflict` if it differs | read with `get` |
+| `--registry ` | 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`
diff --git a/packages/cli/README.md b/packages/cli/README.md
index f5ac9d3..ec709df 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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 `, `promote`
+### Release commands: `validate`, `pack`, `publish `, `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 `)
> 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
diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts
index e21444e..24476a1 100644
--- a/packages/cli/src/commands/release-publish.ts
+++ b/packages/cli/src/commands/release-publish.ts
@@ -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';
@@ -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) {
diff --git a/packages/cli/src/commands/visibility.test.ts b/packages/cli/src/commands/visibility.test.ts
new file mode 100644
index 0000000..4813137
--- /dev/null
+++ b/packages/cli/src/commands/visibility.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { runVisibilityGet, runVisibilitySet } from './visibility.js';
+import { withVisibilityMismatchHint } from '../lib/visibility-hints.js';
+import { CliError } from '../lib/release-cli.js';
+
+const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+const REG = { registry: 'https://reg.test' };
+const LIST = 'https://reg.test/api/v1/packages/@alpha/demo/releases';
+const SET = 'https://reg.test/api/v1/packages/@alpha/demo/visibility';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+});
+
+describe('skillx visibility get', () => {
+ it('reads the visibility from the release list, with the API key when one is set', async () => {
+ vi.stubEnv('SKILLX_API_KEY', 'sx_owner');
+ const fetchMock = vi.fn(async () => json({ identity: '@alpha/demo', visibility: 'private', releases: [] }));
+ vi.stubGlobal('fetch', fetchMock);
+ expect(await runVisibilityGet('@alpha/demo', REG)).toEqual({ identity: '@alpha/demo', visibility: 'private' });
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe(LIST);
+ expect((init.headers as Record).Authorization).toBe('Bearer sx_owner');
+ });
+
+ it('fails clearly against a registry that does not report visibility', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => json({ identity: '@alpha/demo', releases: [] })));
+ await expect(runVisibilityGet('@alpha/demo', REG)).rejects.toMatchObject({ code: 'api_error', extra: { hint: expect.stringContaining('--from') } });
+ });
+
+ it('refuses a malformed package name as a usage error', async () => {
+ await expect(runVisibilityGet('alpha/demo', REG)).rejects.toMatchObject({ code: 'usage_error' });
+ });
+});
+
+describe('skillx visibility set', () => {
+ it('reads the current visibility, then posts the compare-and-set with the API key', async () => {
+ vi.stubEnv('SKILLX_API_KEY', 'sx_owner');
+ const fetchMock = vi.fn(async (url: string) =>
+ url === LIST ? json({ identity: '@alpha/demo', visibility: 'public', releases: [] }) : json({ identity: '@alpha/demo', from: 'public', to: 'private', visibility: 'private', changedAt: 't' }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ expect(await runVisibilitySet('@alpha/demo', 'private', REG)).toMatchObject({ from: 'public', to: 'private', visibility: 'private', changed: true });
+ const [url, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit];
+ expect(url).toBe(SET);
+ expect(init.method).toBe('POST');
+ expect(JSON.parse(String(init.body))).toEqual({ from: 'public', to: 'private' });
+ expect((init.headers as Record).Authorization).toBe('Bearer sx_owner');
+ });
+
+ it('uses --from without reading, and writes nothing when the stored visibility already matches', async () => {
+ vi.stubEnv('SKILLX_API_KEY', 'sx_owner');
+ const fetchMock = vi.fn(async () => json({ visibility: 'unlisted' }));
+ vi.stubGlobal('fetch', fetchMock);
+ await runVisibilitySet('@alpha/demo', 'unlisted', { ...REG, from: 'public' });
+ expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([SET]);
+ // --from equal to the target is confirmed against the registry, never trusted.
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValueOnce(json({ visibility: 'private' }));
+ expect(await runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'private' })).toMatchObject({ changed: false, visibility: 'private' });
+ expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([LIST]);
+ fetchMock.mockResolvedValueOnce(json({ visibility: 'public' }));
+ await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'private' })).rejects.toMatchObject({
+ code: 'api_error',
+ extra: { details: { code: 'visibility_conflict', stored: 'public' } },
+ });
+ expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([LIST, LIST]);
+ });
+
+ it('validates the visibility and --from before any request', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('SKILLX_API_KEY', 'sx_owner');
+ await expect(runVisibilitySet('@alpha/demo', 'internal', REG)).rejects.toMatchObject({ code: 'usage_error' });
+ await expect(runVisibilitySet('@alpha/demo', 'public', { ...REG, from: 'Public' })).rejects.toMatchObject({ code: 'usage_error' });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('names the dashboard when the registry refuses a widening, and the next step for other refusals', async () => {
+ vi.stubEnv('SKILLX_API_KEY', 'sx_owner');
+ const fetchMock = vi.fn(async () => json({ error: { code: 'visibility.approval_required', message: 'needs approval' } }, 403));
+ vi.stubGlobal('fetch', fetchMock);
+ await expect(runVisibilitySet('@alpha/demo', 'public', { ...REG, from: 'private' })).rejects.toMatchObject({
+ code: 'auth_error',
+ extra: { hint: expect.stringContaining('https://reg.test/dashboard'), details: { status: 403, code: 'visibility.approval_required' } },
+ });
+ fetchMock.mockResolvedValueOnce(json({ error: { code: 'visibility_conflict', message: 'changed' } }, 409));
+ await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'public' })).rejects.toMatchObject({
+ code: 'api_error',
+ extra: { hint: expect.stringContaining('skillx visibility get @alpha/demo') },
+ });
+ fetchMock.mockResolvedValueOnce(json({ error: { code: 'invalid_api_key', message: 'scoped' } }, 401));
+ await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'public' })).rejects.toMatchObject({
+ code: 'auth_error',
+ extra: { hint: expect.stringContaining('unscoped account API key') },
+ });
+ });
+});
+
+describe('publish visibility mismatch hint', () => {
+ it('adds the next step to release.visibility_mismatch and leaves other errors alone', () => {
+ const mismatch = new CliError('api_error', 'registry answered 409', { details: { status: 409, code: 'release.visibility_mismatch' } });
+ const hinted = withVisibilityMismatchHint(mismatch, 'https://reg.test', '@alpha/demo', 'public') as CliError;
+ expect(hinted).toMatchObject({ code: 'api_error', message: 'registry answered 409', extra: { details: { code: 'release.visibility_mismatch' } } });
+ expect(hinted.extra.hint).toContain('skillx visibility set @alpha/demo public');
+ expect(hinted.extra.hint).toContain('https://reg.test/dashboard');
+ const other = new CliError('api_error', 'x', { details: { status: 409, code: 'release.version_exists' } });
+ expect(withVisibilityMismatchHint(other, 'https://reg.test', '@alpha/demo', 'public')).toBe(other);
+ });
+});
diff --git a/packages/cli/src/commands/visibility.ts b/packages/cli/src/commands/visibility.ts
new file mode 100644
index 0000000..947f722
--- /dev/null
+++ b/packages/cli/src/commands/visibility.ts
@@ -0,0 +1,131 @@
+import { Command } from 'commander';
+import chalk from 'chalk';
+import { PACKAGE_VISIBILITIES, type PackageVisibility } from '@skillx/contracts';
+import { CliError, parsePackageName, registryFetch, runCommand, usageError } from '../lib/release-cli.js';
+import { getApiKey } from '../utils/config-store.js';
+import { dashboardUrl, visibilityHint } from '../lib/visibility-hints.js';
+import { resolveRegistry } from './release-publish.js';
+
+interface VisibilityOptions {
+ registry?: string;
+ json?: boolean;
+}
+
+interface SetOptions extends VisibilityOptions {
+ from?: string;
+}
+
+function parseVisibility(raw: string | undefined, label: string): PackageVisibility {
+ if (!(PACKAGE_VISIBILITIES as readonly string[]).includes(raw ?? '')) {
+ throw usageError(`${label} must be one of: ${PACKAGE_VISIBILITIES.join(', ')}, got ${JSON.stringify(raw ?? '')}`);
+ }
+ return raw as PackageVisibility;
+}
+
+function packageName(raw: string) {
+ try {
+ return parsePackageName(raw);
+ } catch {
+ throw usageError(`expected @publisher/slug, got ${JSON.stringify(raw)}`);
+ }
+}
+
+async function ownerFetch(url: string, init: RequestInit, base: string, name: string): Promise> {
+ try {
+ return await registryFetch(url, init);
+ } catch (error) {
+ if (!(error instanceof CliError)) throw error;
+ const hint = visibilityHint((error.extra.details ?? {}) as { status?: number; code?: string | null }, base, name) ?? error.extra.hint;
+ throw new CliError(error.code, error.message, { ...error.extra, ...(hint ? { hint } : {}) });
+ }
+}
+
+function authHeaders(): Record {
+ const apiKey = getApiKey();
+ return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
+}
+
+/** Reads the stored visibility from the release list (`GET .../releases`), as the caller may see it. */
+export async function runVisibilityGet(target: string, options: VisibilityOptions) {
+ const name = packageName(target);
+ const base = resolveRegistry(options.registry);
+ const body = await ownerFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { headers: authHeaders() }, base, name.name);
+ if (typeof body.visibility !== 'string') {
+ throw new CliError('api_error', 'the registry did not report the package visibility', {
+ hint: `Upgrade the registry, or pass --from to \`skillx visibility set\`; the dashboard shows it: ${dashboardUrl(base)}.`,
+ });
+ }
+ return { identity: name.name, visibility: parseVisibility(body.visibility, 'the registry visibility') };
+}
+
+/**
+ * Changes a package's visibility (`POST .../visibility`, a compare-and-set
+ * audited by the registry). Needs the owner's unscoped API key. A key may
+ * narrow (to `unlisted` from `public`, anything to `private`); widening needs
+ * a signed-in approval in the web dashboard, so the registry refuses it here
+ * and the error names the dashboard.
+ */
+export async function runVisibilitySet(target: string, to: string, options: SetOptions) {
+ const name = packageName(target);
+ const next = parseVisibility(to, 'visibility');
+ const expected = options.from === undefined ? undefined : parseVisibility(options.from, '--from');
+ const base = resolveRegistry(options.registry);
+ const apiKey = getApiKey();
+ if (!apiKey) {
+ throw new CliError('auth_error', 'an API key is required', {
+ hint: `Set SKILLX_API_KEY or run \`skillx config set-key\` with an unscoped account key; or use ${dashboardUrl(base)}.`,
+ });
+ }
+ const from = expected ?? (await runVisibilityGet(target, options)).visibility;
+ if (from === next) {
+ // `--from` is only a claim: confirm the stored value before reporting that nothing needs to change.
+ const stored = expected === undefined ? from : (await runVisibilityGet(target, options)).visibility;
+ if (stored !== next) {
+ throw new CliError('api_error', `${name.name} is ${stored}, not ${next} as --from expected`, {
+ details: { code: 'visibility_conflict', stored },
+ hint: visibilityHint({ code: 'visibility_conflict' }, base, name.name),
+ });
+ }
+ return { identity: name.name, from, to: next, visibility: next, changed: false };
+ }
+ const body = await ownerFetch(
+ `${base}/api/v1/packages/@${name.handle}/${name.slug}/visibility`,
+ { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: next }) },
+ base,
+ name.name,
+ );
+ return { ...body, identity: name.name, from, to: next, changed: true };
+}
+
+const registryOption = ['--registry ', 'registry base URL (default: configured base URL)'] as const;
+const jsonOption = ['--json', 'print the result as a JSON envelope'] as const;
+
+const getCommand = new Command('get')
+ .description('Show the visibility of a package you can read')
+ .argument('', '@publisher/slug')
+ .option(...registryOption)
+ .option(...jsonOption)
+ .action(async (target: string, options: VisibilityOptions) => {
+ await runCommand('visibility get', options.json, () => runVisibilityGet(target, options), (result) => {
+ console.log(`${result.identity}: ${chalk.cyan(result.visibility)}`);
+ });
+ });
+
+const setCommand = new Command('set')
+ .description('Change the visibility of your package (audited); making it more visible needs the web dashboard')
+ .argument('', '@publisher/slug')
+ .argument('', PACKAGE_VISIBILITIES.join(' | '))
+ .option('--from ', 'expected current visibility (default: read from the registry); the change fails if it differs')
+ .option(...registryOption)
+ .option(...jsonOption)
+ .action(async (target: string, to: string, options: SetOptions) => {
+ await runCommand('visibility set', options.json, () => runVisibilitySet(target, to, options), (result) => {
+ if (!result.changed) console.log(`${result.identity} is already ${chalk.cyan(result.visibility)}`);
+ else console.log(chalk.bold.green(`${result.identity}: ${result.from} -> ${result.to}`));
+ });
+ });
+
+export const visibilityCommand = new Command('visibility')
+ .description('Show or change the visibility (public, unlisted, private) of a published package')
+ .addCommand(getCommand)
+ .addCommand(setCommand);
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 9ac0e24..78112cf 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -12,6 +12,7 @@ import { importCommand } from './commands/import.js';
import { validateCommand } from './commands/validate.js';
import { createPackCommand } from './commands/pack.js';
import { promoteCommand } from './commands/promote.js';
+import { visibilityCommand } from './commands/visibility.js';
import { releaseCommand } from './commands/release-publish.js';
import { installCommand, listCommand, syncCommand, uninstallCommand, updateCommand } from './commands/install.js';
import { mcpCommand } from './commands/mcp.js';
@@ -42,6 +43,7 @@ program.addCommand(validateCommand);
program.addCommand(createPackCommand());
program.addCommand(publishCommand);
program.addCommand(promoteCommand);
+program.addCommand(visibilityCommand);
program.addCommand(releaseCommand);
for (const command of [installCommand, updateCommand, uninstallCommand, listCommand, syncCommand, mcpCommand]) program.addCommand(command);
program.addCommand(addCommand);
diff --git a/packages/cli/src/lib/visibility-hints.ts b/packages/cli/src/lib/visibility-hints.ts
new file mode 100644
index 0000000..005b258
--- /dev/null
+++ b/packages/cli/src/lib/visibility-hints.ts
@@ -0,0 +1,34 @@
+/**
+ * Next-step hints for package visibility failures, shared by `skillx
+ * visibility` and `skillx publish`: the CLI holds an API key, which may narrow
+ * a package's visibility; widening needs a signed-in approval in the web
+ * dashboard of the same registry.
+ */
+import type { PackageVisibility } from '@skillx/contracts';
+import { CliError } from './output.js';
+
+/** Where the owner changes what the CLI may not: the web dashboard of the registry. */
+export const dashboardUrl = (base: string) => `${base}/dashboard`;
+
+/** The next step for a registry refusal of a visibility change, by its error code. */
+export function visibilityHint(failure: { status?: number; code?: string | null }, base: string, name: string): string | undefined {
+ if (failure.status === 401) return `Use the owner's unscoped account API key (publish and other scoped keys are refused): \`skillx config set-key\`, or ${dashboardUrl(base)}.`;
+ switch (failure.code) {
+ case 'visibility.approval_required':
+ return `Making a package more visible needs a signed-in approval: open ${dashboardUrl(base)}.`;
+ case 'visibility_conflict':
+ return `The visibility changed meanwhile; check it with \`skillx visibility get ${name}\` and retry.`;
+ default:
+ return undefined;
+ }
+}
+
+/** Names the next step of a `release.visibility_mismatch` refusal: change the visibility first, then publish again. */
+export function withVisibilityMismatchHint(error: unknown, base: string, name: string, requested: PackageVisibility | undefined): unknown {
+ if (!(error instanceof CliError) || (error.extra.details as { code?: unknown } | undefined)?.code !== 'release.visibility_mismatch') return error;
+ const hint =
+ `Change the package visibility first, then publish again: \`skillx visibility set ${name} ${requested ?? ''}\` ` +
+ `(an account API key may make it less visible; making it more visible needs ${dashboardUrl(base)}), or publish without --visibility to keep it.`;
+ return new CliError(error.code, error.message, { ...error.extra, hint });
+}
+