Skip to content
Open
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
3 changes: 2 additions & 1 deletion sources/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
{ "name": "aws", "domain": "amazonaws.com", "install": "brew install awscli && aws configure", "docs": "https://docs.aws.amazon.com/cli/", "description": "The AWS command-line interface across every AWS service." },
{ "name": "az", "domain": "azure.com", "install": "brew install azure-cli && az login", "docs": "https://learn.microsoft.com/cli/azure/", "description": "Manage Azure resources, identities, and deployments." },
{ "name": "linear", "domain": "linear.app", "install": "npm i -g @linear/cli && linear auth", "docs": "https://linear.app/developers", "description": "Create and manage Linear issues and projects from the terminal." },
{ "name": "shopify", "domain": "shopify.com", "install": "npm i -g @shopify/cli && shopify auth login", "docs": "https://shopify.dev/docs/api/shopify-cli", "description": "Build, test, and deploy Shopify apps and themes." }
{ "name": "shopify", "domain": "shopify.com", "install": "npm i -g @shopify/cli && shopify auth login", "docs": "https://shopify.dev/docs/api/shopify-cli", "description": "Build, test, and deploy Shopify apps and themes." },
{ "name": "clerk", "domain": "clerk.com", "install": "npm i -g clerk && clerk auth login", "docs": "https://clerk.com/docs/cli", "repo": "https://github.com/clerk/cli", "description": "Set up Clerk auth, manage users and apps, and call the Clerk API from the terminal." }
]
}
39 changes: 39 additions & 0 deletions sources/openapi-manual.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,45 @@
"updated": "2026-06-29T00:00:00.000Z",
"added": "2026-06-29T00:00:00.000Z"
},
{
"provider": "clerk.com",
"providerName": "Clerk",
"service": "Backend API",
"versionKey": "latest",
"title": "Clerk Backend API",
"description": "The Clerk REST Backend API, meant to be accessed by backend servers — manage users, organizations, sessions, and instance configuration at https://api.clerk.com/v1 with a secret-key bearer token.",
"openapiVer": "3.0.3",
"origin": "https://clerk.com/docs/reference/spec/bapi/latest.yml",
"categories": ["security", "developer_tools"],
"updated": "2026-07-06T00:00:00.000Z",
"added": "2026-07-06T00:00:00.000Z"
},
{
"provider": "clerk.com",
"providerName": "Clerk",
"service": "Frontend API",
"versionKey": "latest",
"title": "Clerk Frontend API",
"description": "The Clerk REST Frontend API, accessed from browsers and native clients — the form-based API that powers Clerk's SDKs, served per-instance (e.g. https://{slug}.clerk.accounts.dev in development).",
"openapiVer": "3.0.3",
"origin": "https://clerk.com/docs/reference/spec/fapi/latest.yml",
"categories": ["security", "developer_tools"],
"updated": "2026-07-06T00:00:00.000Z",
"added": "2026-07-06T00:00:00.000Z"
},
{
"provider": "clerk.com",
"providerName": "Clerk",
"service": "Platform API",
"versionKey": "beta",
"title": "Clerk Platform API",
"description": "The Clerk REST Platform API for programmatic management of workspace resources — applications, domains, and transfers — at https://api.clerk.com/v1 with a Platform access token. Currently in private beta.",
"openapiVer": "3.0.3",
"origin": "https://clerk.com/docs/reference/spec/platform/latest.yml",
"categories": ["security", "developer_tools"],
"updated": "2026-07-06T00:00:00.000Z",
"added": "2026-07-06T00:00:00.000Z"
},
{
"provider": "datadoghq.com",
"providerName": "Datadog",
Expand Down
158 changes: 157 additions & 1 deletion src/lib/discover.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { PROBE_KEYS } from "./conventions.ts";
import { discover, slackMcpAppManifestUrl, type ChatFn, type WebBackend } from "./discover.ts";
import { discover, mergeDeclared, slackMcpAppManifestUrl, type ChatFn, type WebBackend } from "./discover.ts";
import type { DetectionResult } from "./detect.ts";

const web: WebBackend = {
Expand Down Expand Up @@ -189,3 +189,159 @@ describe("discover MCP onboarding overrides", () => {
expect(setup).not.toContain("api.slack.com/apps?new_app=1");
});
});

describe("mergeDeclared", () => {
const declaredSource = "https://clerk.example/.well-known/integrations.json";
const declaredBasis = { via: "declared", source: declaredSource } as const;

function resultWith(surfaces: object[]): Parameters<typeof mergeDeclared>[0] {
return {
version: 3,
domain: "clerk.example",
summary: "",
credentials: {},
surfaces,
} as Parameters<typeof mergeDeclared>[0];
}

function detectionWith(surfaces: object[]): DetectionResult {
return {
...baseDetection(""),
integrationsJson: {
url: declaredSource,
result: { version: 3, credentials: {}, surfaces },
},
} as DetectionResult;
}

test("merges a declared surface into its discovered twin when only one carries a spec", () => {
const r = resultWith([
{
type: "http",
slug: "backend-api",
name: "Backend API",
url: "https://api.clerk.example/v1",
basis: { via: "discovered", evidence: [] },
auth: { status: "unknown" },
},
]);
mergeDeclared(
r,
detectionWith([
{
type: "http",
slug: "backend-api",
name: "Backend API",
url: "https://api.clerk.example/v1",
spec: "https://clerk.example/spec/bapi/latest.yml",
basis: declaredBasis,
auth: { status: "unknown" },
},
]),
);

expect(r.surfaces).toHaveLength(1);
expect(r.surfaces[0].slug).toBe("backend-api");
expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/bapi/latest.yml");
expect(r.surfaces[0].basis.via).toBe("declared");
});

test("falls back to type+name when locator urls are templated differently", () => {
const r = resultWith([
{
type: "http",
slug: "frontend-api",
name: "Frontend API",
url: "https://{domain}.accounts.example",
basis: { via: "discovered", evidence: [] },
auth: { status: "unknown" },
},
]);
mergeDeclared(
r,
detectionWith([
{
type: "http",
slug: "frontend-api",
name: "Frontend API",
url: "https://{frontend_api}",
spec: "https://clerk.example/spec/fapi/latest.yml",
basis: declaredBasis,
auth: { status: "unknown" },
},
]),
);

expect(r.surfaces).toHaveLength(1);
expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/fapi/latest.yml");
});

test("still appends genuinely new declared surfaces", () => {
const r = resultWith([
{
type: "http",
slug: "backend-api",
name: "Backend API",
url: "https://api.clerk.example/v1",
basis: { via: "discovered", evidence: [] },
auth: { status: "unknown" },
},
]);
mergeDeclared(
r,
detectionWith([
{
type: "http",
slug: "platform-api",
name: "Platform API",
url: "https://api.clerk.example/v1#platform",
basis: declaredBasis,
auth: { status: "unknown" },
},
]),
);

expect(r.surfaces).toHaveLength(2);
expect(r.surfaces.map((s) => s.slug)).toEqual(["backend-api", "platform-api"]);
});

test("does not merge distinct spec-bearing APIs that share a base url", () => {
const r = resultWith([
{
type: "http",
slug: "backend-api",
name: "Backend API",
url: "https://api.clerk.example/v1",
basis: { via: "discovered", evidence: [] },
auth: { status: "unknown" },
},
]);
mergeDeclared(
r,
detectionWith([
{
type: "http",
slug: "backend-api",
name: "Backend API",
url: "https://api.clerk.example/v1",
spec: "https://clerk.example/spec/bapi/latest.yml",
basis: declaredBasis,
auth: { status: "unknown" },
},
{
type: "http",
slug: "platform-api",
name: "Platform API",
url: "https://api.clerk.example/v1",
spec: "https://clerk.example/spec/platform/latest.yml",
basis: declaredBasis,
auth: { status: "unknown" },
},
]),
);

expect(r.surfaces.map((s) => s.slug)).toEqual(["backend-api", "platform-api"]);
expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/bapi/latest.yml");
expect(r.surfaces[1].spec).toBe("https://clerk.example/spec/platform/latest.yml");
});
});
35 changes: 28 additions & 7 deletions src/lib/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,9 +736,24 @@ function markDeclaredSurface(surface: Surface, source: string): Surface {
return surface;
}

function surfaceLocator(s: Surface): string {
const packageId = s.packages?.[0]?.identifier;
return `${s.type}|${(s.spec || s.url || s.command || packageId || s.name).toLowerCase()}`;
/** Every key a surface can be recognized by. A declared surface must merge
* into its discovered twin even when one carries a spec and the other only a
* base url (or their urls are templated differently), so matching tries each
* facet — ending with type+name, the same pair the batch validator treats as
* a duplicate. A surface with a spec does NOT expose its url as a facet:
* distinct APIs can share a base url (Clerk's Backend and Platform APIs both
* live at api.clerk.com/v1), and the spec is what tells them apart. */
function surfaceLocators(s: Surface): string[] {
const keys: string[] = [];
const add = (value: string | undefined) => {
if (value) keys.push(`${s.type}|${value.toLowerCase()}`);
};
add(s.spec);
if (!s.spec) add(s.url);
add(s.command);
add(s.packages?.[0]?.identifier);
add(s.name);
return keys;
}

function entryKey(entry: AuthEntry): string {
Expand Down Expand Up @@ -802,7 +817,7 @@ function mergeDeclaredSurface(existing: Surface, declared: Surface): void {
mergeDeclaredAuth(existing, declared);
}

function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): void {
export function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): void {
const declared = detect.integrationsJson;
if (!declared?.result) return;
const source = declared.url;
Expand All @@ -812,19 +827,25 @@ function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit)
emit?.({ kind: "credential", id, credential: r.credentials[id] });
}
}
const byLocator = new Map(r.surfaces.map((surface) => [surfaceLocator(surface), surface]));
const byLocator = new Map<string, Surface>();
const index = (surface: Surface): void => {
for (const key of surfaceLocators(surface)) if (!byLocator.has(key)) byLocator.set(key, surface);
};
for (const surface of r.surfaces) index(surface);
for (const rawSurface of declared.result.surfaces ?? []) {
const surface = markDeclaredSurface(cloneJson(rawSurface) as Surface, source);
if (!surface.slug) surface.slug = assignSlug(surface.name || "Declared surface", r.surfaces);
const existing = byLocator.get(surfaceLocator(surface));
const existing = surfaceLocators(surface)
.map((key) => byLocator.get(key))
.find(Boolean);
if (existing) {
mergeDeclaredSurface(existing, surface);
emit?.({ kind: "surface", surface: existing });
continue;
}
surface.slug = assignSlug(surface.slug || surface.name || "Declared surface", r.surfaces);
r.surfaces.push(surface);
byLocator.set(surfaceLocator(surface), surface);
index(surface);
emit?.({ kind: "surface", surface });
}
}
Expand Down