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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ cue discover search <query> # find skills on GitHub
cue discover install <skill> # install one
cue lint-skill <path> --fix # validate a SKILL.md

# Marketplace (push your own to cuecards.cc)
cue marketplace login --token <t> # save the API token from the studio → API view
cue marketplace publish profile ship-fast # push a profile / skill / mcp for everyone

# Health
cue doctor --fix # diff declared vs actual state, auto-repair
cue optimizer # dashboard: skills, MCPs, CLIs, usage per profile
Expand All @@ -196,6 +200,41 @@ cue failures --propose # let Claude draft profile improvements from failur

---

## API

cuecards.cc gives every account a free, per-user **API token** and a small HTTP
API. Mint a token in the studio (`cue dashboard` → **API** view, or
[cuecards.cc](https://cuecards.cc)), then use it from your own machine to push
profiles, skills, and MCPs to the community marketplace.

```bash
# 1. Save the token (verifies it against the server before writing ~/.config/cue/credentials.json)
cue marketplace login --token cue_sk_… # or: export CUE_API_TOKEN=cue_sk_…
cue marketplace whoami # confirm which account you're authenticated as

# 2. Push something to the marketplace
cue marketplace publish profile ship-fast --tags build,review
cue marketplace publish skill seo-audit --source-url https://github.com/me/skills
cue marketplace publish mcp my-server --desc "internal tooling MCP"
```

Authenticate HTTP calls with a Bearer header (the token also works as
`x-api-key`):

```bash
curl https://cuecards.cc/api/v1/me -H "Authorization: Bearer $CUE_API_TOKEN"
curl https://cuecards.cc/api/v1/community # public community catalog (no auth)
curl https://cuecards.cc/api/v1/community -H "Authorization: Bearer $CUE_API_TOKEN" \
-X POST -H 'content-type: application/json' \
-d '{"type":"profile","name":"ship-fast","tags":["build"]}'
```

Install commands are **derived server-side** — a submission can never inject an
arbitrary `add` string. See [web/AUTH.md](web/AUTH.md) for the auth model,
self-hosting, and `CUE_API_URL` (point the CLI at a different deployment).

---

## Install options

| Path | Command |
Expand Down Expand Up @@ -239,7 +278,7 @@ No. Everything cue computes — including the per-skill usage bars in `cue optim
<summary><b>What does cue NOT do?</b></summary>

- It doesn't modify or repackage the Claude Code / Codex binaries.
- It doesn't host a remote marketplace — skills live in your repo or come from open source.
- It doesn't lock you in — skills live in your repo or come from open source; the optional [cuecards.cc marketplace](#api) is just a sharing layer you push to with your own token, never a requirement.
- It doesn't coordinate multi-agent runs (that's [colony](https://github.com/recodeee/colony) + [gitguardex](https://github.com/recodeee/gitguardex), layered on top via the parallel-agents tier).
</details>

Expand Down
2 changes: 1 addition & 1 deletion resources/skills
172 changes: 172 additions & 0 deletions src/commands/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,162 @@ async function cmdDiscover(
return 0;
}

// ---------------------------------------------------------------------------
// Hosted marketplace: login / whoami / publish
//
// "Get an API token on cuecards.cc, then push your skills / profiles / mcps
// from your own machine." These talk to the hosted API (not Smithery), using
// the user's bearer token. Install commands are derived server-side, so a
// submission can never inject an arbitrary install string.
// ---------------------------------------------------------------------------

const PUBLISH_TYPES = ["profile", "workflow", "skill", "cli", "mcp", "plugin"] as const;
type PublishType = (typeof PUBLISH_TYPES)[number];

/** Pull the value of `--flag value` out of an arg list (returns null if absent). */
function flagValue(args: string[], flag: string): string | null {
const i = args.indexOf(flag);
return i >= 0 && args[i + 1] && !args[i + 1]!.startsWith("--") ? args[i + 1]! : null;
}

async function apiFetch(
url: string,
token: string | null,
init: { method?: string; body?: unknown } = {},
): Promise<{ status: number; json: any }> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(url, {
method: init.method ?? "GET",
headers,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
});
let json: any = null;
try { json = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, json };
}

/** `cue marketplace login --token <t>` — save the token to ~/.config/cue. */
async function cmdLogin(args: string[]): Promise<number> {
const { resolveApiUrl, saveCredentials, loadCredentials } = await import("../lib/cue-credentials");
const token = flagValue(args, "--token") ?? process.env.CUE_API_TOKEN ?? null;
if (!token) {
process.stderr.write(
"Usage: cue marketplace login --token <token>\n\n" +
"Create a token in the cuecards.cc studio → API view, then paste it here.\n" +
"(or set CUE_API_TOKEN in your environment.)\n",
);
return 1;
}
const apiUrl = resolveApiUrl(loadCredentials());

// Verify the token works before persisting it.
const me = await apiFetch(`${apiUrl}/api/v1/me`, token);
if (me.status !== 200 || !me.json?.ok) {
process.stderr.write(`${red("✗")} token rejected by ${apiUrl} (HTTP ${me.status}). Not saved.\n`);
return 1;
}
const path = saveCredentials({ apiUrl, token });
process.stdout.write(`${green("✓")} logged in as ${bold(me.json.data.email)} — token saved to ${dim(path)}\n`);
return 0;
}

/** `cue marketplace whoami` — print the authenticated account. */
async function cmdWhoami(args: string[]): Promise<number> {
const { resolveApiUrl, resolveToken, loadCredentials } = await import("../lib/cue-credentials");
const token = resolveToken(flagValue(args, "--token"));
const apiUrl = resolveApiUrl(loadCredentials());
if (!token) {
process.stderr.write(`${yellow("not logged in")} — run ${bold("cue marketplace login --token <token>")}\n`);
return 1;
}
const me = await apiFetch(`${apiUrl}/api/v1/me`, token);
if (me.status !== 200 || !me.json?.ok) {
process.stderr.write(`${red("✗")} token invalid or expired (HTTP ${me.status}).\n`);
return 1;
}
process.stdout.write(`${green("✓")} ${bold(me.json.data.email)} ${dim(`(${me.json.data.id})`)} @ ${apiUrl}\n`);
return 0;
}

/** Best-effort description for a local profile (used when --desc is omitted). */
async function deriveProfileDescription(name: string): Promise<string> {
try {
const { loadProfile } = await import("../lib/profile-loader");
const p = await loadProfile(name);
return p.description ?? "";
} catch { return ""; }
}

/**
* `cue marketplace publish <type> <name>` — push one item to the marketplace.
* Flags: --desc, --tags a,b,c, --source-url, --token, --api.
*/
async function cmdPublish(args: string[], json: boolean): Promise<number> {
const { resolveApiUrl, resolveToken, loadCredentials } = await import("../lib/cue-credentials");

// type + name are the first two non-flag args that aren't the value of a
// preceding --flag.
const flagsWithValues = new Set(["--desc", "--tags", "--source-url", "--token", "--api"]);
const pos: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i]!;
if (a.startsWith("--")) { if (flagsWithValues.has(a)) i++; continue; }
pos.push(a);
}

const type = pos[0] as PublishType | undefined;
const name = pos[1];
if (!type || !PUBLISH_TYPES.includes(type) || !name) {
process.stderr.write(
"Usage: cue marketplace publish <type> <name> [--desc <text>] [--tags a,b,c] [--source-url <https://…>]\n\n" +
` <type> one of: ${PUBLISH_TYPES.join(", ")}\n` +
" <name> the profile / skill / mcp name to publish\n\n" +
"Examples:\n" +
" cue marketplace publish profile ship-fast --tags build,review\n" +
" cue marketplace publish skill seo-audit --source-url https://github.com/me/skills\n",
);
return 1;
}

const token = resolveToken(flagValue(args, "--token"));
if (!token) {
process.stderr.write(
`${yellow("not logged in")} — run ${bold("cue marketplace login --token <token>")} first\n` +
`(create the token in the cuecards.cc studio → API view, or set CUE_API_TOKEN).\n`,
);
return 1;
}

const apiUrl = (flagValue(args, "--api") ?? resolveApiUrl(loadCredentials())).replace(/\/+$/, "");
let description = flagValue(args, "--desc") ?? "";
if (!description && type === "profile") description = await deriveProfileDescription(name);
const tags = (flagValue(args, "--tags") ?? "").split(",").map((t) => t.trim()).filter(Boolean);
const sourceUrl = flagValue(args, "--source-url") ?? undefined;

if (!json) process.stdout.write(`Publishing ${bold(type)} "${bold(name)}" to ${apiUrl}…\n`);

const res = await apiFetch(`${apiUrl}/api/v1/community`, token, {
method: "POST",
body: { type, name, description, tags, sourceUrl },
});

if (res.status !== 200 || !res.json?.ok) {
const err = res.json?.error ?? `HTTP ${res.status}`;
if (json) process.stdout.write(JSON.stringify({ ok: false, error: err }) + "\n");
else process.stderr.write(`${red("✗ publish failed:")} ${err}\n`);
return 1;
}

if (json) { process.stdout.write(JSON.stringify(res.json.data, null, 2) + "\n"); return 0; }

const item = res.json.data;
process.stdout.write(`\n${green("✓ published")} as ${bold(item.handle + "/" + item.name)}\n`);
process.stdout.write(` install: ${dim(item.add)}\n`);
process.stdout.write(` it's now in the community marketplace — anyone running cue can find and install it.\n\n`);
return 0;
}

export async function run(args: string[]): Promise<number> {
if (args.includes("-h") || args.includes("--help")) {
process.stdout.write(`cue marketplace — search and install MCPs + skills
Expand Down Expand Up @@ -1058,10 +1214,20 @@ Subcommands:
list-tools [conn] List tools from connected MCPs
find-tools <query> Search tools by intent

Publish (push to the hosted marketplace at cuecards.cc):
login --token <t> Save your API token (create it in the studio → API view,
or set CUE_API_TOKEN). Verifies before saving.
whoami Show the account your token authenticates as.
publish <type> <name> Push a profile / skill / mcp to the marketplace.
Flags: --desc <text>, --tags a,b,c, --source-url <url>,
--token <t>, --api <url>. Types: ${PUBLISH_TYPES.join(", ")}.

Examples:
cue marketplace search "github"
cue marketplace install-mcp exa
cue marketplace search-skills "kubernetes"
cue marketplace login --token cue_sk_…
cue marketplace publish profile ship-fast --tags build,review
`);
return 0;
}
Expand All @@ -1087,6 +1253,12 @@ Examples:
return cmdListTools(rest[1] ?? "", json);
case "find-tools":
return cmdFindTools(rest.slice(1).join(" ") || "", json);
case "login":
return cmdLogin(rest.slice(1));
case "whoami":
return cmdWhoami(rest.slice(1));
case "publish":
return cmdPublish(rest.slice(1), json);
case "discover": {
const previewIdx = rest.indexOf("--pr-preview");
if (previewIdx >= 0) {
Expand Down
74 changes: 74 additions & 0 deletions src/lib/cue-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
DEFAULT_API_URL,
loadCredentials,
resolveApiUrl,
resolveToken,
saveCredentials,
} from "./cue-credentials";

let dir: string;
let prevXdg: string | undefined;
let prevToken: string | undefined;
let prevUrl: string | undefined;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cue-creds-"));
prevXdg = process.env.XDG_CONFIG_HOME;
prevToken = process.env.CUE_API_TOKEN;
prevUrl = process.env.CUE_API_URL;
process.env.XDG_CONFIG_HOME = dir; // configDir() → <dir>/cue
delete process.env.CUE_API_TOKEN;
delete process.env.CUE_API_URL;
});

afterEach(() => {
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prevXdg;
if (prevToken === undefined) delete process.env.CUE_API_TOKEN; else process.env.CUE_API_TOKEN = prevToken;
if (prevUrl === undefined) delete process.env.CUE_API_URL; else process.env.CUE_API_URL = prevUrl;
rmSync(dir, { recursive: true, force: true });
});

describe("cue-credentials", () => {
test("returns null when nothing is saved", () => {
expect(loadCredentials()).toBeNull();
expect(resolveToken()).toBeNull();
});

test("saves and reloads a token", () => {
const path = saveCredentials({ apiUrl: "https://cuecards.cc", token: "cue_sk_abc" });
expect(path).toContain("credentials.json");
const creds = loadCredentials();
expect(creds?.token).toBe("cue_sk_abc");
expect(resolveToken()).toBe("cue_sk_abc");
});

test("writes the credentials file with 0600 perms", () => {
const path = saveCredentials({ apiUrl: DEFAULT_API_URL, token: "secret" });
const mode = statSync(path).mode & 0o777;
expect(mode).toBe(0o600);
// sanity: the secret is actually persisted
expect(readFileSync(path, "utf8")).toContain("secret");
});

test("resolution order: flag > env > file", () => {
saveCredentials({ apiUrl: DEFAULT_API_URL, token: "from-file" });
expect(resolveToken("from-flag")).toBe("from-flag");
process.env.CUE_API_TOKEN = "from-env";
expect(resolveToken()).toBe("from-env");
expect(resolveToken("from-flag")).toBe("from-flag");
delete process.env.CUE_API_TOKEN;
expect(resolveToken()).toBe("from-file");
});

test("resolveApiUrl honors env, then saved, then default; trims trailing slash", () => {
expect(resolveApiUrl(null)).toBe(DEFAULT_API_URL);
expect(resolveApiUrl({ apiUrl: "https://my.host/", token: "t" })).toBe("https://my.host");
process.env.CUE_API_URL = "http://localhost:3000/";
expect(resolveApiUrl({ apiUrl: "https://my.host", token: "t" })).toBe("http://localhost:3000");
});
});
Loading
Loading