feat(plugin-system): GitHub connector, discovery, marketplaces, and access UX (#1525)

* feat(plugin-system): add GitHub connector, discovery, marketplaces, and access UX

End-to-end GitHub App connector flow and UI:

- GitHub App connect: install start/callback/complete endpoints, connector account upsert from installation, selection state, and a dedicated Den Web setup page.
- Repo discovery: GitHub tree + manifest inspection, Claude-compatible classification (marketplace/plugin-manifest), marketplace plugin metadata/component path parsing, discovery API + snapshot.
- Apply pipeline: materialize plugins, connector mappings, config objects (with frontmatter-aware skill/agent parsing), memberships, and source bindings; create marketplaces with name/description from marketplace.json.
- Auto-import on push: persist flag on connector instance, webhook-driven re-apply for new discoveries.
- Cleanup: cascading disconnect on connector account removal and remove on connector instance.
- Integrations UI: cleaner connected-account card, GitHub avatar, hover trash + confirm dialog, inline "Add new repo" action, per-account repo picker, manifest badges, configured/unconfigured sorting.
- Discovery UI: cleaner loader, plugin cards with component chips, inline apply action, auto-import toggle default on.
- Manage UI: instance configuration endpoint, auto-import toggle, remove repo danger zone with cascade confirmation.
- Plugins & Marketplaces pages: dashboard nav entries, list + detail screens, per-plugin component counts, marketplace resolved endpoint with source + plugins, marketplace access section (org-wide/team/member grants).
- Bitbucket card marked "Coming soon".
- PRDs, GitHub setup instructions, and learnings docs added.

* chore(docs): move GitHub-instructions.md into prds/new-plugin-arch/github-connection

* fix(den-web): wrap github integration page in Suspense for useSearchParams

* refactor(den-web): redirect GitHub post-install flow into the clean account selection phase

After completing the GitHub App install, previously we rendered a separate
GithubRepositorySelectionPhase with different styling. Now we call the install
completion endpoint, then router.replace to ?connectorAccountId=... so the
existing GithubConnectedAccountSelectionPhase renders the repo list. Removes
the duplicate selection phase and its unused helpers/imports.

* fix(den-web): drop Requires-scopes body and show GitHub description in integrations card

Removes the empty-state Requires scopes: <code>… block from both provider
cards and restores the provider description on the GitHub card so the empty
state is consistent with Bitbucket. Drops the header's bottom border when no
body follows.

* fix(den-web): only show integration provider description in empty state

Once a provider has connections, hide the description in the header so the
card focuses on the connected accounts + repos list.

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-22 17:27:59 -07:00
committed by GitHub
parent 47db4e39e3
commit 6053ac937e
37 changed files with 9659 additions and 422 deletions

View File

@@ -1 +1 @@
export const BUILD_LATEST_APP_VERSION = "0.11.212" as const;
export const BUILD_LATEST_APP_VERSION = "0.11.212" as const

View File

@@ -20,7 +20,11 @@ import {
connectorAccountDisconnectSchema,
connectorAccountListQuerySchema,
connectorAccountListResponseSchema,
connectorAccountDisconnectResponseSchema,
connectorAccountMutationResponseSchema,
connectorInstanceAutoImportSchema,
connectorInstanceConfigurationResponseSchema,
connectorInstanceRemoveResponseSchema,
connectorAccountParamsSchema,
connectorAccountRepositoryParamsSchema,
connectorInstanceAccessGrantParamsSchema,
@@ -50,6 +54,15 @@ import {
connectorTargetParamsSchema,
connectorTargetUpdateSchema,
githubConnectorAccountCreateSchema,
githubConnectorDiscoveryResponseSchema,
githubDiscoveryApplyResponseSchema,
githubDiscoveryApplySchema,
githubDiscoveryTreeQuerySchema,
githubDiscoveryTreeResponseSchema,
githubInstallCompleteResponseSchema,
githubInstallCompleteSchema,
githubInstallStartResponseSchema,
githubInstallStartSchema,
githubConnectorSetupSchema,
githubRepositoryListQuerySchema,
githubRepositoryListResponseSchema,
@@ -69,6 +82,7 @@ import {
marketplaceMutationResponseSchema,
marketplaceParamsSchema,
marketplacePluginListResponseSchema,
marketplaceResolvedResponseSchema,
marketplacePluginMutationResponseSchema,
marketplacePluginParamsSchema,
marketplacePluginWriteSchema,
@@ -152,6 +166,7 @@ export const pluginArchRoutePaths = {
pluginAccessGrant: `${orgBasePath}/plugins/:pluginId/access/:grantId`,
marketplaces: `${orgBasePath}/marketplaces`,
marketplace: `${orgBasePath}/marketplaces/:marketplaceId`,
marketplaceResolved: `${orgBasePath}/marketplaces/:marketplaceId/resolved`,
marketplaceArchive: `${orgBasePath}/marketplaces/:marketplaceId/archive`,
marketplaceRestore: `${orgBasePath}/marketplaces/:marketplaceId/restore`,
marketplacePlugins: `${orgBasePath}/marketplaces/:marketplaceId/plugins`,
@@ -163,6 +178,9 @@ export const pluginArchRoutePaths = {
connectorAccountDisconnect: `${orgBasePath}/connector-accounts/:connectorAccountId/disconnect`,
connectorInstances: `${orgBasePath}/connector-instances`,
connectorInstance: `${orgBasePath}/connector-instances/:connectorInstanceId`,
connectorInstanceConfiguration: `${orgBasePath}/connector-instances/:connectorInstanceId/configuration`,
connectorInstanceAutoImport: `${orgBasePath}/connector-instances/:connectorInstanceId/auto-import`,
connectorInstanceRemove: `${orgBasePath}/connector-instances/:connectorInstanceId/remove`,
connectorInstanceArchive: `${orgBasePath}/connector-instances/:connectorInstanceId/archive`,
connectorInstanceDisable: `${orgBasePath}/connector-instances/:connectorInstanceId/disable`,
connectorInstanceEnable: `${orgBasePath}/connector-instances/:connectorInstanceId/enable`,
@@ -177,11 +195,16 @@ export const pluginArchRoutePaths = {
connectorSyncEvents: `${orgBasePath}/connector-sync-events`,
connectorSyncEvent: `${orgBasePath}/connector-sync-events/:connectorSyncEventId`,
connectorSyncEventRetry: `${orgBasePath}/connector-sync-events/:connectorSyncEventId/retry`,
connectorInstanceDiscovery: `${orgBasePath}/connector-instances/:connectorInstanceId/discovery`,
connectorInstanceDiscoveryApply: `${orgBasePath}/connector-instances/:connectorInstanceId/discovery/apply`,
connectorInstanceDiscoveryTree: `${orgBasePath}/connector-instances/:connectorInstanceId/discovery/tree`,
githubInstallStart: `${orgBasePath}/connectors/github/install/start`,
githubInstallComplete: `${orgBasePath}/connectors/github/install/complete`,
githubSetup: `${orgBasePath}/connectors/github/setup`,
githubAccounts: `${orgBasePath}/connectors/github/accounts`,
githubAccountRepositories: `${orgBasePath}/connectors/github/accounts/:connectorAccountId/repositories`,
githubValidateTarget: `${orgBasePath}/connectors/github/validate-target`,
githubWebhookIngress: "/api/webhooks/connectors/github",
githubWebhookIngress: "/v1/webhooks/connectors/github",
} as const
export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
@@ -509,6 +532,15 @@ export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
response: { description: "Marketplace plugin memberships.", schema: marketplacePluginListResponseSchema, status: 200 },
tag: "Marketplaces",
},
getMarketplaceResolved: {
audience: "admin",
description: "Return marketplace detail with plugins and derived source info.",
method: "GET",
path: pluginArchRoutePaths.marketplaceResolved,
request: { params: marketplaceParamsSchema },
response: { description: "Marketplace resolved detail.", schema: marketplaceResolvedResponseSchema, status: 200 },
tag: "Marketplaces",
},
addMarketplacePlugin: {
audience: "admin",
description: "Add a plugin to a marketplace using marketplace-scoped write access.",
@@ -583,11 +615,11 @@ export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
},
disconnectConnectorAccount: {
audience: "admin",
description: "Disconnect one connector account without deleting historical sync state.",
description: "Disconnect one connector account and delete its connector-managed imports (mappings, config objects, plugins created via discovery, and empty marketplaces).",
method: "POST",
path: pluginArchRoutePaths.connectorAccountDisconnect,
request: { body: connectorAccountDisconnectSchema, params: connectorAccountParamsSchema },
response: { description: "Connector account disconnected successfully.", schema: connectorAccountMutationResponseSchema, status: 200 },
response: { description: "Connector account disconnected and cleaned up successfully.", schema: connectorAccountDisconnectResponseSchema, status: 200 },
tag: "Connectors",
},
listConnectorInstances: {
@@ -635,6 +667,33 @@ export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
response: { description: "Connector instance archived successfully.", schema: connectorInstanceMutationResponseSchema, status: 200 },
tag: "Connectors",
},
getConnectorInstanceConfiguration: {
audience: "admin",
description: "Return the configured plugins, mappings, and import state for a connector instance.",
method: "GET",
path: pluginArchRoutePaths.connectorInstanceConfiguration,
request: { params: connectorInstanceParamsSchema },
response: { description: "Connector instance configuration returned successfully.", schema: connectorInstanceConfigurationResponseSchema, status: 200 },
tag: "Connectors",
},
setConnectorInstanceAutoImport: {
audience: "admin",
description: "Enable or disable auto-import of new plugins for a connector instance.",
method: "POST",
path: pluginArchRoutePaths.connectorInstanceAutoImport,
request: { body: connectorInstanceAutoImportSchema, params: connectorInstanceParamsSchema },
response: { description: "Connector instance auto-import updated successfully.", schema: connectorInstanceConfigurationResponseSchema, status: 200 },
tag: "Connectors",
},
removeConnectorInstance: {
audience: "admin",
description: "Remove a connector instance and delete its associated plugins, mappings, config objects, and bindings.",
method: "POST",
path: pluginArchRoutePaths.connectorInstanceRemove,
request: { params: connectorInstanceParamsSchema },
response: { description: "Connector instance removed and cleaned up successfully.", schema: connectorInstanceRemoveResponseSchema, status: 200 },
tag: "Connectors",
},
disableConnectorInstance: {
audience: "admin",
description: "Disable sync execution for a connector instance.",
@@ -788,6 +847,51 @@ export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
response: { description: "Connector sync retry queued successfully.", schema: connectorSyncAsyncResponseSchema, status: 202 },
tag: "Connectors",
},
getGithubConnectorDiscovery: {
audience: "admin",
description: "Analyze a GitHub connector instance and return the discovered repository shape and plugin candidates.",
method: "GET",
path: pluginArchRoutePaths.connectorInstanceDiscovery,
request: { params: connectorInstanceParamsSchema },
response: { description: "GitHub connector discovery returned successfully.", schema: githubConnectorDiscoveryResponseSchema, status: 200 },
tag: "GitHub",
},
listGithubConnectorDiscoveryTree: {
audience: "admin",
description: "Page through the normalized GitHub repository tree for one connector instance.",
method: "GET",
path: pluginArchRoutePaths.connectorInstanceDiscoveryTree,
request: { params: connectorInstanceParamsSchema, query: githubDiscoveryTreeQuerySchema },
response: { description: "GitHub discovery tree page returned successfully.", schema: githubDiscoveryTreeResponseSchema, status: 200 },
tag: "GitHub",
},
applyGithubConnectorDiscovery: {
audience: "admin",
description: "Create OpenWork plugins and connector mappings from selected GitHub discovery candidates.",
method: "POST",
path: pluginArchRoutePaths.connectorInstanceDiscoveryApply,
request: { body: githubDiscoveryApplySchema, params: connectorInstanceParamsSchema },
response: { description: "GitHub discovery selection applied successfully.", schema: githubDiscoveryApplyResponseSchema, status: 200 },
tag: "GitHub",
},
githubInstallStart: {
audience: "admin",
description: "Start the GitHub App install flow and return a redirect URL.",
method: "POST",
path: pluginArchRoutePaths.githubInstallStart,
request: { body: githubInstallStartSchema },
response: { description: "GitHub install redirect created successfully.", schema: githubInstallStartResponseSchema, status: 200 },
tag: "GitHub",
},
githubInstallComplete: {
audience: "admin",
description: "Complete one GitHub App installation and return repositories visible to it.",
method: "POST",
path: pluginArchRoutePaths.githubInstallComplete,
request: { body: githubInstallCompleteSchema },
response: { description: "GitHub installation completed successfully.", schema: githubInstallCompleteResponseSchema, status: 200 },
tag: "GitHub",
},
githubSetup: {
audience: "admin",
description: "Create the GitHub connector account, instance, target, and initial mappings in one setup flow.",

View File

@@ -0,0 +1,639 @@
import { createHmac, createSign, randomUUID, timingSafeEqual } from "node:crypto"
export class GithubConnectorConfigError extends Error {
constructor(message: string) {
super(message)
this.name = "GithubConnectorConfigError"
}
}
export class GithubConnectorRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly body?: unknown,
) {
super(message)
this.name = "GithubConnectorRequestError"
}
}
export type GithubConnectorAppConfig = {
appId: string
clientId?: string
clientSecret?: string
privateKey: string
}
type GithubFetch = typeof fetch
export type GithubManifestKind = "marketplace" | "plugin" | null
type GithubRepositorySummary = {
defaultBranch: string | null
fullName: string
hasPluginManifest?: boolean
id: number
manifestKind?: GithubManifestKind
marketplacePluginCount?: number | null
private: boolean
}
export type GithubRepositoryTreeEntry = {
id: string
kind: "blob" | "tree"
path: string
sha: string | null
size: number | null
}
export type GithubRepositoryTreeSnapshot = {
headSha: string
truncated: boolean
treeEntries: GithubRepositoryTreeEntry[]
treeSha: string
}
export type GithubAppSummary = {
htmlUrl: string
name: string
slug: string
}
export type GithubInstallationSummary = {
accountLogin: string
accountType: "Organization" | "User"
displayName: string
installationId: number
repositorySelection: "all" | "selected"
settingsUrl: string | null
}
export type GithubInstallStatePayload = {
exp: number
nonce: string
orgId: string
returnPath: string
userId: string
}
const GITHUB_API_BASE = "https://api.github.com"
const GITHUB_API_VERSION = "2022-11-28"
function base64UrlEncode(value: unknown) {
const buffer = typeof value === "string"
? Buffer.from(value)
: Buffer.isBuffer(value)
? value
: value instanceof Uint8Array
? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
: (() => {
throw new GithubConnectorConfigError("Unsupported value passed to base64UrlEncode.")
})()
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
export function normalizeGithubPrivateKey(privateKey: string) {
return privateKey.includes("\\n") ? privateKey.replace(/\\n/g, "\n") : privateKey
}
export function getGithubConnectorAppConfig(input: { appId?: string; privateKey?: string }) {
const appId = input.appId?.trim()
const privateKey = input.privateKey?.trim()
if (!appId) {
throw new GithubConnectorConfigError("GITHUB_CONNECTOR_APP_ID is required for live GitHub connector testing.")
}
if (!privateKey) {
throw new GithubConnectorConfigError("GITHUB_CONNECTOR_APP_PRIVATE_KEY is required for live GitHub connector testing.")
}
return {
appId,
privateKey: normalizeGithubPrivateKey(privateKey),
} satisfies GithubConnectorAppConfig
}
function base64UrlDecode(value: string) {
return Buffer.from(value, "base64url")
}
function isSafeRelativeReturnPath(value: string) {
return value.startsWith("/") && !value.startsWith("//")
}
export function createGithubInstallStateToken(input: {
now?: Date | number
orgId: string
returnPath: string
secret: string
ttlSeconds?: number
userId: string
}) {
const nowMs = input.now instanceof Date ? input.now.getTime() : (typeof input.now === "number" ? input.now : Date.now())
const returnPath = input.returnPath.trim()
if (!isSafeRelativeReturnPath(returnPath)) {
throw new GithubConnectorConfigError("GitHub install return path must be a safe relative path.")
}
const payload: GithubInstallStatePayload = {
exp: Math.floor(nowMs / 1000) + (input.ttlSeconds ?? 10 * 60),
nonce: randomUUID(),
orgId: input.orgId,
returnPath,
userId: input.userId,
}
const encodedPayload = base64UrlEncode(JSON.stringify(payload))
const signature = base64UrlEncode(createHmac("sha256", input.secret).update(encodedPayload).digest())
return `${encodedPayload}.${signature}`
}
export function verifyGithubInstallStateToken(input: { now?: Date | number; secret: string; token: string }) {
const [encodedPayload, encodedSignature] = input.token.split(".")
if (!encodedPayload || !encodedSignature) {
return null
}
try {
const expectedSignature = createHmac("sha256", input.secret).update(encodedPayload).digest()
const providedSignature = base64UrlDecode(encodedSignature)
const expectedBytes = new Uint8Array(expectedSignature)
const providedBytes = new Uint8Array(providedSignature)
if (expectedBytes.length !== providedBytes.length || !timingSafeEqual(expectedBytes, providedBytes)) {
return null
}
const payload = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8")) as Partial<GithubInstallStatePayload>
const nowSeconds = Math.floor((input.now instanceof Date ? input.now.getTime() : (typeof input.now === "number" ? input.now : Date.now())) / 1000)
if (
typeof payload.exp !== "number"
|| typeof payload.nonce !== "string"
|| typeof payload.orgId !== "string"
|| typeof payload.returnPath !== "string"
|| typeof payload.userId !== "string"
|| payload.exp < nowSeconds
|| !isSafeRelativeReturnPath(payload.returnPath)
) {
return null
}
return payload as GithubInstallStatePayload
} catch {
return null
}
}
export function createGithubAppJwt(input: GithubConnectorAppConfig & { now?: Date | number }) {
const nowMs = input.now instanceof Date ? input.now.getTime() : (typeof input.now === "number" ? input.now : Date.now())
const issuedAt = Math.floor(nowMs / 1000) - 60
const expiresAt = issuedAt + (9 * 60)
const signingInput = [
base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" })),
base64UrlEncode(JSON.stringify({ exp: expiresAt, iat: issuedAt, iss: input.appId })),
].join(".")
const signer = createSign("RSA-SHA256")
signer.update(signingInput)
signer.end()
return `${signingInput}.${base64UrlEncode(signer.sign(input.privateKey))}`
}
async function requestGithubJson<TResponse>(input: {
fetchFn?: GithubFetch
headers?: Record<string, string>
method?: "GET" | "POST"
path: string
allowStatuses?: number[]
}) {
const fetchFn = input.fetchFn ?? fetch
const response = await fetchFn(`${GITHUB_API_BASE}${input.path}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "openwork-den-api",
"X-GitHub-Api-Version": GITHUB_API_VERSION,
...input.headers,
},
method: input.method ?? "GET",
})
const text = await response.text()
const body = text ? JSON.parse(text) as unknown : null
if (!response.ok && !(input.allowStatuses ?? []).includes(response.status)) {
const message = body && typeof body === "object" && typeof (body as Record<string, unknown>).message === "string"
? (body as Record<string, unknown>).message as string
: `GitHub request failed with status ${response.status}.`
throw new GithubConnectorRequestError(message, response.status, body)
}
return {
body: body as TResponse,
ok: response.ok,
status: response.status,
}
}
export async function getGithubAppSummary(input: { config: GithubConnectorAppConfig; fetchFn?: GithubFetch }) {
const jwt = createGithubAppJwt(input.config)
const response = await requestGithubJson<{ html_url?: string; name?: string; slug?: string }>({
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${jwt}`,
},
path: "/app",
})
const htmlUrl = typeof response.body.html_url === "string" ? response.body.html_url.trim() : ""
const slug = typeof response.body.slug === "string" ? response.body.slug.trim() : ""
const name = typeof response.body.name === "string" ? response.body.name.trim() : ""
if (!htmlUrl || !slug || !name) {
throw new GithubConnectorRequestError("GitHub app metadata response was incomplete.", 502, response.body)
}
return {
htmlUrl,
name,
slug,
} satisfies GithubAppSummary
}
export function buildGithubAppInstallUrl(input: { app: GithubAppSummary; state: string }) {
const url = new URL(`${input.app.htmlUrl.replace(/\/+$/, "")}/installations/new`)
url.searchParams.set("state", input.state)
return url.toString()
}
export async function getGithubInstallationSummary(input: { config: GithubConnectorAppConfig; fetchFn?: GithubFetch; installationId: number }) {
const jwt = createGithubAppJwt(input.config)
const response = await requestGithubJson<{
account?: {
login?: string
type?: string
}
html_url?: string
id?: number
repository_selection?: string
}>({
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${jwt}`,
},
path: `/app/installations/${input.installationId}`,
})
const installationId = typeof response.body.id === "number" ? response.body.id : input.installationId
const accountLogin = typeof response.body.account?.login === "string" ? response.body.account.login.trim() : ""
const accountType = response.body.account?.type === "Organization" ? "Organization" : "User"
const repositorySelection = response.body.repository_selection === "selected" ? "selected" : "all"
if (!accountLogin) {
throw new GithubConnectorRequestError("GitHub installation response was missing the account login.", 502, response.body)
}
return {
accountLogin,
accountType,
displayName: accountLogin,
installationId,
repositorySelection,
settingsUrl: typeof response.body.html_url === "string" ? response.body.html_url.trim() || null : null,
} satisfies GithubInstallationSummary
}
async function createGithubInstallationAccessToken(input: { config: GithubConnectorAppConfig; fetchFn?: GithubFetch; installationId: number }) {
const jwt = createGithubAppJwt(input.config)
const response = await requestGithubJson<{ token?: string }>({
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${jwt}`,
},
method: "POST",
path: `/app/installations/${input.installationId}/access_tokens`,
})
const token = typeof response.body?.token === "string" ? response.body.token : null
if (!token) {
throw new GithubConnectorRequestError("GitHub did not return an installation access token.", 502, response.body)
}
return token
}
export async function getGithubInstallationAccessToken(input: { config: GithubConnectorAppConfig; fetchFn?: GithubFetch; installationId: number }) {
return createGithubInstallationAccessToken(input)
}
function normalizeGithubRepository(entry: unknown): GithubRepositorySummary | null {
if (!entry || typeof entry !== "object") {
return null
}
const candidate = entry as Record<string, unknown>
const id = typeof candidate.id === "number" ? candidate.id : Number(candidate.id)
const fullName = typeof candidate.full_name === "string"
? candidate.full_name
: typeof candidate.fullName === "string"
? candidate.fullName
: null
if (!Number.isFinite(id) || !fullName) {
return null
}
return {
defaultBranch: typeof candidate.default_branch === "string"
? candidate.default_branch
: typeof candidate.defaultBranch === "string"
? candidate.defaultBranch
: null,
fullName,
id,
private: Boolean(candidate.private),
}
}
export async function listGithubInstallationRepositories(input: { config: GithubConnectorAppConfig; fetchFn?: GithubFetch; installationId: number }) {
const token = await createGithubInstallationAccessToken(input)
const response = await requestGithubJson<{ repositories?: unknown[] }>({
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${token}`,
},
path: "/installation/repositories",
})
if (!Array.isArray(response.body.repositories)) {
return []
}
const repositories: GithubRepositorySummary[] = []
for (const entry of response.body.repositories) {
const normalized = normalizeGithubRepository(entry)
if (!normalized) {
continue
}
const manifest = await detectRepositoryManifest({
fetchFn: input.fetchFn,
ownerAndRepo: normalized.fullName,
token,
})
repositories.push({
...normalized,
hasPluginManifest: manifest.manifestKind !== null,
manifestKind: manifest.manifestKind,
marketplacePluginCount: manifest.marketplacePluginCount,
})
}
return repositories
}
async function detectRepositoryManifest(input: { fetchFn?: GithubFetch; ownerAndRepo: string; token: string }): Promise<{
manifestKind: GithubManifestKind
marketplacePluginCount: number | null
}> {
const parts = splitRepositoryFullName(input.ownerAndRepo)
if (!parts) {
return { manifestKind: null, marketplacePluginCount: null }
}
const marketplaceResponse = await requestGithubJson<{ content?: string; encoding?: string }>({
allowStatuses: [404],
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${input.token}`,
},
path: `/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/contents/.claude-plugin/marketplace.json`,
})
if (marketplaceResponse.ok && typeof marketplaceResponse.body?.content === "string" && marketplaceResponse.body.encoding === "base64") {
let marketplacePluginCount: number | null = null
try {
const decoded = Buffer.from(marketplaceResponse.body.content.replace(/\n/g, ""), "base64").toString("utf8")
const parsed = JSON.parse(decoded) as unknown
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray((parsed as Record<string, unknown>).plugins)) {
marketplacePluginCount = ((parsed as Record<string, unknown>).plugins as unknown[]).length
}
} catch {
marketplacePluginCount = null
}
return { manifestKind: "marketplace", marketplacePluginCount }
}
const pluginResponse = await requestGithubJson<unknown>({
allowStatuses: [404],
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${input.token}`,
},
path: `/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/contents/.claude-plugin/plugin.json`,
})
if (pluginResponse.ok) {
return { manifestKind: "plugin", marketplacePluginCount: null }
}
return { manifestKind: null, marketplacePluginCount: null }
}
function splitRepositoryFullName(repositoryFullName: string) {
const [owner, repo, ...rest] = repositoryFullName.trim().split("/")
if (!owner || !repo || rest.length > 0) {
return null
}
return { owner, repo }
}
export async function getGithubRepositoryTextFile(input: {
config: GithubConnectorAppConfig
fetchFn?: GithubFetch
installationId: number
path: string
ref: string
repositoryFullName: string
}) {
const repositoryParts = splitRepositoryFullName(input.repositoryFullName)
if (!repositoryParts) {
throw new GithubConnectorRequestError("GitHub repository full name is invalid.", 400)
}
const token = await createGithubInstallationAccessToken(input)
const response = await requestGithubJson<{ content?: string; encoding?: string }>({
allowStatuses: [404],
fetchFn: input.fetchFn,
headers: {
Authorization: `Bearer ${token}`,
},
path: `/repos/${encodeURIComponent(repositoryParts.owner)}/${encodeURIComponent(repositoryParts.repo)}/contents/${input.path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(input.ref)}`,
})
if (!response.ok) {
return null
}
if (response.body.encoding !== "base64" || typeof response.body.content !== "string") {
throw new GithubConnectorRequestError("GitHub file response was incomplete.", 502, response.body)
}
return Buffer.from(response.body.content.replace(/\n/g, ""), "base64").toString("utf8")
}
export async function getGithubRepositoryTree(input: {
branch: string
config: GithubConnectorAppConfig
fetchFn?: GithubFetch
installationId: number
repositoryFullName: string
}) {
const repositoryParts = splitRepositoryFullName(input.repositoryFullName)
if (!repositoryParts) {
throw new GithubConnectorRequestError("GitHub repository full name is invalid.", 400)
}
const token = await createGithubInstallationAccessToken(input)
const authHeaders = {
Authorization: `Bearer ${token}`,
}
const commitResponse = await requestGithubJson<{
commit?: {
tree?: {
sha?: string
}
}
sha?: string
}>({
fetchFn: input.fetchFn,
headers: authHeaders,
path: `/repos/${encodeURIComponent(repositoryParts.owner)}/${encodeURIComponent(repositoryParts.repo)}/commits/${encodeURIComponent(input.branch.trim())}`,
})
const headSha = typeof commitResponse.body.sha === "string" ? commitResponse.body.sha : ""
const treeSha = typeof commitResponse.body.commit?.tree?.sha === "string" ? commitResponse.body.commit.tree.sha : ""
if (!headSha || !treeSha) {
throw new GithubConnectorRequestError("GitHub commit response was missing the head or tree sha.", 502, commitResponse.body)
}
const treeResponse = await requestGithubJson<{
truncated?: boolean
tree?: Array<{
path?: string
sha?: string
size?: number
type?: string
}>
}>({
fetchFn: input.fetchFn,
headers: authHeaders,
path: `/repos/${encodeURIComponent(repositoryParts.owner)}/${encodeURIComponent(repositoryParts.repo)}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`,
})
const treeEntries = Array.isArray(treeResponse.body.tree)
? treeResponse.body.tree.flatMap((entry) => {
const path = typeof entry.path === "string" ? entry.path.trim() : ""
const kind = entry.type === "blob" || entry.type === "tree" ? entry.type : null
if (!path || !kind) {
return []
}
return [{
id: path,
kind,
path,
sha: typeof entry.sha === "string" ? entry.sha : null,
size: typeof entry.size === "number" ? entry.size : null,
} satisfies GithubRepositoryTreeEntry]
})
: []
return {
headSha,
truncated: Boolean(treeResponse.body.truncated),
treeEntries,
treeSha,
} satisfies GithubRepositoryTreeSnapshot
}
export async function validateGithubInstallationTarget(input: {
branch: string
config: GithubConnectorAppConfig
fetchFn?: GithubFetch
installationId: number
ref: string
repositoryFullName: string
repositoryId: number
}) {
const repositoryParts = splitRepositoryFullName(input.repositoryFullName)
if (!repositoryParts) {
return {
branchExists: false,
defaultBranch: null,
repositoryAccessible: false,
}
}
const token = await createGithubInstallationAccessToken(input)
const authHeaders = {
Authorization: `Bearer ${token}`,
}
const repositoryResponse = await requestGithubJson<{
default_branch?: string
full_name?: string
id?: number
}>({
allowStatuses: [404],
fetchFn: input.fetchFn,
headers: authHeaders,
path: `/repos/${encodeURIComponent(repositoryParts.owner)}/${encodeURIComponent(repositoryParts.repo)}`,
})
if (!repositoryResponse.ok) {
return {
branchExists: false,
defaultBranch: null,
repositoryAccessible: false,
}
}
const defaultBranch = typeof repositoryResponse.body.default_branch === "string"
? repositoryResponse.body.default_branch
: null
const repositoryAccessible = repositoryResponse.body.id === input.repositoryId
&& repositoryResponse.body.full_name === input.repositoryFullName
if (!repositoryAccessible) {
return {
branchExists: false,
defaultBranch,
repositoryAccessible: false,
}
}
const expectedRef = `refs/heads/${input.branch.trim()}`
if (input.ref.trim() !== expectedRef) {
return {
branchExists: false,
defaultBranch,
repositoryAccessible: true,
}
}
const branchResponse = await requestGithubJson<{ name?: string }>({
allowStatuses: [404],
fetchFn: input.fetchFn,
headers: authHeaders,
path: `/repos/${encodeURIComponent(repositoryParts.owner)}/${encodeURIComponent(repositoryParts.repo)}/branches/${encodeURIComponent(input.branch.trim())}`,
})
return {
branchExists: branchResponse.ok && branchResponse.body.name === input.branch.trim(),
defaultBranch,
repositoryAccessible: true,
}
}

View File

@@ -0,0 +1,494 @@
type GithubDiscoveryTreeEntryKind = "blob" | "tree"
export type GithubDiscoveryTreeEntry = {
id: string
kind: GithubDiscoveryTreeEntryKind
path: string
sha: string | null
size: number | null
}
export type GithubDiscoveryClassification =
| "claude_marketplace_repo"
| "claude_multi_plugin_repo"
| "claude_single_plugin_repo"
| "folder_inferred_repo"
| "unsupported"
export type GithubDiscoveredPluginSourceKind =
| "marketplace_entry"
| "plugin_manifest"
| "standalone_claude"
| "folder_inference"
export type GithubDiscoveredPluginComponentKind =
| "skill"
| "command"
| "agent"
| "hook"
| "mcp_server"
| "lsp_server"
| "monitor"
| "settings"
export type GithubDiscoveredPlugin = {
componentKinds: GithubDiscoveredPluginComponentKind[]
componentPaths: {
agents: string[]
commands: string[]
hooks: string[]
lspServers: string[]
mcpServers: string[]
monitors: string[]
settings: string[]
skills: string[]
}
description: string | null
displayName: string
key: string
manifestPath: string | null
metadata: Record<string, unknown>
rootPath: string
selectedByDefault: boolean
sourceKind: GithubDiscoveredPluginSourceKind
supported: boolean
warnings: string[]
}
export type GithubMarketplaceInfo = {
description: string | null
name: string | null
owner: string | null
version: string | null
}
export type GithubRepoDiscoveryResult = {
classification: GithubDiscoveryClassification
discoveredPlugins: GithubDiscoveredPlugin[]
marketplace: GithubMarketplaceInfo | null
warnings: string[]
}
type MarketplaceEntry = {
agents?: unknown
commands?: unknown
description?: unknown
hooks?: unknown
mcpServers?: unknown
name?: unknown
settings?: unknown
skills?: unknown
source?: unknown
}
type PluginMetadata = {
description: string | null
metadata: Record<string, unknown>
name: string | null
}
const KNOWN_COMPONENT_SEGMENTS = ["skills", "commands", "agents"] as const
function normalizePath(value: string) {
return value.trim().replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "")
}
function joinPath(rootPath: string, childPath: string) {
const root = normalizePath(rootPath)
const child = normalizePath(childPath)
if (!root) return child
if (!child) return root
return `${root}/${child}`
}
function basename(path: string) {
const normalized = normalizePath(path)
if (!normalized) return null
const parts = normalized.split("/")
return parts[parts.length - 1] ?? null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function asString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null
}
function pathDirectoryPrefixes(path: string) {
const segments = normalizePath(path).split("/").filter(Boolean)
const prefixes: string[] = []
for (let index = 1; index <= segments.length; index += 1) {
prefixes.push(segments.slice(0, index).join("/"))
}
return prefixes
}
function buildPathSet(entries: GithubDiscoveryTreeEntry[]) {
const knownPaths = new Set<string>()
for (const entry of entries) {
const normalizedPath = normalizePath(entry.path)
if (!normalizedPath) continue
knownPaths.add(normalizedPath)
for (const prefix of pathDirectoryPrefixes(normalizedPath)) {
knownPaths.add(prefix)
}
}
return knownPaths
}
function hasPath(knownPaths: Set<string>, path: string) {
const normalized = normalizePath(path)
return normalized.length > 0 && knownPaths.has(normalized)
}
function hasDescendant(knownPaths: Set<string>, path: string) {
const normalized = normalizePath(path)
if (!normalized) return false
for (const candidate of knownPaths) {
if (candidate === normalized || candidate.startsWith(`${normalized}/`)) {
return true
}
}
return false
}
function readJsonMap(fileTextByPath: Record<string, string | null | undefined>, path: string) {
const text = fileTextByPath[normalizePath(path)]
if (!text) return null
try {
return JSON.parse(text) as unknown
} catch {
return null
}
}
function readPluginMetadata(fileTextByPath: Record<string, string | null | undefined>, rootPath: string, manifestPath?: string | null): PluginMetadata {
const manifestCandidate = manifestPath ? normalizePath(manifestPath) : normalizePath(joinPath(rootPath, ".claude-plugin/plugin.json"))
const explicitManifest = manifestCandidate ? readJsonMap(fileTextByPath, manifestCandidate) : null
if (isRecord(explicitManifest)) {
return {
description: asString(explicitManifest.description),
metadata: explicitManifest,
name: asString(explicitManifest.name),
}
}
const fallbackPluginJson = readJsonMap(fileTextByPath, joinPath(rootPath, "plugin.json"))
if (isRecord(fallbackPluginJson)) {
return {
description: asString(fallbackPluginJson.description),
metadata: fallbackPluginJson,
name: asString(fallbackPluginJson.name),
}
}
return {
description: null,
metadata: {},
name: null,
}
}
function collectComponentPaths(knownPaths: Set<string>, rootPath: string) {
const componentPaths = {
agents: [] as string[],
commands: [] as string[],
hooks: [] as string[],
lspServers: [] as string[],
mcpServers: [] as string[],
monitors: [] as string[],
settings: [] as string[],
skills: [] as string[],
}
const candidates: Array<[keyof typeof componentPaths, string]> = [
["skills", joinPath(rootPath, "skills")],
["skills", joinPath(rootPath, ".claude/skills")],
["commands", joinPath(rootPath, "commands")],
["commands", joinPath(rootPath, ".claude/commands")],
["agents", joinPath(rootPath, "agents")],
["agents", joinPath(rootPath, ".claude/agents")],
["hooks", joinPath(rootPath, "hooks/hooks.json")],
["mcpServers", joinPath(rootPath, ".mcp.json")],
["lspServers", joinPath(rootPath, ".lsp.json")],
["monitors", joinPath(rootPath, "monitors/monitors.json")],
["settings", joinPath(rootPath, "settings.json")],
]
for (const [bucket, candidate] of candidates) {
if (!candidate) continue
if (bucket === "hooks" || bucket === "mcpServers" || bucket === "lspServers" || bucket === "monitors" || bucket === "settings") {
if (hasPath(knownPaths, candidate)) {
componentPaths[bucket].push(candidate)
}
continue
}
if (hasDescendant(knownPaths, candidate)) {
componentPaths[bucket].push(candidate)
}
}
return componentPaths
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value.flatMap((entry) => {
const normalized = asString(entry)
return normalized ? [normalized] : []
})
: []
}
function marketplaceComponentPaths(entry: MarketplaceEntry, knownPaths: Set<string>, rootPath: string) {
const collect = (values: unknown, { file, directory }: { file?: boolean; directory?: boolean }) => {
const paths: string[] = []
for (const value of readStringArray(values)) {
const candidate = joinPath(rootPath, value)
if (!candidate && !rootPath) {
continue
}
if ((directory && hasDescendant(knownPaths, candidate)) || (file && hasPath(knownPaths, candidate))) {
paths.push(candidate)
}
}
return paths
}
return {
agents: collect(entry.agents, { directory: true }),
commands: collect(entry.commands, { directory: true }),
hooks: collect(entry.hooks, { file: true, directory: true }),
lspServers: [],
mcpServers: collect(entry.mcpServers, { file: true }),
monitors: [],
settings: collect(entry.settings, { file: true }),
skills: collect(entry.skills, { directory: true }),
} satisfies GithubDiscoveredPlugin["componentPaths"]
}
function hasAnyComponentPaths(componentPaths: GithubDiscoveredPlugin["componentPaths"]) {
return Object.values(componentPaths).some((paths) => paths.length > 0)
}
function componentKindsFromPaths(componentPaths: GithubDiscoveredPlugin["componentPaths"]): GithubDiscoveredPluginComponentKind[] {
const kinds: GithubDiscoveredPluginComponentKind[] = []
if (componentPaths.skills.length > 0) kinds.push("skill")
if (componentPaths.commands.length > 0) kinds.push("command")
if (componentPaths.agents.length > 0) kinds.push("agent")
if (componentPaths.hooks.length > 0) kinds.push("hook")
if (componentPaths.mcpServers.length > 0) kinds.push("mcp_server")
if (componentPaths.lspServers.length > 0) kinds.push("lsp_server")
if (componentPaths.monitors.length > 0) kinds.push("monitor")
if (componentPaths.settings.length > 0) kinds.push("settings")
return kinds
}
function buildDiscoveredPlugin(input: {
componentPathsOverride?: GithubDiscoveredPlugin["componentPaths"] | null
description?: string | null
displayName?: string | null
fileTextByPath: Record<string, string | null | undefined>
key: string
knownPaths: Set<string>
manifestPath?: string | null
rootPath: string
sourceKind: GithubDiscoveredPluginSourceKind
supported?: boolean
warnings?: string[]
}) {
const metadata = readPluginMetadata(input.fileTextByPath, input.rootPath, input.manifestPath)
const componentPaths = input.componentPathsOverride ?? collectComponentPaths(input.knownPaths, input.rootPath)
const displayName = input.displayName?.trim()
|| metadata.name
|| basename(input.rootPath)
|| "Repository plugin"
return {
componentKinds: componentKindsFromPaths(componentPaths),
componentPaths,
description: input.description ?? metadata.description,
displayName,
key: input.key,
manifestPath: input.manifestPath ? normalizePath(input.manifestPath) : (hasPath(input.knownPaths, joinPath(input.rootPath, ".claude-plugin/plugin.json")) ? joinPath(input.rootPath, ".claude-plugin/plugin.json") : null),
metadata: metadata.metadata,
rootPath: normalizePath(input.rootPath),
selectedByDefault: input.supported !== false,
sourceKind: input.sourceKind,
supported: input.supported !== false,
warnings: input.warnings ?? [],
} satisfies GithubDiscoveredPlugin
}
function localMarketplaceRoot(entry: MarketplaceEntry) {
if (typeof entry.source === "string") {
return normalizePath(entry.source)
}
if (!isRecord(entry.source)) {
return null
}
if (typeof entry.source.url === "string") {
return null
}
const localPath = asString(entry.source.path)
return localPath ? normalizePath(localPath) : null
}
function pluginRootsFromManifests(entries: GithubDiscoveryTreeEntry[]) {
return entries
.map((entry) => normalizePath(entry.path))
.filter((path) => path.endsWith(".claude-plugin/plugin.json"))
.map((path) => path.slice(0, -"/.claude-plugin/plugin.json".length))
}
function inferredRootsFromKnownFolders(entries: GithubDiscoveryTreeEntry[]) {
const inferred = new Set<string>()
for (const entry of entries) {
const normalized = normalizePath(entry.path)
if (!normalized) continue
const segments = normalized.split("/")
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index]
if (!KNOWN_COMPONENT_SEGMENTS.includes(segment as (typeof KNOWN_COMPONENT_SEGMENTS)[number])) {
continue
}
const rootSegments = segments.slice(0, index)
if (rootSegments.length === 1 && rootSegments[0] === ".claude") {
inferred.add("")
continue
}
inferred.add(rootSegments.join("/"))
break
}
}
return [...inferred]
}
export function buildGithubRepoDiscovery(input: {
entries: GithubDiscoveryTreeEntry[]
fileTextByPath: Record<string, string | null | undefined>
}) {
const knownPaths = buildPathSet(input.entries)
const warnings: string[] = []
if (hasPath(knownPaths, ".claude-plugin/marketplace.json")) {
const marketplaceJson = readJsonMap(input.fileTextByPath, ".claude-plugin/marketplace.json")
const marketplaceEntries = isRecord(marketplaceJson) && Array.isArray(marketplaceJson.plugins)
? marketplaceJson.plugins.filter(isRecord) as MarketplaceEntry[]
: []
const marketplaceInfo: GithubMarketplaceInfo = isRecord(marketplaceJson)
? {
description: asString(marketplaceJson.description),
name: asString(marketplaceJson.name),
owner: isRecord(marketplaceJson.owner)
? asString(marketplaceJson.owner.name) ?? asString(marketplaceJson.owner.login) ?? asString(marketplaceJson.owner)
: asString(marketplaceJson.owner),
version: asString(marketplaceJson.version),
}
: { description: null, name: null, owner: null, version: null }
const discoveredPlugins = marketplaceEntries.map((entry, index) => {
const rootPath = localMarketplaceRoot(entry)
if (rootPath === null) {
const warning = "Marketplace entry points at an external source and cannot be auto-mapped from this connected repo yet."
warnings.push(warning)
return buildDiscoveredPlugin({
description: asString(entry.description),
displayName: asString(entry.name) ?? `Marketplace plugin ${index + 1}`,
fileTextByPath: input.fileTextByPath,
key: `marketplace:${asString(entry.name) ?? index}`,
knownPaths,
manifestPath: null,
rootPath: "",
sourceKind: "marketplace_entry",
supported: false,
warnings: [warning],
})
}
return buildDiscoveredPlugin({
componentPathsOverride: (() => {
const override = marketplaceComponentPaths(entry, knownPaths, rootPath)
return hasAnyComponentPaths(override) ? override : null
})(),
description: asString(entry.description),
displayName: asString(entry.name),
fileTextByPath: input.fileTextByPath,
key: `marketplace:${rootPath}`,
knownPaths,
manifestPath: joinPath(rootPath, ".claude-plugin/plugin.json"),
rootPath,
sourceKind: "marketplace_entry",
})
})
return {
classification: "claude_marketplace_repo",
discoveredPlugins,
marketplace: marketplaceInfo,
warnings,
} satisfies GithubRepoDiscoveryResult
}
const manifestRoots = [...new Set(pluginRootsFromManifests(input.entries))]
if (manifestRoots.length > 0) {
const discoveredPlugins = manifestRoots.map((rootPath) => buildDiscoveredPlugin({
fileTextByPath: input.fileTextByPath,
key: `manifest:${rootPath || "root"}`,
knownPaths,
manifestPath: joinPath(rootPath, ".claude-plugin/plugin.json"),
rootPath,
sourceKind: "plugin_manifest",
}))
return {
classification: manifestRoots.length === 1 && manifestRoots[0] === "" ? "claude_single_plugin_repo" : "claude_multi_plugin_repo",
discoveredPlugins,
marketplace: null,
warnings,
} satisfies GithubRepoDiscoveryResult
}
// Intentionally disabled for now: directory-based inference can over-classify
// arbitrary repos as plugins. Until we support a broader compatibility model,
// discovery should only accept explicit Claude plugin markers.
// const inferredRoots = inferredRootsFromKnownFolders(input.entries)
// const standaloneRoot = inferredRoots.includes("") && (
// hasDescendant(knownPaths, ".claude/skills")
// || hasDescendant(knownPaths, ".claude/commands")
// || hasDescendant(knownPaths, ".claude/agents")
// )
// const folderRoots = standaloneRoot ? inferredRoots : inferredRoots.filter((root) => root !== "")
//
// if (folderRoots.length > 0) {
// const discoveredPlugins = folderRoots.map((rootPath) => buildDiscoveredPlugin({
// fileTextByPath: input.fileTextByPath,
// key: `${standaloneRoot && rootPath === "" ? "standalone" : "folder"}:${rootPath || "root"}`,
// knownPaths,
// rootPath,
// sourceKind: standaloneRoot && rootPath === "" ? "standalone_claude" : "folder_inference",
// }))
//
// return {
// classification: "folder_inferred_repo",
// discoveredPlugins,
// warnings,
// } satisfies GithubRepoDiscoveryResult
// }
warnings.push("OpenWork currently only supports Claude-compatible plugins and marketplaces. Add `.claude-plugin/marketplace.json` or `.claude-plugin/plugin.json` to this repository.")
return {
classification: "unsupported",
discoveredPlugins: [],
marketplace: null,
warnings,
} satisfies GithubRepoDiscoveryResult
}

View File

@@ -1,5 +1,7 @@
export * from "./contracts.js"
export * from "./access.js"
export * from "./github-app.js"
export * from "./github-discovery.js"
export * from "./routes.js"
export * from "./schemas.js"
export * from "./store.js"

View File

@@ -24,11 +24,20 @@ import {
connectorAccountDisconnectSchema,
connectorAccountListQuerySchema,
connectorAccountListResponseSchema,
connectorAccountDisconnectResponseSchema,
connectorAccountMutationResponseSchema,
connectorInstanceAutoImportSchema,
connectorInstanceConfigurationResponseSchema,
connectorInstanceRemoveResponseSchema,
connectorAccountParamsSchema,
connectorAccountRepositoryParamsSchema,
connectorInstanceAccessGrantParamsSchema,
connectorInstanceCreateSchema,
githubConnectorDiscoveryResponseSchema,
githubDiscoveryApplyResponseSchema,
githubDiscoveryApplySchema,
githubDiscoveryTreeQuerySchema,
githubDiscoveryTreeResponseSchema,
connectorInstanceDetailResponseSchema,
connectorInstanceListQuerySchema,
connectorInstanceListResponseSchema,
@@ -54,6 +63,10 @@ import {
connectorTargetParamsSchema,
connectorTargetUpdateSchema,
githubConnectorAccountCreateSchema,
githubInstallCompleteResponseSchema,
githubInstallCompleteSchema,
githubInstallStartResponseSchema,
githubInstallStartSchema,
githubRepositoryListQuerySchema,
githubRepositoryListResponseSchema,
githubSetupResponseSchema,
@@ -68,6 +81,7 @@ import {
marketplaceMutationResponseSchema,
marketplaceParamsSchema,
marketplacePluginListResponseSchema,
marketplaceResolvedResponseSchema,
marketplacePluginMutationResponseSchema,
marketplacePluginParamsSchema,
marketplacePluginWriteSchema,
@@ -112,6 +126,7 @@ import {
getConnectorTargetDetail,
getLatestConfigObjectVersion,
getMarketplaceDetail,
getMarketplaceResolved,
getPluginDetail,
githubSetup,
listConfigObjectPlugins,
@@ -129,6 +144,13 @@ import {
listPlugins,
listResourceAccess,
attachPluginToMarketplace,
completeGithubConnectorInstall,
applyGithubConnectorDiscovery,
getConnectorInstanceConfiguration,
getGithubConnectorDiscovery,
getGithubConnectorDiscoveryTree,
removeConnectorInstance,
setConnectorInstanceAutoImport,
queueConnectorTargetResync,
removeConfigObjectFromPlugin,
removePluginFromMarketplace,
@@ -138,6 +160,7 @@ import {
setConnectorInstanceLifecycle,
setMarketplaceLifecycle,
setPluginLifecycle,
startGithubConnectorInstall,
updateConnectorInstance,
updateConnectorMapping,
updateConnectorTarget,
@@ -196,6 +219,62 @@ function withPluginArchOrgContext(app: Hono<any>, method: "delete" | "get" | "pa
}
export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
withPluginArchOrgContext(
app,
"post",
pluginArchRoutePaths.githubInstallStart,
jsonValidator(githubInstallStartSchema),
describeRoute({
tags: ["GitHub"],
summary: "Start GitHub install",
description: "Builds a GitHub App install redirect URL for the current organization.",
responses: {
200: jsonResponse("GitHub install redirect returned successfully.", githubInstallStartResponseSchema),
400: jsonResponse("The GitHub install request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to connect GitHub.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to connect GitHub.", forbiddenSchema),
},
}),
async (c: OrgContext) => {
try {
const context = actorContext(c)
await requirePluginArchCapability(context, "connector_account.create")
const body = validJson<any>(c)
return c.json({ ok: true, item: await startGithubConnectorInstall({ context, returnPath: body.returnPath }) })
} catch (error) {
return routeErrorResponse(c, error)
}
},
)
withPluginArchOrgContext(
app,
"post",
pluginArchRoutePaths.githubInstallComplete,
jsonValidator(githubInstallCompleteSchema),
describeRoute({
tags: ["GitHub"],
summary: "Complete GitHub install",
description: "Completes a GitHub App installation for the current organization and returns visible repositories.",
responses: {
200: jsonResponse("GitHub installation completed successfully.", githubInstallCompleteResponseSchema),
400: jsonResponse("The GitHub install completion request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to complete GitHub connection.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to complete GitHub connection.", forbiddenSchema),
},
}),
async (c: OrgContext) => {
try {
const context = actorContext(c)
await requirePluginArchCapability(context, "connector_account.create")
const body = validJson<any>(c)
return c.json({ ok: true, item: await completeGithubConnectorInstall({ context, installationId: body.installationId, state: body.state }) })
} catch (error) {
return routeErrorResponse(c, error)
}
},
)
withPluginArchOrgContext(
app,
"get",
@@ -958,6 +1037,28 @@ export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariable
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplaceResolved,
paramValidator(marketplaceParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Get marketplace resolved",
description: "Returns marketplace detail with plugins and derived source info.",
responses: {
200: jsonResponse("Marketplace resolved detail returned successfully.", marketplaceResolvedResponseSchema),
400: jsonResponse("The marketplace path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to view marketplaces.", unauthorizedSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json({ ok: true, item: await getMarketplaceResolved({ context: actorContext(c), marketplaceId: params.marketplaceId }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplacePlugins,
paramValidator(marketplaceParamsSchema),
jsonValidator(marketplacePluginWriteSchema),
@@ -1146,9 +1247,9 @@ export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariable
describeRoute({
tags: ["Connectors"],
summary: "Disconnect connector account",
description: "Marks a connector account as disconnected.",
description: "Disconnects a connector account and cleans up all associated connector-managed records.",
responses: {
200: jsonResponse("Connector account disconnected successfully.", connectorAccountMutationResponseSchema),
200: jsonResponse("Connector account disconnected and cleaned up successfully.", connectorAccountDisconnectResponseSchema),
400: jsonResponse("The connector account disconnect request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage connector accounts.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to manage connector accounts.", forbiddenSchema),
@@ -1280,6 +1381,150 @@ export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariable
})
}
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorInstanceConfiguration,
paramValidator(connectorInstanceParamsSchema),
describeRoute({
tags: ["Connectors"],
summary: "Get connector instance configuration",
description: "Returns the currently configured plugins and import stats for a connector instance.",
responses: {
200: jsonResponse("Connector instance configuration returned successfully.", connectorInstanceConfigurationResponseSchema),
400: jsonResponse("The connector instance path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to inspect connector instances.", unauthorizedSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
return c.json({ ok: true, item: await getConnectorInstanceConfiguration({ connectorInstanceId: validParam<any>(c).connectorInstanceId, context: actorContext(c) }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.connectorInstanceRemove,
paramValidator(connectorInstanceParamsSchema),
describeRoute({
tags: ["Connectors"],
summary: "Remove connector instance",
description: "Removes a connector instance and deletes the plugins, mappings, config objects, and bindings associated with it.",
responses: {
200: jsonResponse("Connector instance removed and cleaned up successfully.", connectorInstanceRemoveResponseSchema),
400: jsonResponse("The connector instance path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to remove connector instances.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to remove this connector instance.", forbiddenSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const context = actorContext(c)
return c.json({ ok: true, item: await removeConnectorInstance({ connectorInstanceId: validParam<any>(c).connectorInstanceId, context }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.connectorInstanceAutoImport,
paramValidator(connectorInstanceParamsSchema),
jsonValidator(connectorInstanceAutoImportSchema),
describeRoute({
tags: ["Connectors"],
summary: "Set connector instance auto-import",
description: "Enables or disables auto-import of new plugins on future push webhooks for a connector instance.",
responses: {
200: jsonResponse("Connector instance auto-import updated successfully.", connectorInstanceConfigurationResponseSchema),
400: jsonResponse("The auto-import request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to configure connector instances.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to configure this connector instance.", forbiddenSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const context = actorContext(c)
const params = validParam<any>(c)
const body = validJson<any>(c)
return c.json({ ok: true, item: await setConnectorInstanceAutoImport({ autoImportNewPlugins: Boolean(body.autoImportNewPlugins), connectorInstanceId: params.connectorInstanceId, context }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorInstanceDiscovery,
paramValidator(connectorInstanceParamsSchema),
describeRoute({
tags: ["GitHub"],
summary: "Get GitHub connector discovery",
description: "Analyzes a GitHub connector target and returns discovered plugin candidates.",
responses: {
200: jsonResponse("GitHub connector discovery returned successfully.", githubConnectorDiscoveryResponseSchema),
400: jsonResponse("The connector instance path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to inspect GitHub discovery.", unauthorizedSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
return c.json({ ok: true, item: await getGithubConnectorDiscovery({ connectorInstanceId: validParam<any>(c).connectorInstanceId, context: actorContext(c) }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorInstanceDiscoveryTree,
paramValidator(connectorInstanceParamsSchema),
queryValidator(githubDiscoveryTreeQuerySchema),
describeRoute({
tags: ["GitHub"],
summary: "List GitHub discovery tree entries",
description: "Pages through the normalized GitHub repository tree used during discovery.",
responses: {
200: jsonResponse("GitHub discovery tree returned successfully.", githubDiscoveryTreeResponseSchema),
400: jsonResponse("The discovery tree request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to inspect GitHub discovery tree entries.", unauthorizedSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
const query = validQuery<any>(c)
return c.json(await getGithubConnectorDiscoveryTree({ connectorInstanceId: params.connectorInstanceId, context: actorContext(c), cursor: query.cursor, limit: query.limit, prefix: query.prefix }))
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.connectorInstanceDiscoveryApply,
paramValidator(connectorInstanceParamsSchema),
jsonValidator(githubDiscoveryApplySchema),
describeRoute({
tags: ["GitHub"],
summary: "Apply GitHub discovery selection",
description: "Creates OpenWork plugins and connector mappings from selected discovery candidates.",
responses: {
200: jsonResponse("GitHub discovery selection applied successfully.", githubDiscoveryApplyResponseSchema),
400: jsonResponse("The discovery apply request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to apply discovery selections.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to edit this connector instance.", forbiddenSchema),
404: jsonResponse("The connector instance could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
const body = validJson<any>(c)
const context = actorContext(c)
if (Array.isArray(body.selectedKeys) && body.selectedKeys.length > 0) {
await requirePluginArchCapability(context, "plugin.create")
}
return c.json({ ok: true, item: await applyGithubConnectorDiscovery({ autoImportNewPlugins: Boolean(body.autoImportNewPlugins), connectorInstanceId: params.connectorInstanceId, context, selectedKeys: body.selectedKeys }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorInstanceAccess,
paramValidator(connectorInstanceParamsSchema),
describeRoute({
@@ -1713,6 +1958,6 @@ export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariable
}),
async (c: OrgContext) => {
const body = validJson<any>(c)
return c.json({ ok: true, item: await validateGithubTarget({ branch: body.branch, ref: body.ref, repositoryFullName: body.repositoryFullName }) })
return c.json({ ok: true, item: await validateGithubTarget({ branch: body.branch, installationId: body.installationId, ref: body.ref, repositoryFullName: body.repositoryFullName, repositoryId: body.repositoryId }) })
})
}

View File

@@ -344,6 +344,26 @@ export const githubConnectorSetupSchema = z.object({
mappings: z.array(connectorMappingCreateSchema).max(100).default([]),
})
export const githubInstallStartSchema = z.object({
returnPath: z.string().trim().min(1).max(1024),
})
export const githubInstallCompleteSchema = z.object({
installationId: z.number().int().positive(),
state: z.string().trim().min(1).max(4096),
})
export const githubDiscoveryApplySchema = z.object({
autoImportNewPlugins: z.boolean().default(false),
selectedKeys: z.array(z.string().trim().min(1).max(255)).max(200),
})
export const githubDiscoveryTreeQuerySchema = z.object({
cursor: z.string().trim().min(1).max(255).optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
prefix: z.string().trim().min(1).max(1024).optional(),
})
export const githubConnectorAccountCreateSchema = z.object({
installationId: z.number().int().positive(),
accountLogin: z.string().trim().min(1).max(255),
@@ -427,6 +447,10 @@ export const pluginSchema = z.object({
updatedAt: z.string().datetime({ offset: true }),
deletedAt: nullableTimestampSchema,
memberCount: z.number().int().nonnegative().optional(),
marketplaces: z.array(z.object({
id: marketplaceIdSchema,
name: z.string().trim().min(1).max(255),
})).optional(),
}).meta({ ref: "PluginArchPlugin" })
export const marketplacePluginSchema = z.object({
@@ -461,6 +485,7 @@ export const connectorAccountSchema = z.object({
externalAccountRef: z.string().trim().min(1).max(255).nullable(),
displayName: z.string().trim().min(1).max(255),
status: connectorAccountStatusSchema,
createdByName: z.string().trim().min(1).max(255).nullable().optional(),
createdByOrgMembershipId: memberIdSchema,
createdAt: z.string().datetime({ offset: true }),
updatedAt: z.string().datetime({ offset: true }),
@@ -672,6 +697,23 @@ export const pluginMembershipMutationResponseSchema = pluginArchMutationResponse
export const marketplaceListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplaceListResponse", marketplaceSchema)
export const marketplaceDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchMarketplaceDetailResponse", marketplaceSchema)
export const marketplaceMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplaceMutationResponse", marketplaceSchema)
export const marketplaceResolvedResponseSchema = pluginArchMutationResponseSchema(
"PluginArchMarketplaceResolvedResponse",
z.object({
marketplace: marketplaceSchema,
plugins: z.array(pluginSchema.extend({
componentCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
})),
source: z.object({
connectorAccountId: connectorAccountIdSchema,
connectorInstanceId: connectorInstanceIdSchema,
accountLogin: z.string().trim().min(1).nullable(),
repositoryFullName: z.string().trim().min(1),
branch: z.string().trim().min(1).nullable(),
}).nullable(),
}),
)
export const marketplacePluginListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplacePluginListResponse", marketplacePluginSchema)
export const marketplacePluginMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplacePluginMutationResponse", marketplacePluginSchema)
export const accessGrantListResponseSchema = pluginArchListResponseSchema("PluginArchAccessGrantListResponse", accessGrantSchema)
@@ -679,6 +721,42 @@ export const accessGrantMutationResponseSchema = pluginArchMutationResponseSchem
export const connectorAccountListResponseSchema = pluginArchListResponseSchema("PluginArchConnectorAccountListResponse", connectorAccountSchema)
export const connectorAccountDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchConnectorAccountDetailResponse", connectorAccountSchema)
export const connectorAccountMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchConnectorAccountMutationResponse", connectorAccountSchema)
export const connectorAccountDisconnectResponseSchema = pluginArchMutationResponseSchema(
"PluginArchConnectorAccountDisconnectResponse",
z.object({
deletedConfigObjectCount: z.number().int().nonnegative(),
deletedConnectorInstanceCount: z.number().int().nonnegative(),
deletedConnectorMappingCount: z.number().int().nonnegative(),
disconnectedAccountId: connectorAccountIdSchema,
reason: z.string().trim().nullable(),
}),
)
export const connectorInstanceConfiguredPluginSchema = pluginSchema.extend({
componentCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
rootPath: z.string().nullable(),
}).meta({ ref: "PluginArchConnectorInstanceConfiguredPlugin" })
export const connectorInstanceConfigurationResponseSchema = pluginArchMutationResponseSchema(
"PluginArchConnectorInstanceConfigurationResponse",
z.object({
autoImportNewPlugins: z.boolean(),
configuredPlugins: z.array(connectorInstanceConfiguredPluginSchema),
connectorInstance: connectorInstanceSchema,
importedConfigObjectCount: z.number().int().nonnegative(),
mappingCount: z.number().int().nonnegative(),
}),
)
export const connectorInstanceAutoImportSchema = z.object({
autoImportNewPlugins: z.boolean(),
})
export const connectorInstanceRemoveResponseSchema = pluginArchMutationResponseSchema(
"PluginArchConnectorInstanceRemoveResponse",
z.object({
deletedConfigObjectCount: z.number().int().nonnegative(),
deletedConnectorMappingCount: z.number().int().nonnegative(),
removedConnectorInstanceId: connectorInstanceIdSchema,
}),
)
export const connectorInstanceListResponseSchema = pluginArchListResponseSchema("PluginArchConnectorInstanceListResponse", connectorInstanceSchema)
export const connectorInstanceDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchConnectorInstanceDetailResponse", connectorInstanceSchema)
export const connectorInstanceMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchConnectorInstanceMutationResponse", connectorInstanceSchema)
@@ -697,9 +775,95 @@ export const githubRepositorySchema = z.object({
id: z.number().int().positive(),
fullName: z.string().trim().min(1),
defaultBranch: z.string().trim().min(1).nullable(),
hasPluginManifest: z.boolean().optional(),
manifestKind: z.enum(["marketplace", "plugin"]).nullable().optional(),
marketplacePluginCount: z.number().int().nonnegative().nullable().optional(),
private: z.boolean(),
}).meta({ ref: "PluginArchGithubRepository" })
export const githubRepositoryListResponseSchema = pluginArchListResponseSchema("PluginArchGithubRepositoryListResponse", githubRepositorySchema)
export const githubDiscoveryStepSchema = z.object({
id: z.enum(["read_repository_structure", "check_marketplace_manifest", "check_plugin_manifests", "prepare_discovered_plugins"]),
label: z.string().trim().min(1),
status: z.enum(["completed", "running", "warning"]),
}).meta({ ref: "PluginArchGithubDiscoveryStep" })
export const githubDiscoveryTreeSummarySchema = z.object({
scannedEntryCount: z.number().int().nonnegative(),
strategy: z.enum(["git-tree-recursive"]),
truncated: z.boolean(),
}).meta({ ref: "PluginArchGithubDiscoveryTreeSummary" })
export const githubDiscoveredPluginSchema = z.object({
key: z.string().trim().min(1),
sourceKind: z.enum(["marketplace_entry", "plugin_manifest", "standalone_claude", "folder_inference"]),
rootPath: z.string(),
displayName: z.string().trim().min(1),
description: nullableStringSchema,
selectedByDefault: z.boolean(),
supported: z.boolean(),
manifestPath: nullableStringSchema,
warnings: z.array(z.string().trim().min(1)),
componentKinds: z.array(z.enum(["skill", "command", "agent", "hook", "mcp_server", "lsp_server", "monitor", "settings"])),
componentPaths: z.object({
agents: z.array(z.string().trim().min(1)),
commands: z.array(z.string().trim().min(1)),
hooks: z.array(z.string().trim().min(1)),
lspServers: z.array(z.string().trim().min(1)),
mcpServers: z.array(z.string().trim().min(1)),
monitors: z.array(z.string().trim().min(1)),
settings: z.array(z.string().trim().min(1)),
skills: z.array(z.string().trim().min(1)),
}),
metadata: jsonObjectSchema,
}).meta({ ref: "PluginArchGithubDiscoveredPlugin" })
export const githubConnectorDiscoveryResponseSchema = pluginArchMutationResponseSchema(
"PluginArchGithubConnectorDiscoveryResponse",
z.object({
autoImportNewPlugins: z.boolean(),
classification: z.enum(["claude_marketplace_repo", "claude_multi_plugin_repo", "claude_single_plugin_repo", "folder_inferred_repo", "unsupported"]),
connectorInstance: connectorInstanceSchema,
connectorTarget: connectorTargetSchema,
discoveredPlugins: z.array(githubDiscoveredPluginSchema),
repositoryFullName: z.string().trim().min(1),
sourceRevisionRef: z.string().trim().min(1),
steps: z.array(githubDiscoveryStepSchema),
treeSummary: githubDiscoveryTreeSummarySchema,
warnings: z.array(z.string().trim().min(1)),
}),
)
export const githubDiscoveryTreeEntrySchema = z.object({
id: z.string().trim().min(1),
kind: z.enum(["blob", "tree"]),
path: z.string().trim().min(1),
sha: nullableStringSchema,
size: z.number().int().nonnegative().nullable(),
}).meta({ ref: "PluginArchGithubDiscoveryTreeEntry" })
export const githubDiscoveryTreeResponseSchema = pluginArchListResponseSchema("PluginArchGithubDiscoveryTreeResponse", githubDiscoveryTreeEntrySchema)
export const githubDiscoveryApplyResponseSchema = pluginArchMutationResponseSchema(
"PluginArchGithubDiscoveryApplyResponse",
z.object({
autoImportNewPlugins: z.boolean(),
createdMarketplace: marketplaceSchema.nullable().optional(),
connectorInstance: connectorInstanceSchema,
connectorTarget: connectorTargetSchema,
createdPlugins: z.array(pluginSchema),
createdMappings: z.array(connectorMappingSchema),
materializedConfigObjects: z.array(configObjectSchema),
sourceRevisionRef: z.string().trim().min(1),
}),
)
export const githubInstallStartResponseSchema = pluginArchMutationResponseSchema(
"PluginArchGithubInstallStartResponse",
z.object({
redirectUrl: z.string().url(),
state: z.string().trim().min(1),
}),
)
export const githubInstallCompleteResponseSchema = pluginArchMutationResponseSchema(
"PluginArchGithubInstallCompleteResponse",
z.object({
connectorAccount: connectorAccountSchema,
repositories: z.array(githubRepositorySchema),
}),
)
export const githubSetupResponseSchema = pluginArchMutationResponseSchema(
"PluginArchGithubSetupResponse",
z.object({

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
import { describe, expect, test } from "bun:test"
import { generateKeyPairSync } from "node:crypto"
import {
buildGithubAppInstallUrl,
createGithubInstallStateToken,
createGithubAppJwt,
getGithubAppSummary,
getGithubConnectorAppConfig,
getGithubInstallationSummary,
listGithubInstallationRepositories,
normalizeGithubPrivateKey,
validateGithubInstallationTarget,
verifyGithubInstallStateToken,
} from "../src/routes/org/plugin-system/github-app.js"
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 })
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString()
describe("github connector app helpers", () => {
test("normalizes escaped private keys and produces a signed app JWT", () => {
const escapedKey = privateKeyPem.replace(/\n/g, "\\n")
expect(normalizeGithubPrivateKey(escapedKey)).toBe(privateKeyPem)
const config = getGithubConnectorAppConfig({
appId: "123456",
privateKey: escapedKey,
})
const jwt = createGithubAppJwt({ ...config, now: new Date("2026-04-21T19:00:00.000Z") })
const [headerSegment, payloadSegment, signatureSegment] = jwt.split(".")
expect(signatureSegment.length).toBeGreaterThan(0)
expect(JSON.parse(Buffer.from(headerSegment, "base64url").toString("utf8"))).toEqual({ alg: "RS256", typ: "JWT" })
expect(JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"))).toMatchObject({
iss: "123456",
})
})
test("lists repositories through the GitHub installation token flow", async () => {
const requests: Array<{ method: string; url: string }> = []
const repositories = await listGithubInstallationRepositories({
config: { appId: "123456", privateKey: privateKeyPem },
fetchFn: async (url, init) => {
requests.push({
method: init?.method ?? "GET",
url: String(url),
})
if (String(url).endsWith("/access_tokens")) {
return new Response(JSON.stringify({ token: "installation-token" }), { status: 201 })
}
if (String(url).endsWith("/contents/.claude-plugin/marketplace.json")) {
if (String(url).includes("different-ai/openwork")) {
const content = Buffer.from(JSON.stringify({ plugins: [{ name: "a" }, { name: "b" }, { name: "c" }] })).toString("base64")
return new Response(JSON.stringify({ content, encoding: "base64" }), { status: 200 })
}
return new Response(JSON.stringify({ message: "not found" }), { status: 404 })
}
if (String(url).endsWith("/contents/.claude-plugin/plugin.json")) {
if (String(url).includes("different-ai/opencode")) {
return new Response(JSON.stringify({ name: "plugin.json" }), { status: 200 })
}
return new Response(JSON.stringify({ message: "not found" }), { status: 404 })
}
return new Response(JSON.stringify({
repositories: [
{ default_branch: "main", full_name: "different-ai/openwork", id: 42, private: true },
{ default_branch: "dev", full_name: "different-ai/opencode", id: 99, private: false },
],
}), { status: 200 })
},
installationId: 777,
})
expect(requests.map((request) => request.url)).toEqual([
"https://api.github.com/app/installations/777/access_tokens",
"https://api.github.com/installation/repositories",
"https://api.github.com/repos/different-ai/openwork/contents/.claude-plugin/marketplace.json",
"https://api.github.com/repos/different-ai/opencode/contents/.claude-plugin/marketplace.json",
"https://api.github.com/repos/different-ai/opencode/contents/.claude-plugin/plugin.json",
])
expect(repositories).toEqual([
{ defaultBranch: "main", fullName: "different-ai/openwork", hasPluginManifest: true, id: 42, manifestKind: "marketplace", marketplacePluginCount: 3, private: true },
{ defaultBranch: "dev", fullName: "different-ai/opencode", hasPluginManifest: true, id: 99, manifestKind: "plugin", marketplacePluginCount: null, private: false },
])
})
test("builds install URLs and validates signed state tokens", async () => {
const app = await getGithubAppSummary({
config: { appId: "123456", privateKey: privateKeyPem },
fetchFn: async () => new Response(JSON.stringify({
html_url: "https://github.com/apps/openwork-test",
name: "OpenWork Test",
slug: "openwork-test",
}), { status: 200 }),
})
const token = createGithubInstallStateToken({
now: new Date("2026-04-21T19:00:00.000Z"),
orgId: "org_123",
returnPath: "/o/test-org/dashboard/integrations/github",
secret: "secret-123",
userId: "user_123",
})
expect(buildGithubAppInstallUrl({ app, state: token })).toBe(`https://github.com/apps/openwork-test/installations/new?state=${encodeURIComponent(token)}`)
expect(verifyGithubInstallStateToken({ now: new Date("2026-04-21T19:05:00.000Z"), secret: "secret-123", token })).toMatchObject({
orgId: "org_123",
returnPath: "/o/test-org/dashboard/integrations/github",
userId: "user_123",
})
expect(verifyGithubInstallStateToken({ now: new Date("2026-04-21T19:05:00.000Z"), secret: "wrong-secret", token })).toBeNull()
})
test("reads GitHub installation account details", async () => {
const installation = await getGithubInstallationSummary({
config: { appId: "123456", privateKey: privateKeyPem },
fetchFn: async (url) => {
if (String(url).endsWith("/app/installations/777")) {
return new Response(JSON.stringify({
account: {
login: "different-ai",
type: "Organization",
},
id: 777,
}), { status: 200 })
}
return new Response(JSON.stringify({ message: "not found" }), { status: 404 })
},
installationId: 777,
})
expect(installation).toEqual({
accountLogin: "different-ai",
accountType: "Organization",
displayName: "different-ai",
installationId: 777,
repositorySelection: "all",
settingsUrl: null,
})
})
test("validates repository identity and branch existence against GitHub", async () => {
const result = await validateGithubInstallationTarget({
branch: "main",
config: { appId: "123456", privateKey: privateKeyPem },
fetchFn: async (url) => {
if (String(url).endsWith("/access_tokens")) {
return new Response(JSON.stringify({ token: "installation-token" }), { status: 201 })
}
if (String(url).endsWith("/repos/different-ai/openwork")) {
return new Response(JSON.stringify({
default_branch: "main",
full_name: "different-ai/openwork",
id: 42,
}), { status: 200 })
}
if (String(url).endsWith("/repos/different-ai/openwork/branches/main")) {
return new Response(JSON.stringify({ name: "main" }), { status: 200 })
}
return new Response(JSON.stringify({ message: "not found" }), { status: 404 })
},
installationId: 777,
ref: "refs/heads/main",
repositoryFullName: "different-ai/openwork",
repositoryId: 42,
})
expect(result).toEqual({
branchExists: true,
defaultBranch: "main",
repositoryAccessible: true,
})
})
})

View File

@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test"
import { buildGithubRepoDiscovery, type GithubDiscoveryTreeEntry } from "../src/routes/org/plugin-system/github-discovery.js"
function blob(path: string): GithubDiscoveryTreeEntry {
return { id: path, kind: "blob", path, sha: null, size: null }
}
describe("github discovery", () => {
test("classifies marketplace repos and resolves local plugin roots", () => {
const result = buildGithubRepoDiscovery({
entries: [
blob(".claude-plugin/marketplace.json"),
blob("plugins/sales/.claude-plugin/plugin.json"),
blob("plugins/sales/skills/hello/SKILL.md"),
blob("plugins/sales/commands/deploy.md"),
],
fileTextByPath: {
".claude-plugin/marketplace.json": JSON.stringify({
plugins: [
{ name: "sales", description: "Sales workflows", source: "./plugins/sales" },
],
}),
"plugins/sales/.claude-plugin/plugin.json": JSON.stringify({
name: "sales",
description: "Sales plugin",
}),
},
})
expect(result.classification).toBe("claude_marketplace_repo")
expect(result.discoveredPlugins).toHaveLength(1)
expect(result.discoveredPlugins[0]).toMatchObject({
displayName: "sales",
rootPath: "plugins/sales",
sourceKind: "marketplace_entry",
})
expect(result.discoveredPlugins[0]?.componentPaths.skills).toEqual(["plugins/sales/skills"])
expect(result.discoveredPlugins[0]?.componentPaths.commands).toEqual(["plugins/sales/commands"])
})
test("treats marketplace source './' as the current repo root", () => {
const result = buildGithubRepoDiscovery({
entries: [
blob(".claude-plugin/marketplace.json"),
blob("skills/agent-browser/SKILL.md"),
blob("skills/other-skill/SKILL.md"),
],
fileTextByPath: {
".claude-plugin/marketplace.json": JSON.stringify({
plugins: [
{
name: "agent-browser",
description: "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
source: "./",
strict: false,
skills: ["./skills/agent-browser"],
category: "development",
},
],
}),
},
})
expect(result.classification).toBe("claude_marketplace_repo")
expect(result.warnings).toEqual([])
expect(result.discoveredPlugins).toHaveLength(1)
expect(result.discoveredPlugins[0]).toMatchObject({
displayName: "agent-browser",
rootPath: "",
sourceKind: "marketplace_entry",
supported: true,
})
expect(result.discoveredPlugins[0]?.componentPaths.skills).toEqual(["skills/agent-browser"])
})
test("treats non-Claude folder-only repos as unsupported", () => {
const result = buildGithubRepoDiscovery({
entries: [
blob("Sales/skills/pitch/SKILL.md"),
blob("Sales/commands/release.md"),
blob("finance/agents/reviewer.md"),
blob("finance/commands/audit.md"),
],
fileTextByPath: {
"Sales/plugin.json": JSON.stringify({ name: "Sales", description: "Sales tools" }),
},
})
expect(result.classification).toBe("unsupported")
expect(result.discoveredPlugins).toEqual([])
expect(result.warnings[0]).toContain("only supports Claude-compatible plugins and marketplaces")
})
test("treats standalone .claude directories as unsupported without plugin manifests", () => {
const result = buildGithubRepoDiscovery({
entries: [
blob(".claude/skills/research/SKILL.md"),
blob(".claude/commands/publish.md"),
],
fileTextByPath: {},
})
expect(result.classification).toBe("unsupported")
expect(result.discoveredPlugins).toEqual([])
expect(result.warnings[0]).toContain("only supports Claude-compatible plugins and marketplaces")
})
})

View File

@@ -30,7 +30,7 @@ function createWebhookApp() {
test("webhook route rejects invalid signatures before JSON parsing", async () => {
envModule.env.githubConnectorApp.webhookSecret = "super-secret"
const app = createWebhookApp()
const response = await app.request("http://den.local/api/webhooks/connectors/github", {
const response = await app.request("http://den.local/v1/webhooks/connectors/github", {
body: "{",
headers: {
"x-github-delivery": "delivery-1",
@@ -47,7 +47,7 @@ test("webhook route rejects invalid signatures before JSON parsing", async () =>
test("webhook route returns 503 when the GitHub webhook secret is unset", async () => {
envModule.env.githubConnectorApp.webhookSecret = undefined
const app = createWebhookApp()
const response = await app.request("http://den.local/api/webhooks/connectors/github", {
const response = await app.request("http://den.local/v1/webhooks/connectors/github", {
body: "{}",
headers: {
"x-github-delivery": "delivery-2",
@@ -72,7 +72,7 @@ test("webhook route accepts a valid signature and ignores unbound deliveries cle
},
})
const response = await app.request("http://den.local/api/webhooks/connectors/github", {
const response = await app.request("http://den.local/v1/webhooks/connectors/github", {
body: payload,
headers: {
"x-github-delivery": "delivery-3",

View File

@@ -349,10 +349,30 @@ export function getPluginRoute(orgSlug: string | null | undefined, pluginId: str
return `${getPluginsRoute(orgSlug)}/${encodeURIComponent(pluginId)}`;
}
export function getMarketplacesRoute(orgSlug?: string | null): string {
return `${getOrgDashboardRoute(orgSlug)}/marketplaces`;
}
export function getMarketplaceRoute(orgSlug: string | null | undefined, marketplaceId: string): string {
return `${getMarketplacesRoute(orgSlug)}/${encodeURIComponent(marketplaceId)}`;
}
export function getIntegrationsRoute(orgSlug?: string | null): string {
return `${getOrgDashboardRoute(orgSlug)}/integrations`;
}
export function getGithubIntegrationRoute(orgSlug?: string | null): string {
return `${getIntegrationsRoute(orgSlug)}/github`;
}
export function getGithubIntegrationSetupRoute(orgSlug: string | null | undefined, connectorInstanceId: string): string {
return `${getGithubIntegrationRoute(orgSlug)}?connectorInstanceId=${encodeURIComponent(connectorInstanceId)}`;
}
export function getGithubIntegrationAccountRoute(orgSlug: string | null | undefined, connectorAccountId: string): string {
return `${getGithubIntegrationRoute(orgSlug)}?connectorAccountId=${encodeURIComponent(connectorAccountId)}`;
}
export function parseOrgListPayload(payload: unknown): {
orgs: DenOrgSummary[];
activeOrgId: string | null;

View File

@@ -0,0 +1 @@
export { default } from "../../../o/[orgSlug]/dashboard/integrations/github/page";

View File

@@ -0,0 +1 @@
export { default } from "../../../o/[orgSlug]/dashboard/marketplaces/[marketplaceId]/page";

View File

@@ -0,0 +1 @@
export { default } from "../../o/[orgSlug]/dashboard/marketplaces/page";

View File

@@ -1,39 +1,37 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
/**
* Integrations model — the "connectors" layer that sits in front of Plugins.
*
* A plugin catalog is only populated once at least one integration is
* connected. Until then, plugins/skills/hooks/mcps all render empty.
*
* In this preview the OAuth flow is fully mocked: the UI walks through the
* same steps it would for a real integration (authorize → select account
* → select repositories → connecting → connected) but never leaves the app.
* State lives in the React Query cache, scoped to the dashboard subtree, so
* it is intentionally in-memory only.
*/
// ── Types ──────────────────────────────────────────────────────────────────
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
export type IntegrationProvider = "github" | "bitbucket";
export type IntegrationAccount = {
id: string;
installationId?: number;
manageUrl?: string | null;
name: string;
/** `user` or `org`/`workspace` */
kind: "user" | "org";
avatarInitial: string;
createdByName?: string | null;
ownerName?: string;
repositorySelection?: "all" | "selected";
};
export type IntegrationRepoManifestKind = "marketplace" | "plugin" | null;
export type IntegrationRepo = {
connectorInstanceId?: string;
id: string;
name: string;
fullName: string;
description: string;
/** whether this repo contributes plugins when connected */
hasPluginManifest?: boolean;
manifestKind?: IntegrationRepoManifestKind;
marketplacePluginCount?: number | null;
hasPlugins: boolean;
defaultBranch?: string | null;
private?: boolean;
};
export type ConnectedIntegration = {
@@ -44,9 +42,80 @@ export type ConnectedIntegration = {
connectedAt: string;
};
// ── Provider catalog (static UI metadata) ──────────────────────────────────
export type GithubInstallStartResult = {
redirectUrl: string;
state: string;
};
export type IntegrationProviderMeta = {
export type GithubInstallCompleteResult = {
connectorAccount: {
id: string;
displayName: string;
metadata?: Record<string, unknown>;
};
repositories: IntegrationRepo[];
};
export type GithubConnectorCreationResult = {
connectorInstanceId: string;
connectorTargetId: string;
repositoryFullName: string;
};
export type GithubDiscoveryStep = {
id: string;
label: string;
status: "completed" | "running" | "warning";
};
export type GithubDiscoveredPlugin = {
componentKinds: string[];
componentPaths: {
agents: string[];
commands: string[];
hooks: string[];
lspServers: string[];
mcpServers: string[];
monitors: string[];
settings: string[];
skills: string[];
};
description: string | null;
displayName: string;
key: string;
manifestPath: string | null;
rootPath: string;
selectedByDefault: boolean;
sourceKind: string;
supported: boolean;
warnings: string[];
};
export type GithubConnectorDiscoveryResult = {
autoImportNewPlugins: boolean;
classification: string;
connectorInstanceId: string;
connectorTargetId: string;
discoveredPlugins: GithubDiscoveredPlugin[];
repositoryFullName: string;
sourceRevisionRef: string;
steps: GithubDiscoveryStep[];
treeSummary: {
scannedEntryCount: number;
strategy: string;
truncated: boolean;
};
warnings: string[];
};
export type GithubDiscoveryApplyResult = {
autoImportNewPlugins: boolean;
createdMappingCount: number;
materializedConfigObjectCount: number;
createdPluginNames: string[];
};
type IntegrationProviderMeta = {
provider: IntegrationProvider;
name: string;
description: string;
@@ -58,9 +127,9 @@ export const INTEGRATION_PROVIDERS: Record<IntegrationProvider, IntegrationProvi
github: {
provider: "github",
name: "GitHub",
description: "Connect repositories on GitHub to discover plugins, skills, and MCP servers.",
docsHref: "https://docs.github.com/en/apps/oauth-apps",
scopes: ["repo", "read:org"],
description: "Install the OpenWork GitHub App, then pick a repository to turn into a connector instance.",
docsHref: "https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps",
scopes: ["metadata:read", "contents:read", "webhooks"],
},
bitbucket: {
provider: "bitbucket",
@@ -71,13 +140,6 @@ export const INTEGRATION_PROVIDERS: Record<IntegrationProvider, IntegrationProvi
},
};
// ── Mock backing store (in-memory, keyed on the React Query cache) ────────
//
// The store below models what a real server would return. The React Query
// queryFn reads from this module-level array; mutations push/splice it and
// then invalidate the cache. Swapping for a real API later is a one-line
// change inside each queryFn/mutationFn.
let mockConnections: ConnectedIntegration[] = [];
export function getMockAccountsFor(provider: IntegrationProvider): IntegrationAccount[] {
@@ -94,10 +156,7 @@ export function getMockAccountsFor(provider: IntegrationProvider): IntegrationAc
];
}
export function getMockReposFor(
provider: IntegrationProvider,
accountId: string,
): IntegrationRepo[] {
export function getMockReposFor(provider: IntegrationProvider, accountId: string): IntegrationRepo[] {
const tag = `${provider}:${accountId}`;
const base: IntegrationRepo[] = [
{
@@ -121,20 +180,6 @@ export function getMockReposFor(
description: "Infra-as-code for Den Cloud. No plugins yet.",
hasPlugins: false,
},
{
id: `${tag}:llm-ops`,
name: "llm-ops",
fullName: `${accountToLabel(accountId)}/llm-ops`,
description: "Evaluation harnesses, eval data, and dashboard for prompt regressions.",
hasPlugins: true,
},
{
id: `${tag}:design-system`,
name: "design-system",
fullName: `${accountToLabel(accountId)}/design-system`,
description: "Shared UI primitives used by the web and desktop apps.",
hasPlugins: false,
},
];
return base;
}
@@ -146,7 +191,131 @@ function accountToLabel(accountId: string): string {
return "bshafii";
}
// ── Display helpers ────────────────────────────────────────────────────────
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseGithubConnectorAccounts(payload: unknown) {
if (!isRecord(payload) || !Array.isArray(payload.items)) {
return [];
}
return payload.items.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const id = asString(entry.id);
const displayName = asString(entry.displayName);
const createdAt = asString(entry.createdAt);
const externalAccountRef = asString(entry.externalAccountRef);
const createdByName = asNullableString(entry.createdByName);
const metadata = isRecord(entry.metadata) ? entry.metadata : undefined;
if (!id || !displayName || !createdAt) {
return [];
}
const remoteId = asString(entry.remoteId);
return [{ id, createdAt, createdByName, displayName, externalAccountRef, metadata, remoteId }];
});
}
function parseGithubConnectorInstances(payload: unknown) {
if (!isRecord(payload) || !Array.isArray(payload.items)) {
return [];
}
return payload.items.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const id = asString(entry.id);
const connectorAccountId = asString(entry.connectorAccountId);
const remoteId = asNullableString(entry.remoteId);
const name = asString(entry.name);
if (!id || !connectorAccountId || !name) {
return [];
}
return [{ connectorAccountId, id, name, remoteId }];
});
}
function toAccountKind(metadata: Record<string, unknown> | undefined): "org" | "user" {
return metadata?.accountType === "Organization" ? "org" : "user";
}
function toAvatarInitial(name: string) {
return name.trim().charAt(0).toUpperCase() || "?";
}
function toRepoName(fullName: string) {
const parts = fullName.split("/");
return parts[parts.length - 1] ?? fullName;
}
async function simulateLatency(ms = 450) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function fetchGithubConnections() {
const [accountsResult, instancesResult] = await Promise.all([
requestJson("/v1/connector-accounts?connectorType=github&status=active&limit=100", { method: "GET" }, 15000),
requestJson("/v1/connector-instances?connectorType=github&status=active&limit=100", { method: "GET" }, 15000),
]);
if (!accountsResult.response.ok) {
throw new Error(getErrorMessage(accountsResult.payload, `Failed to load GitHub integrations (${accountsResult.response.status}).`));
}
if (!instancesResult.response.ok) {
throw new Error(getErrorMessage(instancesResult.payload, `Failed to load GitHub connector instances (${instancesResult.response.status}).`));
}
const accounts = parseGithubConnectorAccounts(accountsResult.payload);
const instances = parseGithubConnectorInstances(instancesResult.payload);
return accounts.map<ConnectedIntegration>((account) => ({
id: account.id,
provider: "github",
account: {
avatarInitial: toAvatarInitial(account.displayName),
createdByName: account.createdByName,
id: account.id,
installationId: account.remoteId ? Number(account.remoteId) : undefined,
kind: toAccountKind(account.metadata),
manageUrl: typeof account.metadata?.settingsUrl === "string" ? account.metadata.settingsUrl : null,
name: account.displayName,
ownerName: account.externalAccountRef ?? (typeof account.metadata?.accountLogin === "string" ? account.metadata.accountLogin : undefined),
repositorySelection: account.metadata?.repositorySelection === "selected" ? "selected" : "all",
},
connectedAt: account.createdAt,
repos: instances
.filter((instance) => instance.connectorAccountId === account.id && instance.remoteId)
.map((instance) => ({
connectorInstanceId: instance.id,
defaultBranch: null,
description: "Repository selected for connector sync.",
fullName: instance.remoteId ?? instance.name,
hasPlugins: true,
id: instance.id,
name: toRepoName(instance.remoteId ?? instance.name),
})),
}));
}
async function fetchConnections(): Promise<ConnectedIntegration[]> {
const githubConnections = await fetchGithubConnections();
return [...githubConnections, ...mockConnections];
}
export function formatIntegrationTimestamp(value: string | null): string {
if (!value) return "Recently connected";
@@ -163,30 +332,42 @@ export function getProviderMeta(provider: IntegrationProvider): IntegrationProvi
return INTEGRATION_PROVIDERS[provider];
}
// ── Query keys ─────────────────────────────────────────────────────────────
export const integrationQueryKeys = {
all: ["integrations"] as const,
list: () => [...integrationQueryKeys.all, "list"] as const,
list: (orgId?: string | null) => [...integrationQueryKeys.all, "list", orgId ?? "none"] as const,
accounts: (provider: IntegrationProvider) => [...integrationQueryKeys.all, "accounts", provider] as const,
repos: (provider: IntegrationProvider, accountId: string | null) =>
[...integrationQueryKeys.all, "repos", provider, accountId ?? "none"] as const,
githubInstall: (installationId: number | null) => [...integrationQueryKeys.all, "github-install", installationId ?? 0] as const,
githubDiscovery: (connectorInstanceId: string | null) => [...integrationQueryKeys.all, "github-discovery", connectorInstanceId ?? "none"] as const,
connectorInstanceConfiguration: (connectorInstanceId: string | null) => [...integrationQueryKeys.all, "connector-instance-config", connectorInstanceId ?? "none"] as const,
};
// ── Hooks ──────────────────────────────────────────────────────────────────
export type ConnectorInstanceConfiguredPlugin = {
id: string;
name: string;
description: string | null;
memberCount: number;
componentCounts: Record<string, number>;
rootPath: string | null;
};
async function simulateLatency(ms = 450) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function fetchConnections(): Promise<ConnectedIntegration[]> {
await simulateLatency(150);
return [...mockConnections];
}
export type ConnectorInstanceConfiguration = {
autoImportNewPlugins: boolean;
configuredPlugins: ConnectorInstanceConfiguredPlugin[];
connectorInstanceId: string;
connectorInstanceName: string;
importedConfigObjectCount: number;
mappingCount: number;
repositoryFullName: string | null;
};
export function useIntegrations() {
const { orgId } = useOrgDashboard();
return useQuery({
queryKey: integrationQueryKeys.list(),
enabled: Boolean(orgId),
queryKey: integrationQueryKeys.list(orgId),
queryFn: fetchConnections,
});
}
@@ -219,7 +400,395 @@ export function useIntegrationRepos(provider: IntegrationProvider, accountId: st
});
}
// ── Mutations ──────────────────────────────────────────────────────────────
export function useStartGithubInstall() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { returnPath: string }): Promise<GithubInstallStartResult> => {
const { response, payload } = await requestJson(
"/v1/connectors/github/install/start",
{
method: "POST",
body: JSON.stringify({ returnPath: input.returnPath }),
},
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to start GitHub install (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const redirectUrl = item ? asString(item.redirectUrl) : null;
const state = item ? asString(item.state) : null;
if (!redirectUrl || !state) {
throw new Error("GitHub install start response was incomplete.");
}
return { redirectUrl, state };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
},
});
}
export function useGithubInstallCompletion(input: { installationId: number | null; state: string | null }) {
return useQuery({
enabled: Number.isFinite(input.installationId ?? NaN) && (input.installationId ?? 0) > 0 && Boolean(input.state?.trim()),
queryKey: [...integrationQueryKeys.githubInstall(input.installationId), input.state ?? "no-state"] as const,
queryFn: async (): Promise<GithubInstallCompleteResult> => {
const { response, payload } = await requestJson(
"/v1/connectors/github/install/complete",
{
method: "POST",
body: JSON.stringify({ installationId: input.installationId, state: input.state }),
},
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to complete GitHub installation (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const connectorAccount = item && isRecord(item.connectorAccount) ? item.connectorAccount : null;
const repositories = item && Array.isArray(item.repositories)
? item.repositories.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const id = typeof entry.id === "number" ? String(entry.id) : asString(entry.id);
const fullName = asString(entry.fullName);
if (!id || !fullName) {
return [];
}
const manifestKindValue = entry.manifestKind;
const manifestKind: IntegrationRepoManifestKind = manifestKindValue === "marketplace" || manifestKindValue === "plugin"
? manifestKindValue
: null;
return [{
defaultBranch: asNullableString(entry.defaultBranch),
description: manifestKind === "marketplace"
? "Claude marketplace manifest detected."
: manifestKind === "plugin"
? "Claude plugin manifest detected."
: "Repository available to connect.",
fullName,
hasPluginManifest: Boolean(entry.hasPluginManifest),
hasPlugins: Boolean(entry.hasPluginManifest),
id,
manifestKind,
marketplacePluginCount: typeof entry.marketplacePluginCount === "number" ? entry.marketplacePluginCount : null,
name: toRepoName(fullName),
private: Boolean(entry.private),
} satisfies IntegrationRepo];
})
: [];
if (!connectorAccount || !asString(connectorAccount.id) || !asString(connectorAccount.displayName)) {
throw new Error("GitHub install completion response was incomplete.");
}
return {
connectorAccount: {
displayName: asString(connectorAccount.displayName) ?? "GitHub",
id: asString(connectorAccount.id) ?? "",
metadata: isRecord(connectorAccount.metadata) ? connectorAccount.metadata : undefined,
},
repositories,
};
},
});
}
export function useGithubAccountRepositories(connectorAccountId: string | null) {
return useQuery({
enabled: Boolean(connectorAccountId),
queryKey: [...integrationQueryKeys.repos("github", connectorAccountId), "connected-account"] as const,
queryFn: async (): Promise<IntegrationRepo[]> => {
const { response, payload } = await requestJson(
`/v1/connectors/github/accounts/${encodeURIComponent(connectorAccountId ?? "")}/repositories?limit=100`,
{ method: "GET" },
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load GitHub repositories (${response.status}).`));
}
return isRecord(payload) && Array.isArray(payload.items)
? payload.items.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const id = typeof entry.id === "number" ? String(entry.id) : asString(entry.id);
const fullName = asString(entry.fullName);
if (!id || !fullName) {
return [];
}
const manifestKindValue = entry.manifestKind;
const manifestKind: IntegrationRepoManifestKind = manifestKindValue === "marketplace" || manifestKindValue === "plugin"
? manifestKindValue
: null;
return [{
defaultBranch: asNullableString(entry.defaultBranch),
description: manifestKind === "marketplace"
? "Claude marketplace manifest detected."
: manifestKind === "plugin"
? "Claude plugin manifest detected."
: "Repository available to connect.",
fullName,
hasPluginManifest: Boolean(entry.hasPluginManifest),
hasPlugins: Boolean(entry.hasPluginManifest),
id,
manifestKind,
marketplacePluginCount: typeof entry.marketplacePluginCount === "number" ? entry.marketplacePluginCount : null,
name: toRepoName(fullName),
private: Boolean(entry.private),
} satisfies IntegrationRepo];
})
: [];
},
});
}
export function useCreateGithubConnectorInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: {
branch: string;
connectorAccountId: string;
connectorInstanceName: string;
installationId: number;
repositoryFullName: string;
repositoryId: number;
}): Promise<GithubConnectorCreationResult> => {
const { response, payload } = await requestJson(
"/v1/connectors/github/setup",
{
method: "POST",
body: JSON.stringify({
branch: input.branch,
connectorAccountId: input.connectorAccountId,
connectorInstanceName: input.connectorInstanceName,
installationId: input.installationId,
mappings: [],
ref: `refs/heads/${input.branch}`,
repositoryFullName: input.repositoryFullName,
repositoryId: input.repositoryId,
}),
},
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to connect GitHub repository (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const connectorInstance = item && isRecord(item.connectorInstance) ? item.connectorInstance : null;
const connectorTarget = item && isRecord(item.connectorTarget) ? item.connectorTarget : null;
const connectorInstanceId = connectorInstance ? asString(connectorInstance.id) : null;
const connectorTargetId = connectorTarget ? asString(connectorTarget.id) : null;
const repositoryFullName = connectorTarget && isRecord(connectorTarget.targetConfigJson)
? asString(connectorTarget.targetConfigJson.repositoryFullName)
: null;
if (!connectorInstanceId || !connectorTargetId || !repositoryFullName) {
throw new Error("GitHub setup response was incomplete.");
}
return {
connectorInstanceId,
connectorTargetId,
repositoryFullName,
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
export function useGithubConnectorDiscovery(connectorInstanceId: string | null) {
return useQuery({
enabled: Boolean(connectorInstanceId),
queryKey: integrationQueryKeys.githubDiscovery(connectorInstanceId),
queryFn: async (): Promise<GithubConnectorDiscoveryResult> => {
const { response, payload } = await requestJson(
`/v1/connector-instances/${encodeURIComponent(connectorInstanceId ?? "")}/discovery`,
{ method: "GET" },
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to inspect GitHub repository (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const connectorInstance = item && isRecord(item.connectorInstance) ? item.connectorInstance : null;
const connectorTarget = item && isRecord(item.connectorTarget) ? item.connectorTarget : null;
const connectorInstanceIdValue = connectorInstance ? asString(connectorInstance.id) : null;
const connectorTargetId = connectorTarget ? asString(connectorTarget.id) : null;
const repositoryFullName = item ? asString(item.repositoryFullName) : null;
const sourceRevisionRef = item ? asString(item.sourceRevisionRef) : null;
const classification = item ? asString(item.classification) : null;
const discoveredPlugins = item && Array.isArray(item.discoveredPlugins)
? item.discoveredPlugins.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const key = asString(entry.key);
const displayName = asString(entry.displayName);
if (!key || !displayName) {
return [];
}
const componentPaths = isRecord(entry.componentPaths) ? entry.componentPaths : {};
const asStringArray = (value: unknown) => Array.isArray(value)
? value.flatMap((candidate) => {
const normalized = asString(candidate);
return normalized ? [normalized] : [];
})
: [];
return [{
componentKinds: Array.isArray(entry.componentKinds)
? entry.componentKinds.flatMap((candidate) => {
const normalized = asString(candidate);
return normalized ? [normalized] : [];
})
: [],
componentPaths: {
agents: asStringArray(componentPaths.agents),
commands: asStringArray(componentPaths.commands),
hooks: asStringArray(componentPaths.hooks),
lspServers: asStringArray(componentPaths.lspServers),
mcpServers: asStringArray(componentPaths.mcpServers),
monitors: asStringArray(componentPaths.monitors),
settings: asStringArray(componentPaths.settings),
skills: asStringArray(componentPaths.skills),
},
description: asNullableString(entry.description),
displayName,
key,
manifestPath: asNullableString(entry.manifestPath),
rootPath: typeof entry.rootPath === "string" ? entry.rootPath : "",
selectedByDefault: Boolean(entry.selectedByDefault),
sourceKind: asString(entry.sourceKind) ?? "folder_inference",
supported: Boolean(entry.supported),
warnings: Array.isArray(entry.warnings)
? entry.warnings.flatMap((candidate) => {
const normalized = asString(candidate);
return normalized ? [normalized] : [];
})
: [],
} satisfies GithubDiscoveredPlugin];
})
: [];
const steps = item && Array.isArray(item.steps)
? item.steps.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const id = asString(entry.id);
const label = asString(entry.label);
const status = asString(entry.status);
if (!id || !label || (status !== "completed" && status !== "running" && status !== "warning")) {
return [];
}
return [{ id, label, status } satisfies GithubDiscoveryStep];
})
: [];
const treeSummary = item && isRecord(item.treeSummary)
? {
scannedEntryCount: typeof item.treeSummary.scannedEntryCount === "number" ? item.treeSummary.scannedEntryCount : 0,
strategy: asString(item.treeSummary.strategy) ?? "git-tree-recursive",
truncated: Boolean(item.treeSummary.truncated),
}
: { scannedEntryCount: 0, strategy: "git-tree-recursive", truncated: false };
const warnings = item && Array.isArray(item.warnings)
? item.warnings.flatMap((entry) => {
const normalized = asString(entry);
return normalized ? [normalized] : [];
})
: [];
const autoImportNewPlugins = item ? Boolean(item.autoImportNewPlugins) : false;
if (!connectorInstanceIdValue || !connectorTargetId || !repositoryFullName || !sourceRevisionRef || !classification) {
throw new Error("GitHub discovery response was incomplete.");
}
return {
autoImportNewPlugins,
classification,
connectorInstanceId: connectorInstanceIdValue,
connectorTargetId,
discoveredPlugins,
repositoryFullName,
sourceRevisionRef,
steps,
treeSummary,
warnings,
};
},
});
}
export function useApplyGithubDiscovery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { autoImportNewPlugins: boolean; connectorInstanceId: string; selectedKeys: string[] }): Promise<GithubDiscoveryApplyResult> => {
const { response, payload } = await requestJson(
`/v1/connector-instances/${encodeURIComponent(input.connectorInstanceId)}/discovery/apply`,
{
method: "POST",
body: JSON.stringify({ autoImportNewPlugins: input.autoImportNewPlugins, selectedKeys: input.selectedKeys }),
},
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to apply GitHub discovery (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const createdPlugins = item && Array.isArray(item.createdPlugins)
? item.createdPlugins.flatMap((entry) => {
if (!isRecord(entry)) return [];
const name = asString(entry.name);
return name ? [name] : [];
})
: [];
const createdMappingCount = item && Array.isArray(item.createdMappings) ? item.createdMappings.length : 0;
const materializedConfigObjectCount = item && Array.isArray(item.materializedConfigObjects) ? item.materializedConfigObjects.length : 0;
return {
autoImportNewPlugins: item ? Boolean(item.autoImportNewPlugins) : input.autoImportNewPlugins,
createdMappingCount,
materializedConfigObjectCount,
createdPluginNames: createdPlugins,
};
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.githubDiscovery(variables.connectorInstanceId) });
},
});
}
export type ConnectInput = {
provider: IntegrationProvider;
@@ -232,7 +801,6 @@ export function useConnectIntegration() {
return useMutation({
mutationFn: async (input: ConnectInput): Promise<ConnectedIntegration> => {
// Simulate the remote OAuth exchange + repo webhook install roundtrip.
await simulateLatency(900);
const connection: ConnectedIntegration = {
@@ -243,7 +811,6 @@ export function useConnectIntegration() {
connectedAt: new Date().toISOString(),
};
// Replace any prior connection on the same account (idempotent).
mockConnections = [
...mockConnections.filter(
(entry) => !(entry.provider === input.provider && entry.account.id === input.account.id),
@@ -254,7 +821,7 @@ export function useConnectIntegration() {
return connection;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.list() });
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});
@@ -265,12 +832,144 @@ export function useDisconnectIntegration() {
return useMutation({
mutationFn: async (connectionId: string) => {
const isGithubConnection = connectionId.startsWith("cac_");
if (isGithubConnection) {
const { response, payload } = await requestJson(
`/v1/connector-accounts/${encodeURIComponent(connectionId)}/disconnect`,
{
method: "POST",
body: JSON.stringify({ reason: "Disconnected from Den Web integrations." }),
},
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to disconnect integration (${response.status}).`));
}
return connectionId;
}
await simulateLatency(300);
mockConnections = mockConnections.filter((entry) => entry.id !== connectionId);
return connectionId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.list() });
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
export function useConnectorInstanceConfiguration(connectorInstanceId: string | null) {
return useQuery({
enabled: Boolean(connectorInstanceId),
queryKey: integrationQueryKeys.connectorInstanceConfiguration(connectorInstanceId),
queryFn: async (): Promise<ConnectorInstanceConfiguration> => {
const { response, payload } = await requestJson(
`/v1/connector-instances/${encodeURIComponent(connectorInstanceId ?? "")}/configuration`,
{ method: "GET" },
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load connector instance (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const connectorInstance = item && isRecord(item.connectorInstance) ? item.connectorInstance : null;
const connectorInstanceIdValue = connectorInstance ? asString(connectorInstance.id) : null;
const connectorInstanceName = connectorInstance ? asString(connectorInstance.name) : null;
const remoteId = connectorInstance ? asString(connectorInstance.remoteId) : null;
if (!item || !connectorInstanceIdValue || !connectorInstanceName) {
throw new Error("Connector instance configuration response was incomplete.");
}
const configuredPlugins = Array.isArray(item.configuredPlugins)
? item.configuredPlugins.flatMap((entry) => {
if (!isRecord(entry)) return [];
const id = asString(entry.id);
const name = asString(entry.name);
if (!id || !name) return [];
const componentCounts: Record<string, number> = {};
if (isRecord(entry.componentCounts)) {
for (const [key, value] of Object.entries(entry.componentCounts)) {
if (typeof value === "number" && value > 0) {
componentCounts[key] = value;
}
}
}
const rootPathValue = entry.rootPath;
const rootPath = typeof rootPathValue === "string" ? rootPathValue : null;
return [{
componentCounts,
description: asNullableString(entry.description),
id,
memberCount: typeof entry.memberCount === "number" ? entry.memberCount : 0,
name,
rootPath,
} satisfies ConnectorInstanceConfiguredPlugin];
})
: [];
return {
autoImportNewPlugins: Boolean(item.autoImportNewPlugins),
configuredPlugins,
connectorInstanceId: connectorInstanceIdValue,
connectorInstanceName,
importedConfigObjectCount: typeof item.importedConfigObjectCount === "number" ? item.importedConfigObjectCount : 0,
mappingCount: typeof item.mappingCount === "number" ? item.mappingCount : 0,
repositoryFullName: remoteId,
};
},
});
}
export function useSetConnectorInstanceAutoImport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { autoImportNewPlugins: boolean; connectorInstanceId: string }) => {
const { response, payload } = await requestJson(
`/v1/connector-instances/${encodeURIComponent(input.connectorInstanceId)}/auto-import`,
{
method: "POST",
body: JSON.stringify({ autoImportNewPlugins: input.autoImportNewPlugins }),
},
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to update auto-import (${response.status}).`));
}
return input.autoImportNewPlugins;
},
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({
queryKey: integrationQueryKeys.connectorInstanceConfiguration(variables.connectorInstanceId),
});
},
});
}
export function useRemoveConnectorInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (connectorInstanceId: string) => {
const { response, payload } = await requestJson(
`/v1/connector-instances/${encodeURIComponent(connectorInstanceId)}/remove`,
{ method: "POST" },
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to remove connector instance (${response.status}).`));
}
return connectorInstanceId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.all });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});

View File

@@ -1,7 +1,9 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Cable, Check, GitBranch, Unplug } from "lucide-react";
import { Cable, Check, GitBranch, Github, Loader2, Plus, Settings, Trash2 } from "lucide-react";
import { getGithubIntegrationAccountRoute, getGithubIntegrationRoute, getGithubIntegrationSetupRoute } from "../../../../_lib/den-org";
import { DenButton } from "../../../../_components/ui/button";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { IntegrationConnectDialog } from "./integration-connect-dialog";
@@ -12,13 +14,33 @@ import {
formatIntegrationTimestamp,
useDisconnectIntegration,
useIntegrations,
useStartGithubInstall,
} from "./integration-data";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
export function IntegrationsScreen() {
const { orgSlug } = useOrgDashboard();
const { data: connections = [], isLoading, error } = useIntegrations();
const disconnect = useDisconnectIntegration();
const startGithubInstall = useStartGithubInstall();
const [dialogProvider, setDialogProvider] = useState<IntegrationProvider | null>(null);
async function handleConnect(provider: IntegrationProvider) {
if (provider !== "github") {
setDialogProvider(provider);
return;
}
try {
const result = await startGithubInstall.mutateAsync({
returnPath: getGithubIntegrationRoute(orgSlug),
});
window.location.assign(result.redirectUrl);
} catch {
return;
}
}
const connectedByProvider = connections.reduce<
Partial<Record<IntegrationProvider, ConnectedIntegration[]>>
>((acc, connection) => {
@@ -38,9 +60,13 @@ export function IntegrationsScreen() {
description="Connect to GitHub or Bitbucket. Once an account is linked, plugins and skills from those repositories show up on the Plugins page."
colors={["#E0F2FE", "#0C4A6E", "#0284C7", "#7DD3FC"]}
>
{error ? (
{error || startGithubInstall.error ? (
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
{error instanceof Error ? error.message : "Failed to load integrations."}
{error instanceof Error
? error.message
: startGithubInstall.error instanceof Error
? startGithubInstall.error.message
: "Failed to load integrations."}
</div>
) : null}
@@ -60,12 +86,18 @@ export function IntegrationsScreen() {
className="overflow-hidden rounded-2xl border border-gray-100 bg-white"
>
{/* Header */}
<div className="flex items-start gap-4 border-b border-gray-100 px-6 py-5">
<div
className={`flex items-start gap-4 px-6 py-5 ${isConnected ? "border-b border-gray-100" : ""}`}
>
<ProviderLogo provider={meta.provider} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-[15px] font-semibold text-gray-900">{meta.name}</h2>
{isConnected ? (
{meta.provider === "bitbucket" ? (
<span className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
Coming soon
</span>
) : isConnected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
<Check className="h-3 w-3" />
Connected
@@ -76,44 +108,43 @@ export function IntegrationsScreen() {
</span>
)}
</div>
<p className="mt-1 text-[13px] leading-[1.55] text-gray-500">{meta.description}</p>
{!isConnected ? (
<p className="mt-1 text-[13px] leading-[1.55] text-gray-500">{meta.description}</p>
) : null}
</div>
<div className="shrink-0">
<DenButton
variant={isConnected ? "secondary" : "primary"}
size="sm"
onClick={() => setDialogProvider(meta.provider)}
loading={meta.provider === "github" && startGithubInstall.isPending}
disabled={meta.provider === "bitbucket"}
onClick={() => void handleConnect(meta.provider)}
>
{isConnected ? "Connect another" : "Connect"}
{meta.provider === "bitbucket"
? "Coming soon"
: isConnected
? "Connect another"
: "Connect"}
</DenButton>
</div>
</div>
{/* Body: connected accounts + repos */}
{isConnected ? (
<div className="grid gap-3 px-6 py-5">
<div className="divide-y divide-gray-100">
{providerConnections.map((connection) => (
<ConnectionRow
key={connection.id}
connection={connection}
orgSlug={orgSlug}
onConfigureNewRepo={meta.provider === "github" ? () => window.location.assign(getGithubIntegrationAccountRoute(orgSlug, connection.account.id)) : undefined}
onDisconnect={() => disconnect.mutate(connection.id)}
busy={disconnect.isPending && disconnect.variables === connection.id}
/>
))}
</div>
) : (
<div className="px-6 py-5 text-[13px] text-gray-400">
Requires scopes: {meta.scopes.map((scope) => (
<code
key={scope}
className="mr-1 rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-600"
>
{scope}
</code>
))}
</div>
)}
) : null}
</div>
);
})}
@@ -131,53 +162,205 @@ export function IntegrationsScreen() {
function ConnectionRow({
connection,
orgSlug,
onConfigureNewRepo,
onDisconnect,
busy,
}: {
connection: ConnectedIntegration;
orgSlug: string | null;
onConfigureNewRepo?: () => void;
onDisconnect: () => void;
busy: boolean;
}) {
const accountLogin = connection.account.ownerName ?? connection.account.name;
const connectedBy = connection.account.createdByName ?? null;
const avatarUrl = connection.provider === "github" && accountLogin
? `https://github.com/${encodeURIComponent(accountLogin)}.png?size=80`
: null;
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#0f172a] text-[11px] font-semibold text-white">
{connection.account.avatarInitial}
<div className="group relative">
<button
type="button"
onClick={() => setConfirmOpen(true)}
aria-label={`Disconnect ${accountLogin}`}
disabled={busy}
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-400 opacity-0 transition-all duration-150 hover:bg-red-50 hover:text-red-600 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-red-500/30 group-hover:opacity-100 disabled:cursor-not-allowed"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Trash2 className="h-4 w-4" aria-hidden />
)}
</button>
<div className="flex items-start gap-3 px-6 py-5 pr-14">
<Avatar url={avatarUrl} fallback={connection.account.avatarInitial} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-[14px] font-semibold text-gray-900">@{accountLogin}</p>
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
{connection.account.kind === "user" ? "Personal" : "Organization"}
</span>
</div>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{connection.account.name}</p>
<p className="truncate text-[12px] text-gray-400">
{connection.account.kind === "user" ? "Personal" : "Organization"} · Connected{" "}
{formatIntegrationTimestamp(connection.connectedAt)}
<p className="mt-0.5 truncate text-[12px] text-gray-500">
{connectedBy ? `Added by ${connectedBy}` : "Added recently"}
<span className="text-gray-400"> · {formatIntegrationTimestamp(connection.connectedAt)}</span>
</p>
</div>
</div>
<div className="px-6 pb-5">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">
Configured repositories
</p>
{connection.repos.length > 0 ? (
<ul className="space-y-1.5">
{connection.repos.map((repo) => (
<li
key={repo.id}
className="flex items-center justify-between gap-3 rounded-lg px-2 py-1.5 transition hover:bg-gray-50"
>
<span className="inline-flex min-w-0 items-center gap-2 text-[13px] text-gray-700">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span className="truncate">{repo.fullName}</span>
</span>
{connection.provider === "github" && repo.connectorInstanceId ? (
<Link
href={getGithubIntegrationSetupRoute(orgSlug, repo.connectorInstanceId)}
aria-label={`Open setup for ${repo.fullName}`}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-200 hover:text-gray-900"
>
<Settings className="h-4 w-4" aria-hidden />
</Link>
) : null}
</li>
))}
</ul>
) : (
<p className="text-[13px] text-gray-400">No repositories configured yet.</p>
)}
{onConfigureNewRepo ? (
<button
type="button"
onClick={onConfigureNewRepo}
className="mt-2 inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-200 px-3 py-2 text-[13px] font-medium text-gray-500 transition hover:border-gray-400 hover:bg-gray-50 hover:text-gray-900"
>
<Plus className="h-4 w-4" aria-hidden />
Add new repo
</button>
) : null}
</div>
<DisconnectConfirmDialog
open={confirmOpen}
accountLogin={accountLogin}
repoCount={connection.repos.length}
busy={busy}
onClose={() => {
if (!busy) setConfirmOpen(false);
}}
onConfirm={() => {
onDisconnect();
setConfirmOpen(false);
}}
/>
</div>
);
}
function Avatar({ url, fallback }: { url: string | null; fallback: string }) {
const [errored, setErrored] = useState(false);
if (!url || errored) {
return (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#0f172a] text-[14px] font-semibold text-white">
{fallback}
</div>
);
}
return (
<img
src={url}
alt=""
onError={() => setErrored(true)}
className="h-11 w-11 shrink-0 rounded-full bg-gray-100 object-cover"
/>
);
}
function DisconnectConfirmDialog({
open,
accountLogin,
repoCount,
busy,
onClose,
onConfirm,
}: {
open: boolean;
accountLogin: string;
repoCount: number;
busy: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6" onClick={onClose}>
<div
className="w-full max-w-md rounded-[28px] border border-gray-200 bg-white p-6 shadow-[0_24px_80px_-32px_rgba(15,23,42,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Trash2 className="h-5 w-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-[18px] font-semibold tracking-[-0.02em] text-gray-950">
Remove @{accountLogin}?
</h2>
<p className="mt-1 text-[13px] leading-6 text-gray-600">
This will permanently delete everything OpenWork imported from this GitHub account, including:
</p>
<ul className="mt-3 space-y-1.5 text-[13px] leading-6 text-gray-600">
<li className="flex gap-2">
<span className="text-gray-400"></span>
<span>
<strong>{repoCount}</strong> connected {repoCount === 1 ? "repository" : "repositories"} and their connector setup
</span>
</li>
<li className="flex gap-2">
<span className="text-gray-400"></span>
<span>All plugins and marketplaces created from those repos</span>
</li>
<li className="flex gap-2">
<span className="text-gray-400"></span>
<span>All imported config objects, versions and source bindings</span>
</li>
</ul>
<p className="mt-3 text-[12px] leading-5 text-gray-500">
The GitHub App installation itself stays on GitHub. You can remove it from your GitHub account settings if you also want to revoke access.
</p>
</div>
</div>
<DenButton
variant="destructive"
size="sm"
icon={Unplug}
loading={busy}
onClick={onDisconnect}
>
Disconnect
</DenButton>
</div>
{connection.repos.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{connection.repos.map((repo) => (
<span
key={repo.id}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-medium text-gray-600"
>
<GitBranch className="h-3 w-3 text-gray-400" />
{repo.fullName}
</span>
))}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DenButton variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</DenButton>
<DenButton variant="destructive" icon={Trash2} loading={busy} onClick={onConfirm}>
Remove integration
</DenButton>
</div>
) : null}
</div>
</div>
);
}
@@ -185,8 +368,8 @@ function ConnectionRow({
function ProviderLogo({ provider }: { provider: IntegrationProvider }) {
if (provider === "github") {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-[#0f172a] text-[13px] font-semibold text-white">
GH
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-[#0f172a] text-white">
<Github className="h-5 w-5" aria-hidden />
</div>
);
}

View File

@@ -0,0 +1,265 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
export type DenMarketplace = {
id: string;
name: string;
description: string | null;
pluginCount: number;
createdAt: string;
updatedAt: string;
};
export const marketplaceQueryKeys = {
all: ["marketplaces"] as const,
list: () => [...marketplaceQueryKeys.all, "list"] as const,
detail: (id: string) => [...marketplaceQueryKeys.all, "detail", id] as const,
resolved: (id: string) => [...marketplaceQueryKeys.all, "resolved", id] as const,
access: (id: string) => [...marketplaceQueryKeys.all, "access", id] as const,
};
export type MarketplaceAccessRole = "viewer" | "editor" | "manager";
export type MarketplaceAccessGrant = {
id: string;
orgMembershipId: string | null;
teamId: string | null;
orgWide: boolean;
role: MarketplaceAccessRole;
createdAt: string;
removedAt: string | null;
};
export type MarketplaceResolvedSource = {
connectorAccountId: string;
connectorInstanceId: string;
accountLogin: string | null;
repositoryFullName: string;
branch: string | null;
};
export type MarketplacePluginSummary = {
id: string;
name: string;
description: string | null;
memberCount: number;
componentCounts: Record<string, number>;
};
export type MarketplaceResolved = {
marketplace: DenMarketplace;
plugins: MarketplacePluginSummary[];
source: MarketplaceResolvedSource | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseMarketplace(entry: unknown): DenMarketplace | null {
if (!isRecord(entry)) return null;
const id = asString(entry.id);
const name = asString(entry.name);
const createdAt = asString(entry.createdAt);
const updatedAt = asString(entry.updatedAt);
if (!id || !name || !createdAt || !updatedAt) return null;
return {
id,
name,
description: asString(entry.description),
pluginCount: typeof entry.pluginCount === "number" ? entry.pluginCount : 0,
createdAt,
updatedAt,
};
}
export function useMarketplace(marketplaceId: string | null) {
return useQuery({
enabled: Boolean(marketplaceId),
queryKey: marketplaceQueryKeys.resolved(marketplaceId ?? "none"),
queryFn: async (): Promise<MarketplaceResolved> => {
const { response, payload } = await requestJson(
`/v1/marketplaces/${encodeURIComponent(marketplaceId ?? "")}/resolved`,
{ method: "GET" },
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load marketplace (${response.status}).`));
}
const item = isRecord(payload) && isRecord(payload.item) ? payload.item : null;
const marketplace = item && isRecord(item.marketplace) ? parseMarketplace(item.marketplace) : null;
if (!item || !marketplace) {
throw new Error("Marketplace resolved response was incomplete.");
}
const plugins = Array.isArray(item.plugins)
? item.plugins.flatMap((entry) => {
if (!isRecord(entry)) return [];
const id = asString(entry.id);
const name = asString(entry.name);
if (!id || !name) return [];
const componentCounts: Record<string, number> = {};
if (isRecord(entry.componentCounts)) {
for (const [key, value] of Object.entries(entry.componentCounts)) {
if (typeof value === "number" && value > 0) {
componentCounts[key] = value;
}
}
}
return [{
id,
name,
description: asString(entry.description),
memberCount: typeof entry.memberCount === "number" ? entry.memberCount : 0,
componentCounts,
} satisfies MarketplacePluginSummary];
})
: [];
const sourceRecord = isRecord(item.source) ? item.source : null;
const source: MarketplaceResolvedSource | null = sourceRecord
? {
connectorAccountId: asString(sourceRecord.connectorAccountId) ?? "",
connectorInstanceId: asString(sourceRecord.connectorInstanceId) ?? "",
accountLogin: asString(sourceRecord.accountLogin),
repositoryFullName: asString(sourceRecord.repositoryFullName) ?? "",
branch: asString(sourceRecord.branch),
}
: null;
return { marketplace, plugins, source };
},
});
}
function parseAccessGrant(entry: unknown): MarketplaceAccessGrant | null {
if (!isRecord(entry)) return null;
const id = asString(entry.id);
const role = asString(entry.role);
if (!id || !role) return null;
if (role !== "viewer" && role !== "editor" && role !== "manager") return null;
return {
id,
orgMembershipId: asString(entry.orgMembershipId),
teamId: asString(entry.teamId),
orgWide: Boolean(entry.orgWide),
role,
createdAt: asString(entry.createdAt) ?? new Date().toISOString(),
removedAt: asString(entry.removedAt),
};
}
export function useMarketplaceAccess(marketplaceId: string | null) {
return useQuery({
enabled: Boolean(marketplaceId),
queryKey: marketplaceQueryKeys.access(marketplaceId ?? "none"),
queryFn: async (): Promise<MarketplaceAccessGrant[]> => {
const { response, payload } = await requestJson(
`/v1/marketplaces/${encodeURIComponent(marketplaceId ?? "")}/access`,
{ method: "GET" },
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load marketplace access (${response.status}).`));
}
const items = isRecord(payload) && Array.isArray(payload.items) ? payload.items : [];
return items
.map(parseAccessGrant)
.filter((value): value is MarketplaceAccessGrant => Boolean(value) && value?.removedAt === null);
},
});
}
export function useGrantMarketplaceAccess() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: {
marketplaceId: string;
body:
| { orgWide: true; role?: MarketplaceAccessRole }
| { teamId: string; role?: MarketplaceAccessRole }
| { orgMembershipId: string; role?: MarketplaceAccessRole };
}) => {
const body = {
role: input.body.role ?? "viewer",
...("orgWide" in input.body ? { orgWide: true } : {}),
...("teamId" in input.body ? { teamId: input.body.teamId } : {}),
...("orgMembershipId" in input.body ? { orgMembershipId: input.body.orgMembershipId } : {}),
};
const { response, payload } = await requestJson(
`/v1/marketplaces/${encodeURIComponent(input.marketplaceId)}/access`,
{ method: "POST", body: JSON.stringify(body) },
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to grant access (${response.status}).`));
}
return input.marketplaceId;
},
onSuccess: (marketplaceId) => {
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.access(marketplaceId) });
},
});
}
export function useRevokeMarketplaceAccess() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { marketplaceId: string; grantId: string }) => {
const { response, payload } = await requestJson(
`/v1/marketplaces/${encodeURIComponent(input.marketplaceId)}/access/${encodeURIComponent(input.grantId)}`,
{ method: "DELETE" },
15000,
);
if (response.status !== 204 && !response.ok) {
throw new Error(getErrorMessage(payload, `Failed to revoke access (${response.status}).`));
}
return input.marketplaceId;
},
onSuccess: (marketplaceId) => {
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.access(marketplaceId) });
},
});
}
export function useMarketplaces() {
return useQuery({
queryKey: marketplaceQueryKeys.list(),
queryFn: async () => {
const { response, payload } = await requestJson(
"/v1/marketplaces?status=active&limit=100",
{ method: "GET" },
15000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load marketplaces (${response.status}).`));
}
const items = isRecord(payload) && Array.isArray(payload.items) ? payload.items : [];
return items
.map(parseMarketplace)
.filter((value): value is DenMarketplace => Boolean(value));
},
});
}
export function formatMarketplaceTimestamp(value: string | null): string {
if (!value) return "Recently added";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Recently added";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}

View File

@@ -0,0 +1,568 @@
"use client";
import Link from "next/link";
import { useMemo, useRef, useState, useEffect } from "react";
import { ArrowLeft, Check, GitBranch, Github, Globe, Loader2, Plus, Puzzle, Store, Users, X } from "lucide-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import {
getGithubIntegrationSetupRoute,
getMarketplacesRoute,
getPluginRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
formatMarketplaceTimestamp,
type MarketplacePluginSummary,
useGrantMarketplaceAccess,
useMarketplace,
useMarketplaceAccess,
useRevokeMarketplaceAccess,
} from "./marketplace-data";
const COMPONENT_TYPE_LABELS: Record<string, { singular: string; plural: string }> = {
skill: { singular: "skill", plural: "skills" },
agent: { singular: "agent", plural: "agents" },
command: { singular: "command", plural: "commands" },
hook: { singular: "hook", plural: "hooks" },
mcp: { singular: "MCP server", plural: "MCP servers" },
mcp_server: { singular: "MCP server", plural: "MCP servers" },
lsp_server: { singular: "LSP server", plural: "LSP servers" },
monitor: { singular: "monitor", plural: "monitors" },
settings: { singular: "setting", plural: "settings" },
};
function componentTypeLabel(type: string, count: number) {
const label = COMPONENT_TYPE_LABELS[type] ?? {
singular: type.replace(/_/g, " "),
plural: `${type.replace(/_/g, " ")}s`,
};
return count === 1 ? label.singular : label.plural;
}
export function MarketplaceDetailScreen({ marketplaceId }: { marketplaceId: string }) {
const { orgSlug } = useOrgDashboard();
const { data, isLoading, error } = useMarketplace(marketplaceId);
if (isLoading && !data) {
return (
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="rounded-2xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
Loading marketplace
</div>
</div>
);
}
if (!data) {
return (
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="rounded-2xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
{error instanceof Error ? error.message : "That marketplace could not be found."}
</div>
</div>
);
}
const { marketplace, plugins, source } = data;
return (
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="mb-6 flex items-center justify-between gap-4">
<Link
href={getMarketplacesRoute(orgSlug)}
className="inline-flex items-center gap-1.5 text-[13px] text-gray-400 transition hover:text-gray-700"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
</div>
<article className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
<div className="flex items-stretch">
<div className="relative w-[96px] shrink-0 overflow-hidden">
<div className="absolute inset-0">
<PaperMeshGradient seed={marketplace.id} speed={0} />
</div>
<div className="relative flex h-full items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-[16px] border border-white/60 bg-white shadow-[0_10px_24px_-10px_rgba(15,23,42,0.3)]">
<Store className="h-6 w-6 text-gray-700" aria-hidden />
</div>
</div>
</div>
<div className="min-w-0 flex-1 px-6 py-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-[18px] font-semibold tracking-[-0.02em] text-gray-950">
{marketplace.name}
</h1>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
{plugins.length} plugin{plugins.length === 1 ? "" : "s"}
</span>
</div>
{marketplace.description ? (
<p className="mt-1 text-[13px] leading-[1.55] text-gray-500">{marketplace.description}</p>
) : null}
<p className="mt-3 text-[11.5px] text-gray-400">
Added {formatMarketplaceTimestamp(marketplace.createdAt)}
</p>
</div>
</div>
</article>
<div className="mt-6 space-y-6">
{source ? (
<section>
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Source
</h2>
<Link
href={getGithubIntegrationSetupRoute(orgSlug, source.connectorInstanceId)}
className="group flex items-center gap-4 rounded-2xl border border-gray-100 bg-white px-4 py-3 transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-12px_rgba(15,23,42,0.08)]"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-gray-50 text-gray-600 group-hover:bg-gray-100 group-hover:text-gray-800">
<Github className="h-4 w-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">
{source.repositoryFullName}
</p>
<p className="mt-0.5 truncate text-[12.5px] text-gray-500">
{source.accountLogin ? `@${source.accountLogin}` : "GitHub connector"}
{source.branch ? (
<>
<span className="mx-1.5 text-gray-300">·</span>
<GitBranch className="mr-1 inline h-3 w-3 text-gray-400" aria-hidden />
{source.branch}
</>
) : null}
</p>
</div>
</Link>
</section>
) : null}
<MarketplaceAccessSection marketplaceId={marketplace.id} />
<section>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Plugins
</h2>
<p className="text-[11px] text-gray-400">
{plugins.length} plugin{plugins.length === 1 ? "" : "s"}
</p>
</div>
{plugins.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center">
<p className="text-[14px] font-medium tracking-[-0.02em] text-gray-800">
No plugins in this marketplace yet
</p>
<p className="mx-auto mt-2 max-w-[420px] text-[13px] leading-6 text-gray-500">
Plugins appear here as they're imported from the source repository.
</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{plugins.map((plugin) => (
<MarketplacePluginCard key={plugin.id} orgSlug={orgSlug} plugin={plugin} />
))}
</div>
)}
</section>
</div>
</div>
);
}
function MarketplaceAccessSection({ marketplaceId }: { marketplaceId: string }) {
const { orgContext } = useOrgDashboard();
const accessQuery = useMarketplaceAccess(marketplaceId);
const grantMutation = useGrantMarketplaceAccess();
const revokeMutation = useRevokeMarketplaceAccess();
const grants = accessQuery.data ?? [];
const orgWideGrant = grants.find((grant) => grant.orgWide) ?? null;
const teamGrants = grants.filter((grant) => Boolean(grant.teamId));
const memberGrants = grants.filter((grant) => Boolean(grant.orgMembershipId));
const teamsById = useMemo(
() => new Map((orgContext?.teams ?? []).map((team) => [team.id, team])),
[orgContext?.teams],
);
const membersById = useMemo(
() => new Map((orgContext?.members ?? []).map((member) => [member.id, member])),
[orgContext?.members],
);
const teamsAvailable = (orgContext?.teams ?? []).filter(
(team) => !teamGrants.some((grant) => grant.teamId === team.id),
);
const membersAvailable = (orgContext?.members ?? []).filter(
(member) => !memberGrants.some((grant) => grant.orgMembershipId === member.id),
);
async function handleToggleOrgWide() {
if (orgWideGrant) {
await revokeMutation.mutateAsync({ marketplaceId, grantId: orgWideGrant.id });
} else {
await grantMutation.mutateAsync({
marketplaceId,
body: { orgWide: true, role: "viewer" },
});
}
}
async function handleAddTeam(teamId: string) {
await grantMutation.mutateAsync({
marketplaceId,
body: { teamId, role: "viewer" },
});
}
async function handleAddMember(memberId: string) {
await grantMutation.mutateAsync({
marketplaceId,
body: { orgMembershipId: memberId, role: "viewer" },
});
}
async function handleRevoke(grantId: string) {
await revokeMutation.mutateAsync({ marketplaceId, grantId });
}
const busy = accessQuery.isLoading || grantMutation.isPending || revokeMutation.isPending;
return (
<section>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Who can access this
</h2>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" aria-hidden /> : null}
</div>
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
<button
type="button"
onClick={() => void handleToggleOrgWide()}
disabled={grantMutation.isPending || revokeMutation.isPending}
className="flex w-full items-center gap-4 px-5 py-4 text-left transition hover:bg-gray-50/60 disabled:opacity-60"
>
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] ${orgWideGrant ? "bg-emerald-50 text-emerald-600" : "bg-gray-50 text-gray-500"}`}>
<Globe className="h-4 w-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-[14px] font-semibold tracking-[-0.01em] text-gray-900">
Everyone in {orgContext?.organization.name ?? "this organization"}
</p>
<p className="mt-0.5 text-[12.5px] leading-[1.55] text-gray-500">
{orgWideGrant
? "All org members can see this marketplace."
: "Only admins and people you add below can see this marketplace."}
</p>
</div>
<div
role="switch"
aria-checked={Boolean(orgWideGrant)}
className={`relative inline-flex h-6 w-[42px] shrink-0 items-center rounded-full transition-colors ${
orgWideGrant ? "bg-[#0f172a]" : "bg-gray-200"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-[0_2px_6px_-1px_rgba(15,23,42,0.3)] transition-transform ${
orgWideGrant ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
</div>
</button>
<AccessRowGroup
label="Teams"
icon={Users}
emptyLabel="No team access yet"
items={teamGrants.map((grant) => {
const team = grant.teamId ? teamsById.get(grant.teamId) : null;
return {
grantId: grant.id,
title: team?.name ?? "Removed team",
subtitle: team ? `${team.memberIds.length} member${team.memberIds.length === 1 ? "" : "s"}` : null,
};
})}
availableOptions={teamsAvailable.map((team) => ({
id: team.id,
label: team.name,
subtitle: `${team.memberIds.length} member${team.memberIds.length === 1 ? "" : "s"}`,
}))}
availableEmptyLabel="All teams already have access"
onAdd={(id) => void handleAddTeam(id)}
onRemove={(id) => void handleRevoke(id)}
disabled={grantMutation.isPending || revokeMutation.isPending}
/>
<AccessRowGroup
label="People"
icon={Users}
emptyLabel="No individual access yet"
items={memberGrants.map((grant) => {
const member = grant.orgMembershipId ? membersById.get(grant.orgMembershipId) : null;
return {
grantId: grant.id,
title: member?.user.name ?? "Removed member",
subtitle: member?.user.email ?? null,
};
})}
availableOptions={membersAvailable.map((member) => ({
id: member.id,
label: member.user.name,
subtitle: member.user.email,
}))}
availableEmptyLabel="Everyone already has access"
onAdd={(id) => void handleAddMember(id)}
onRemove={(id) => void handleRevoke(id)}
disabled={grantMutation.isPending || revokeMutation.isPending}
/>
</div>
{accessQuery.error ? (
<p className="mt-2 text-[12px] text-red-600">
{accessQuery.error instanceof Error ? accessQuery.error.message : "Failed to load access."}
</p>
) : null}
{grantMutation.error ? (
<p className="mt-2 text-[12px] text-red-600">
{grantMutation.error instanceof Error ? grantMutation.error.message : "Failed to grant access."}
</p>
) : null}
{revokeMutation.error ? (
<p className="mt-2 text-[12px] text-red-600">
{revokeMutation.error instanceof Error ? revokeMutation.error.message : "Failed to revoke access."}
</p>
) : null}
</section>
);
}
function AccessRowGroup({
label,
icon: Icon,
emptyLabel,
items,
availableOptions,
availableEmptyLabel,
onAdd,
onRemove,
disabled,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
emptyLabel: string;
items: Array<{ grantId: string; title: string; subtitle: string | null }>;
availableOptions: Array<{ id: string; label: string; subtitle: string }>;
availableEmptyLabel: string;
onAdd: (id: string) => void;
onRemove: (grantId: string) => void;
disabled: boolean;
}) {
return (
<div className="border-t border-gray-100 px-5 py-4">
<div className="mb-2 flex items-center gap-2">
<Icon className="h-3.5 w-3.5 text-gray-400" aria-hidden />
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">{label}</p>
</div>
{items.length === 0 ? (
<p className="text-[12.5px] text-gray-400">{emptyLabel}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((entry) => (
<span
key={entry.grantId}
className="group inline-flex items-center gap-1.5 rounded-full bg-gray-50 py-1 pl-3 pr-1 text-[12px] text-gray-700"
>
<span className="truncate max-w-[180px]">{entry.title}</span>
{entry.subtitle ? (
<span className="truncate max-w-[140px] text-gray-400">· {entry.subtitle}</span>
) : null}
<button
type="button"
aria-label={`Remove ${entry.title}`}
disabled={disabled}
onClick={() => onRemove(entry.grantId)}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
))}
</div>
)}
<AccessAddPicker
label={label}
options={availableOptions}
emptyLabel={availableEmptyLabel}
disabled={disabled}
onAdd={onAdd}
/>
</div>
);
}
function AccessAddPicker({
label,
options,
emptyLabel,
disabled,
onAdd,
}: {
label: string;
options: Array<{ id: string; label: string; subtitle: string }>;
emptyLabel: string;
disabled: boolean;
onAdd: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function handle(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return options;
return options.filter(
(option) =>
option.label.toLowerCase().includes(normalized) ||
option.subtitle.toLowerCase().includes(normalized),
);
}, [options, query]);
if (options.length === 0) {
return (
<p className="mt-2 text-[11.5px] text-gray-400">{emptyLabel}</p>
);
}
return (
<div ref={ref} className="relative mt-2 inline-block">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((value) => !value)}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-gray-200 px-2.5 py-1 text-[11.5px] text-gray-500 transition hover:border-gray-400 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="h-3 w-3" aria-hidden />
Add {label.toLowerCase().replace(/s$/, "")}
</button>
{open ? (
<div className="absolute left-0 top-[calc(100%+4px)] z-10 w-[260px] overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-[0_20px_40px_-16px_rgba(15,23,42,0.18)]">
<div className="border-b border-gray-100 px-3 py-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${label.toLowerCase()}...`}
className="w-full bg-transparent text-[12.5px] text-gray-900 placeholder:text-gray-400 focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-[240px] overflow-y-auto py-1">
{filtered.length === 0 ? (
<p className="px-3 py-3 text-[12px] text-gray-400">No matches</p>
) : (
filtered.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onAdd(option.id);
setOpen(false);
setQuery("");
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-gray-50"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[12.5px] font-medium text-gray-900">{option.label}</p>
<p className="truncate text-[11px] text-gray-500">{option.subtitle}</p>
</div>
<Check className="h-3.5 w-3.5 shrink-0 text-transparent" aria-hidden />
</button>
))
)}
</div>
</div>
) : null}
</div>
);
}
function MarketplacePluginCard({
orgSlug,
plugin,
}: {
orgSlug: string | null;
plugin: MarketplacePluginSummary;
}) {
const orderedCountEntries = Object.entries(plugin.componentCounts)
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
return (
<Link
href={getPluginRoute(orgSlug, plugin.id)}
className="group block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-12px_rgba(15,23,42,0.12)]"
>
<div className="flex items-stretch">
<div className="relative w-[64px] shrink-0 overflow-hidden">
<div className="absolute inset-0">
<PaperMeshGradient seed={plugin.id} speed={0} />
</div>
<div className="relative flex h-full items-center justify-center">
<div className="flex h-9 w-9 items-center justify-center rounded-[12px] border border-white/60 bg-white shadow-[0_8px_20px_-8px_rgba(15,23,42,0.3)]">
<Puzzle className="h-4 w-4 text-gray-700" aria-hidden />
</div>
</div>
</div>
<div className="min-w-0 flex-1 px-4 py-3">
<p className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">
{plugin.name}
</p>
{plugin.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">
{plugin.description}
</p>
) : null}
{orderedCountEntries.length > 0 ? (
<div className="mt-2.5 flex flex-wrap gap-1.5 border-t border-gray-50 pt-2.5">
{orderedCountEntries.map(([type, count]) => (
<span
key={type}
className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 text-[11.5px] text-gray-600"
>
<span className="font-semibold text-gray-900">{count}</span>
<span className="text-gray-500">{componentTypeLabel(type, count)}</span>
</span>
))}
</div>
) : (
<p className="mt-2 text-[11.5px] text-gray-400">
{plugin.memberCount} imported object{plugin.memberCount === 1 ? "" : "s"}
</p>
)}
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Cable, Search, Store } from "lucide-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { DenInput } from "../../../../_components/ui/input";
import { buttonVariants } from "../../../../_components/ui/button";
import { getIntegrationsRoute, getMarketplaceRoute } from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import { useHasAnyIntegration } from "./integration-data";
import { formatMarketplaceTimestamp, useMarketplaces } from "./marketplace-data";
export function MarketplacesScreen() {
const { orgSlug } = useOrgDashboard();
const { data: marketplaces = [], isLoading, error } = useMarketplaces();
const { hasAny: hasAnyIntegration, isLoading: integrationsLoading } = useHasAnyIntegration();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!normalizedQuery) return marketplaces;
return marketplaces.filter((marketplace) =>
`${marketplace.name}\n${marketplace.description ?? ""}`.toLowerCase().includes(normalizedQuery),
);
}, [marketplaces, normalizedQuery]);
return (
<DashboardPageTemplate
icon={Store}
badgeLabel="Preview"
title="Marketplaces"
description="Marketplaces group plugins imported from a Claude marketplace repository."
colors={["#FEF3C7", "#92400E", "#F59E0B", "#FDE68A"]}
>
<div className="mb-6">
<DenInput
type="search"
icon={Search}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search marketplaces..."
/>
</div>
{error ? (
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
{error instanceof Error ? error.message : "Failed to load marketplaces."}
</div>
) : null}
{isLoading || integrationsLoading ? (
<div className="rounded-2xl border border-gray-100 bg-white px-6 py-10 text-[14px] text-gray-500">
Loading marketplaces
</div>
) : !hasAnyIntegration ? (
<ConnectIntegrationEmptyState integrationsHref={getIntegrationsRoute(orgSlug)} />
) : filtered.length === 0 ? (
<EmptyState
title={marketplaces.length === 0 ? "No marketplaces yet" : "No marketplaces match that search"}
description={
marketplaces.length === 0
? "Marketplaces show up here when you import a repository that has a `.claude-plugin/marketplace.json` manifest."
: "Try a different search term or open the plugins tab."
}
action={
marketplaces.length === 0
? { href: getIntegrationsRoute(orgSlug), label: "Open Integrations", icon: Cable }
: undefined
}
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((marketplace) => (
<Link
key={marketplace.id}
href={getMarketplaceRoute(orgSlug, marketplace.id)}
className="group block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-12px_rgba(15,23,42,0.12)]"
>
<div className="flex items-stretch">
<div className="relative w-[68px] shrink-0 overflow-hidden">
<div className="absolute inset-0">
<PaperMeshGradient seed={marketplace.id} speed={0} />
</div>
<div className="relative flex h-full items-center justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] border border-white/60 bg-white shadow-[0_8px_20px_-8px_rgba(15,23,42,0.3)]">
<Store className="h-4 w-4 text-gray-700" aria-hidden />
</div>
</div>
</div>
<div className="min-w-0 flex-1 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<h2 className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">
{marketplace.name}
</h2>
<span className="shrink-0 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-500">
{marketplace.pluginCount} plugin{marketplace.pluginCount === 1 ? "" : "s"}
</span>
</div>
{marketplace.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">
{marketplace.description}
</p>
) : null}
<p className="mt-3 text-[11.5px] text-gray-400">
Added {formatMarketplaceTimestamp(marketplace.createdAt)}
</p>
</div>
</div>
</Link>
))}
</div>
)}
</DashboardPageTemplate>
);
}
function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: { href: string; label: string; icon: React.ComponentType<{ className?: string }> };
}) {
const ActionIcon = action?.icon;
return (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white px-6 py-12 text-center">
<p className="text-[15px] font-semibold tracking-[-0.02em] text-gray-900">{title}</p>
<p className="mx-auto mt-2 max-w-[520px] text-[13px] leading-6 text-gray-500">{description}</p>
{action ? (
<div className="mt-5 flex justify-center">
<Link href={action.href} className={buttonVariants({ variant: "primary", size: "sm" })}>
{ActionIcon ? <ActionIcon className="h-4 w-4" aria-hidden="true" /> : null}
{action.label}
</Link>
</div>
) : null}
</div>
);
}
function ConnectIntegrationEmptyState({ integrationsHref }: { integrationsHref: string }) {
return (
<EmptyState
title="Connect an integration to discover marketplaces"
description="Marketplaces are created when OpenWork finds a `.claude-plugin/marketplace.json` manifest in a connected repository."
action={{ href: integrationsHref, label: "Open Integrations", icon: Cable }}
/>
);
}

View File

@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import {
BookOpen,
Bot,
Cable,
CreditCard,
Cpu,
FileText,
@@ -13,8 +14,10 @@ import {
KeyRound,
LogOut,
MessageSquare,
Puzzle,
Share2,
SlidersHorizontal,
Store,
Users,
} from "lucide-react";
import { useDenFlow } from "../../../../_providers/den-flow-provider";
@@ -29,6 +32,7 @@ import {
getMembersRoute,
getOrgDashboardRoute,
getOrgSettingsRoute,
getMarketplacesRoute,
getPluginsRoute,
getSharedSetupsRoute,
getSkillHubsRoute,
@@ -117,6 +121,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
if (pathname.startsWith(getPluginsRoute(orgSlug))) {
return "Plugins";
}
if (pathname.startsWith(getMarketplacesRoute(orgSlug))) {
return "Marketplaces";
}
if (pathname.startsWith(getIntegrationsRoute(orgSlug))) {
return "Integrations";
}
@@ -171,16 +178,32 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
icon: Bot,
badge: "Alpha",
},
{
href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#",
label: "LLM Providers",
icon: Cpu,
badge: "New",
},
{
href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#",
label: "LLM Providers",
icon: Cpu,
},
{
href: activeOrg ? getSkillHubsRoute(activeOrg.slug) : "#",
label: "Skill Hubs",
icon: BookOpen,
},
{
href: activeOrg ? getIntegrationsRoute(activeOrg.slug) : "#",
label: "Integrations",
icon: Cable,
badge: "New",
},
{
href: activeOrg ? getMarketplacesRoute(activeOrg.slug) : "#",
label: "Marketplaces",
icon: Store,
badge: "New",
},
{
href: activeOrg ? getPluginsRoute(activeOrg.slug) : "#",
label: "Plugins",
icon: Puzzle,
badge: "New",
},
{

View File

@@ -1,6 +1,7 @@
"use client";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import {
type ConnectedIntegration,
integrationQueryKeys,
@@ -85,16 +86,22 @@ export type PluginSource =
| { type: "github"; repo: string }
| { type: "local"; path: string };
export type PluginMarketplaceRef = {
id: string;
name: string;
};
export type DenPlugin = {
id: string;
name: string;
slug: string;
description: string;
version: string;
version: string | null;
author: string;
category: PluginCategory;
installed: boolean;
source: PluginSource;
marketplaces?: PluginMarketplaceRef[];
skills: PluginSkill[];
hooks: PluginHook[];
mcps: PluginMcp[];
@@ -427,28 +434,190 @@ export const pluginQueryKeys = {
detail: (id: string) => [...pluginQueryKeys.all, "detail", id] as const,
};
function slugifyPluginName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "plugin";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseMembershipConfigObject(entry: unknown) {
if (!isRecord(entry) || !isRecord(entry.configObject)) {
return null;
}
const configObject = entry.configObject;
const id = asString(configObject.id);
const title = asString(configObject.title);
const description = asString(configObject.description) ?? "Imported from a connected repository.";
const objectType = asString(configObject.objectType);
const currentRelativePath = asString(configObject.currentRelativePath);
const latestVersion = isRecord(configObject.latestVersion) ? configObject.latestVersion : null;
const normalizedPayload = latestVersion && isRecord(latestVersion.normalizedPayloadJson)
? latestVersion.normalizedPayloadJson
: null;
if (!id || !title || !objectType) {
return null;
}
return {
currentRelativePath,
description,
id,
normalizedPayload,
objectType,
title,
};
}
function derivePluginCategory(input: { agents: PluginAgent[]; commands: PluginCommand[]; hooks: PluginHook[]; mcps: PluginMcp[]; skills: PluginSkill[] }): PluginCategory {
if (input.mcps.length > 0 || input.hooks.length > 0) {
return "integrations";
}
if (input.agents.length > 0 || input.commands.length > 0 || input.skills.length > 0) {
return "workflows";
}
return "output-styles";
}
function parsePluginHookEvent(value: string | null): PluginHookEvent {
switch (value) {
case "PreToolUse":
case "PostToolUse":
case "SessionStart":
case "SessionEnd":
case "UserPromptSubmit":
case "Notification":
case "Stop":
return value;
default:
return "Notification";
}
}
async function fetchResolvedPlugin(id: string): Promise<DenPlugin | null> {
const [pluginResult, membershipsResult] = await Promise.all([
requestJson(`/v1/plugins/${encodeURIComponent(id)}`, { method: "GET" }, 15000),
requestJson(`/v1/plugins/${encodeURIComponent(id)}/resolved`, { method: "GET" }, 15000),
]);
if (!pluginResult.response.ok) {
throw new Error(getErrorMessage(pluginResult.payload, `Failed to load plugin (${pluginResult.response.status}).`));
}
if (!membershipsResult.response.ok) {
throw new Error(getErrorMessage(membershipsResult.payload, `Failed to load plugin contents (${membershipsResult.response.status}).`));
}
const pluginItem = isRecord(pluginResult.payload) && isRecord(pluginResult.payload.item) ? pluginResult.payload.item : null;
if (!pluginItem) {
return null;
}
const pluginId = asString(pluginItem.id);
const name = asString(pluginItem.name);
if (!pluginId || !name) {
return null;
}
const membershipItems = isRecord(membershipsResult.payload) && Array.isArray(membershipsResult.payload.items)
? membershipsResult.payload.items.map(parseMembershipConfigObject).filter((value): value is NonNullable<typeof value> => Boolean(value))
: [];
const skills = membershipItems
.filter((item) => item.objectType === "skill")
.map((item) => ({ id: item.id, name: item.title, description: item.description } satisfies PluginSkill));
const agents = membershipItems
.filter((item) => item.objectType === "agent")
.map((item) => ({ id: item.id, name: item.title, description: item.description } satisfies PluginAgent));
const commands = membershipItems
.filter((item) => item.objectType === "command")
.map((item) => ({ id: item.id, name: item.currentRelativePath?.split("/").pop()?.replace(/\.md$/i, "") ?? item.title, description: item.description } satisfies PluginCommand));
const hooks = membershipItems
.filter((item) => item.objectType === "hook")
.map((item) => ({
description: item.description,
event: parsePluginHookEvent(asString(item.normalizedPayload?.event) ?? item.title),
id: item.id,
matcher: asString(item.normalizedPayload?.matcher),
} satisfies PluginHook));
const mcps = membershipItems
.filter((item) => item.objectType === "mcp")
.map((item) => ({
description: item.description,
id: item.id,
name: item.title,
toolCount: typeof item.normalizedPayload?.toolCount === "number" ? item.normalizedPayload.toolCount : 0,
transport: (asString(item.normalizedPayload?.transport) as PluginMcpTransport | null) ?? "stdio",
} satisfies PluginMcp));
const marketplaces = Array.isArray(pluginItem.marketplaces)
? pluginItem.marketplaces.flatMap((entry) => {
if (!isRecord(entry)) return [];
const id = asString(entry.id);
const marketplaceName = asString(entry.name);
if (!id || !marketplaceName) return [];
return [{ id, name: marketplaceName } satisfies PluginMarketplaceRef];
})
: [];
return {
agents,
author: "Connected repository",
category: derivePluginCategory({ agents, commands, hooks, mcps, skills }),
commands,
description: asString(pluginItem.description) ?? "Imported from a connected repository.",
hooks,
id: pluginId,
installed: true,
marketplaces,
mcps,
name,
requiresProvider: "github",
skills,
slug: slugifyPluginName(name),
source: marketplaces[0]
? { type: "marketplace", marketplace: marketplaces[0].name }
: { type: "github", repo: "Connected repository" },
updatedAt: asString(pluginItem.updatedAt) ?? new Date().toISOString(),
version: null,
} satisfies DenPlugin;
}
export function usePlugins() {
const queryClient = useQueryClient();
return useQuery({
queryKey: pluginQueryKeys.list(),
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 180));
const connectedProviders = readConnectedProviders(queryClient);
return filterByConnectedProviders(MOCK_PLUGINS, connectedProviders);
const { response, payload } = await requestJson("/v1/plugins?status=active&limit=100", { method: "GET" }, 20000);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load plugins (${response.status}).`));
}
const items = isRecord(payload) && Array.isArray(payload.items) ? payload.items : [];
const pluginIds = items.flatMap((entry) => {
const id = isRecord(entry) ? asString(entry.id) : null;
return id ? [id] : [];
});
const plugins = await Promise.all(pluginIds.map((id) => fetchResolvedPlugin(id)));
return plugins.filter((plugin): plugin is DenPlugin => Boolean(plugin));
},
});
}
export function usePlugin(id: string) {
const queryClient = useQueryClient();
return useQuery({
queryKey: pluginQueryKeys.detail(id),
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
const connectedProviders = readConnectedProviders(queryClient);
const visible = filterByConnectedProviders(MOCK_PLUGINS, connectedProviders);
return visible.find((plugin) => plugin.id === id) ?? null;
},
queryFn: async () => fetchResolvedPlugin(id),
enabled: Boolean(id),
});
}

View File

@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
import { ArrowLeft, FileText, Puzzle, Server, Terminal, Users, Webhook } from "lucide-react";
import { ArrowLeft, FileText, Puzzle, Server, Store, Terminal, Users, Webhook } from "lucide-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import { buttonVariants } from "../../../../_components/ui/button";
import { getPluginsRoute } from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
@@ -14,8 +14,6 @@ import {
type PluginAgent,
type PluginCommand,
formatPluginTimestamp,
getPluginCategoryLabel,
getPluginPartsSummary,
usePlugin,
} from "./plugin-data";
@@ -25,9 +23,9 @@ export function PluginDetailScreen({ pluginId }: { pluginId: string }) {
if (isLoading && !plugin) {
return (
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
<div className="rounded-xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
Loading plugin details...
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="rounded-2xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
Loading plugin details
</div>
</div>
);
@@ -35,17 +33,24 @@ export function PluginDetailScreen({ pluginId }: { pluginId: string }) {
if (!plugin) {
return (
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
<div className="rounded-xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="rounded-2xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
{error instanceof Error ? error.message : "That plugin could not be found."}
</div>
</div>
);
}
const marketplaces = plugin.marketplaces ?? [];
const missingLabels: string[] = [];
if (plugin.skills.length === 0) missingLabels.push("skills");
if (plugin.agents.length === 0) missingLabels.push("agents");
if (plugin.commands.length === 0) missingLabels.push("commands");
if (plugin.hooks.length === 0) missingLabels.push("hooks");
if (plugin.mcps.length === 0) missingLabels.push("MCP servers");
return (
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
{/* Nav */}
<div className="mx-auto max-w-[860px] px-6 py-8 md:px-8">
<div className="mb-6 flex items-center justify-between gap-4">
<Link
href={getPluginsRoute(orgSlug)}
@@ -54,167 +59,110 @@ export function PluginDetailScreen({ pluginId }: { pluginId: string }) {
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<button
type="button"
className={buttonVariants({ variant: plugin.installed ? "secondary" : "primary", size: "sm" })}
disabled
aria-disabled="true"
title="Install/uninstall is not wired up yet in this preview."
>
{plugin.installed ? "Installed" : "Install"}
</button>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_240px]">
{/* ── Main card ── */}
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
{/* Gradient header — seeded by plugin id to match the list card */}
<div className="relative h-40 overflow-hidden border-b border-gray-100">
<article className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
<div className="flex items-stretch">
<div className="relative w-[96px] shrink-0 overflow-hidden">
<div className="absolute inset-0">
<PaperMeshGradient seed={plugin.id} speed={0} />
</div>
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
<Puzzle className="h-6 w-6 text-gray-700" />
<div className="relative flex h-full items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-[16px] border border-white/60 bg-white shadow-[0_10px_24px_-10px_rgba(15,23,42,0.3)]">
<Puzzle className="h-6 w-6 text-gray-700" aria-hidden />
</div>
</div>
</div>
<div className="px-6 pb-6 pt-10">
{/* Title + description + meta */}
<div className="min-w-0 flex-1 px-6 py-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-[18px] font-semibold text-gray-900">{plugin.name}</h1>
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] font-medium text-gray-500">
v{plugin.version}
</span>
<span className="text-[12px] text-gray-400">by {plugin.author}</span>
<h1 className="truncate text-[18px] font-semibold tracking-[-0.02em] text-gray-950">
{plugin.name}
</h1>
{plugin.version ? (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
v{plugin.version}
</span>
) : null}
</div>
<p className="mt-1.5 text-[13px] leading-relaxed text-gray-500">{plugin.description}</p>
<p className="mt-2 text-[12px] text-gray-300">
{getPluginPartsSummary(plugin)} · Updated {formatPluginTimestamp(plugin.updatedAt)}
</p>
{plugin.description ? (
<p className="mt-1 text-[13px] leading-[1.55] text-gray-500">{plugin.description}</p>
) : null}
{/* Skills */}
<PrimitiveSection
icon={FileText}
label="Skills"
emptyLabel="This plugin does not ship any skills."
items={plugin.skills}
render={(skill) => renderSkillRow(skill)}
/>
{marketplaces.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{marketplaces.map((marketplace) => (
<span
key={marketplace.id}
className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-600"
>
<Store className="h-3 w-3 text-gray-400" aria-hidden />
<span className="truncate">{marketplace.name}</span>
</span>
))}
</div>
) : null}
{/* Hooks */}
<PrimitiveSection
icon={Webhook}
label="Hooks"
emptyLabel="This plugin does not register any hooks."
items={plugin.hooks}
render={(hook) => renderHookRow(hook)}
/>
{/* MCP Servers */}
<PrimitiveSection
icon={Server}
label="MCP Servers"
emptyLabel="This plugin does not bundle any MCP servers."
items={plugin.mcps}
render={(mcp) => renderMcpRow(mcp)}
/>
{/* Agents */}
<PrimitiveSection
icon={Users}
label="Agents"
emptyLabel="This plugin does not define any sub-agents."
items={plugin.agents}
render={(agent) => renderAgentRow(agent)}
/>
{/* Commands */}
<PrimitiveSection
icon={Terminal}
label="Commands"
emptyLabel="This plugin does not add any slash-commands."
items={plugin.commands}
render={(command) => renderCommandRow(command)}
/>
</div>
</section>
{/* ── Sidebar ── */}
<aside className="grid gap-3 self-start">
{/* Category */}
<div className="rounded-xl border border-gray-100 bg-white p-4">
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">Category</p>
<span className="rounded-full bg-gray-100 px-3 py-1 text-[12px] text-gray-500">
{getPluginCategoryLabel(plugin.category)}
</span>
</div>
{/* Source */}
<div className="rounded-xl border border-gray-100 bg-white p-4">
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">Source</p>
<p className="text-[13px] font-medium text-gray-900">
{plugin.source.type === "marketplace"
? "Marketplace"
: plugin.source.type === "github"
? "GitHub"
: "Local"}
</p>
<p className="mt-0.5 break-words text-[12px] text-gray-400">
{plugin.source.type === "marketplace"
? plugin.source.marketplace
: plugin.source.type === "github"
? plugin.source.repo
: plugin.source.path}
<p className="mt-3 text-[11.5px] text-gray-400">
Updated {formatPluginTimestamp(plugin.updatedAt)}
</p>
</div>
</div>
</article>
{/* Status */}
<div className="rounded-xl border border-gray-100 bg-white p-4">
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">Status</p>
<p className="text-[13px] font-medium text-gray-900">
{plugin.installed ? "Installed" : "Not installed"}
</p>
<p className="mt-0.5 text-[12px] text-gray-400">
Install and enable management will land in a follow-up.
</p>
</div>
</aside>
<div className="mt-6 space-y-6">
<PrimitiveSection icon={FileText} label="Skills" items={plugin.skills} render={renderSkillRow} />
<PrimitiveSection icon={Users} label="Agents" items={plugin.agents} render={renderAgentRow} />
<PrimitiveSection icon={Terminal} label="Commands" items={plugin.commands} render={renderCommandRow} />
<PrimitiveSection icon={Webhook} label="Hooks" items={plugin.hooks} render={renderHookRow} />
<PrimitiveSection icon={Server} label="MCP Servers" items={plugin.mcps} render={renderMcpRow} />
</div>
{missingLabels.length > 0 ? (
<p className="mt-6 text-center text-[12px] text-gray-400">
No {formatMissingList(missingLabels)} detected in this plugin.
</p>
) : null}
</div>
);
}
// ── Section + row renderers ──────────────────────────────────────────────────
function formatMissingList(labels: string[]) {
if (labels.length === 0) return "";
const lowered = labels.map((label) => label.toLowerCase());
if (lowered.length === 1) return lowered[0];
if (lowered.length === 2) return `${lowered[0]} or ${lowered[1]}`;
return `${lowered.slice(0, -1).join(", ")}, or ${lowered[lowered.length - 1]}`;
}
function PrimitiveSection<T>({
icon: Icon,
label,
items,
emptyLabel,
render,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
items: T[];
emptyLabel: string;
render: (item: T) => React.ReactNode;
}) {
return (
<div className="mt-6 border-t border-gray-100 pt-5">
<p className="mb-3 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-gray-400">
<Icon className="h-3.5 w-3.5" />
{items.length === 0 ? label : `${items.length} ${label}`}
</p>
if (items.length === 0) {
return null;
}
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-100 px-5 py-4 text-[13px] text-gray-400">
{emptyLabel}
</div>
) : (
<div className="grid gap-1.5">{items.map((item) => render(item))}</div>
)}
</div>
return (
<section>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
<Icon className="h-3.5 w-3.5" />
{label}
</h2>
<p className="text-[11px] text-gray-400">
{items.length} {items.length === 1 ? "item" : "items"}
</p>
</div>
<div className="grid gap-2">{items.map((item) => render(item))}</div>
</section>
);
}
@@ -222,13 +170,12 @@ function renderSkillRow(skill: PluginSkill) {
return (
<div
key={skill.id}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3"
className="rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200"
>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{skill.name}</p>
<p className="mt-0.5 truncate text-[12px] text-gray-400">{skill.description}</p>
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">Skill</span>
<p className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">{skill.name}</p>
{skill.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">{skill.description}</p>
) : null}
</div>
);
}
@@ -237,15 +184,19 @@ function renderHookRow(hook: PluginHook) {
return (
<div
key={hook.id}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3"
className="flex items-start justify-between gap-3 rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200"
>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{hook.event}</p>
<p className="mt-0.5 truncate text-[12px] text-gray-400">{hook.description}</p>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-[13px] font-semibold text-gray-900">{hook.event}</p>
{hook.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">{hook.description}</p>
) : null}
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">
{hook.matcher ? `matcher: ${hook.matcher}` : "any"}
</span>
{hook.matcher ? (
<span className="shrink-0 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-500">
matcher: {hook.matcher}
</span>
) : null}
</div>
);
}
@@ -254,14 +205,16 @@ function renderMcpRow(mcp: PluginMcp) {
return (
<div
key={mcp.id}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3"
className="flex items-start justify-between gap-3 rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200"
>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{mcp.name}</p>
<p className="mt-0.5 truncate text-[12px] text-gray-400">{mcp.description}</p>
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">{mcp.name}</p>
{mcp.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">{mcp.description}</p>
) : null}
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">
{mcp.transport} · {mcp.toolCount} tools
<span className="shrink-0 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-500">
{mcp.transport} · {mcp.toolCount} tool{mcp.toolCount === 1 ? "" : "s"}
</span>
</div>
);
@@ -271,13 +224,12 @@ function renderAgentRow(agent: PluginAgent) {
return (
<div
key={agent.id}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3"
className="rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200"
>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{agent.name}</p>
<p className="mt-0.5 truncate text-[12px] text-gray-400">{agent.description}</p>
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">Agent</span>
<p className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">{agent.name}</p>
{agent.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">{agent.description}</p>
) : null}
</div>
);
}
@@ -286,17 +238,14 @@ function renderCommandRow(command: PluginCommand) {
return (
<div
key={command.id}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3"
className="rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200"
>
<div className="min-w-0">
<p className="truncate font-mono text-[13px] font-medium text-gray-900">{command.name}</p>
<p className="mt-0.5 truncate text-[12px] text-gray-400">{command.description}</p>
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">Command</span>
<p className="truncate font-mono text-[13px] font-semibold text-gray-900">{command.name}</p>
{command.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">{command.description}</p>
) : null}
</div>
);
}
// Satisfy the type parameter of DenPlugin import even if unused at runtime.
// (Keeps the file importable when you wire in edit forms later.)
export type { DenPlugin };

View File

@@ -8,6 +8,9 @@ import {
Puzzle,
Search,
Server,
Store,
Terminal,
Users,
Webhook,
} from "lucide-react";
import { PaperMeshGradient } from "@openwork/ui/react";
@@ -24,13 +27,15 @@ import {
usePlugins,
} from "./plugin-data";
type PluginView = "plugins" | "skills" | "hooks" | "mcps";
type PluginView = "plugins" | "skills" | "agents" | "commands" | "hooks" | "mcps";
const PLUGIN_TABS = [
{ value: "plugins" as const, label: "Plugins", icon: Puzzle },
{ value: "skills" as const, label: "All Skills", icon: FileText },
{ value: "hooks" as const, label: "All Hooks", icon: Webhook },
{ value: "mcps" as const, label: "All MCPs", icon: Server },
{ value: "skills" as const, label: "Skills", icon: FileText },
{ value: "agents" as const, label: "Agents", icon: Users },
{ value: "commands" as const, label: "Commands", icon: Terminal },
{ value: "hooks" as const, label: "Hooks", icon: Webhook },
{ value: "mcps" as const, label: "MCPs", icon: Server },
];
export function PluginsScreen() {
@@ -81,6 +86,22 @@ export function PluginsScreen() {
[plugins],
);
const allAgents = useMemo(
() =>
plugins.flatMap((plugin) =>
plugin.agents.map((agent) => ({ ...agent, pluginId: plugin.id, pluginName: plugin.name })),
),
[plugins],
);
const allCommands = useMemo(
() =>
plugins.flatMap((plugin) =>
plugin.commands.map((command) => ({ ...command, pluginId: plugin.id, pluginName: plugin.name })),
),
[plugins],
);
const filteredSkills = useMemo(() => {
if (!normalizedQuery) return allSkills;
return allSkills.filter(
@@ -111,14 +132,38 @@ export function PluginsScreen() {
);
}, [normalizedQuery, allMcps]);
const filteredAgents = useMemo(() => {
if (!normalizedQuery) return allAgents;
return allAgents.filter(
(agent) =>
agent.name.toLowerCase().includes(normalizedQuery) ||
agent.description.toLowerCase().includes(normalizedQuery) ||
agent.pluginName.toLowerCase().includes(normalizedQuery),
);
}, [normalizedQuery, allAgents]);
const filteredCommands = useMemo(() => {
if (!normalizedQuery) return allCommands;
return allCommands.filter(
(command) =>
command.name.toLowerCase().includes(normalizedQuery) ||
command.description.toLowerCase().includes(normalizedQuery) ||
command.pluginName.toLowerCase().includes(normalizedQuery),
);
}, [normalizedQuery, allCommands]);
const searchPlaceholder =
activeView === "plugins"
? "Search plugins..."
: activeView === "skills"
? "Search skills..."
: activeView === "hooks"
? "Search hooks..."
: "Search MCPs...";
: activeView === "agents"
? "Search agents..."
: activeView === "commands"
? "Search commands..."
: activeView === "hooks"
? "Search hooks..."
: "Search MCPs...";
return (
<DashboardPageTemplate
@@ -166,41 +211,54 @@ export function PluginsScreen() {
}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
<div className="grid gap-3 md:grid-cols-2">
{filteredPlugins.map((plugin) => (
<Link
key={plugin.id}
href={getPluginRoute(orgSlug, plugin.id)}
className="block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-8px_rgba(15,23,42,0.1)]"
className="group block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-12px_rgba(15,23,42,0.12)]"
>
{/* Gradient header */}
<div className="relative h-36 overflow-hidden border-b border-gray-100">
<div className="absolute inset-0">
<PaperMeshGradient seed={plugin.id} speed={0} />
<div className="flex items-stretch">
<div className="relative w-[68px] shrink-0 overflow-hidden">
<div className="absolute inset-0">
<PaperMeshGradient seed={plugin.id} speed={0} />
</div>
<div className="relative flex h-full items-center justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] border border-white/60 bg-white shadow-[0_8px_20px_-8px_rgba(15,23,42,0.3)]">
<Puzzle className="h-4 w-4 text-gray-700" aria-hidden />
</div>
</div>
</div>
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
<Puzzle className="h-6 w-6 text-gray-700" />
</div>
{plugin.installed ? (
<span className="absolute right-4 top-4 rounded-full border border-white/30 bg-white/20 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[1px] text-white backdrop-blur-md">
Installed
</span>
) : null}
</div>
{/* Body */}
<div className="px-6 pb-5 pt-9">
<div className="mb-1.5 flex items-center gap-2">
<h2 className="text-[15px] font-semibold text-gray-900">{plugin.name}</h2>
<span className="text-[11px] font-medium text-gray-400">v{plugin.version}</span>
</div>
<p className="line-clamp-2 text-[13px] leading-[1.6] text-gray-400">{plugin.description}</p>
<div className="min-w-0 flex-1 px-5 py-4">
<div className="flex items-start justify-between gap-2">
<h2 className="truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900">
{plugin.name}
</h2>
</div>
{plugin.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">
{plugin.description}
</p>
) : null}
<div className="mt-5 flex items-center gap-2 border-t border-gray-100 pt-4">
<span className="inline-flex rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-500">
{(plugin.marketplaces ?? []).length > 0 ? (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{(plugin.marketplaces ?? []).map((marketplace) => (
<span
key={marketplace.id}
className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-600"
>
<Store className="h-3 w-3 text-gray-400" aria-hidden />
<span className="truncate">{marketplace.name}</span>
</span>
))}
</div>
) : null}
<p className="mt-3 text-[11.5px] text-gray-400">
{getPluginPartsSummary(plugin)}
</span>
<span className="ml-auto text-[13px] font-medium text-gray-500">View plugin</span>
</p>
</div>
</div>
</Link>
@@ -209,6 +267,7 @@ export function PluginsScreen() {
)
) : activeView === "skills" ? (
<PrimitiveList
icon={FileText}
emptyLabel="No skills in this catalog yet."
emptyDescriptionEmpty="Once plugins contribute skills, they will show up here."
emptyDescriptionFiltered="No skills match that search."
@@ -217,12 +276,44 @@ export function PluginsScreen() {
id: skill.id,
title: skill.name,
description: skill.description,
tag: skill.pluginName,
pluginName: skill.pluginName,
href: getPluginRoute(orgSlug, skill.pluginId),
}))}
/>
) : activeView === "agents" ? (
<PrimitiveList
icon={Users}
emptyLabel="No agents in this catalog yet."
emptyDescriptionEmpty="Agents declared by plugins will show up here."
emptyDescriptionFiltered="No agents match that search."
unfilteredCount={allAgents.length}
rows={filteredAgents.map((agent) => ({
id: agent.id,
title: agent.name,
description: agent.description,
pluginName: agent.pluginName,
href: getPluginRoute(orgSlug, agent.pluginId),
}))}
/>
) : activeView === "commands" ? (
<PrimitiveList
icon={Terminal}
emptyLabel="No commands in this catalog yet."
emptyDescriptionEmpty="Slash-commands declared by plugins will show up here."
emptyDescriptionFiltered="No commands match that search."
unfilteredCount={allCommands.length}
rows={filteredCommands.map((command) => ({
id: command.id,
title: command.name,
description: command.description,
pluginName: command.pluginName,
monospacedTitle: true,
href: getPluginRoute(orgSlug, command.pluginId),
}))}
/>
) : activeView === "hooks" ? (
<PrimitiveList
icon={Webhook}
emptyLabel="No hooks in this catalog yet."
emptyDescriptionEmpty="Hooks declared by plugins will show up here."
emptyDescriptionFiltered="No hooks match that search."
@@ -231,12 +322,15 @@ export function PluginsScreen() {
id: hook.id,
title: hook.event,
description: hook.description,
tag: hook.pluginName,
pluginName: hook.pluginName,
monospacedTitle: true,
meta: hook.matcher ? `matcher: ${hook.matcher}` : undefined,
href: getPluginRoute(orgSlug, hook.pluginId),
}))}
/>
) : (
<PrimitiveList
icon={Server}
emptyLabel="No MCP servers in this catalog yet."
emptyDescriptionEmpty="MCP servers exposed by plugins will show up here."
emptyDescriptionFiltered="No MCPs match that search."
@@ -245,7 +339,8 @@ export function PluginsScreen() {
id: mcp.id,
title: mcp.name,
description: mcp.description,
tag: `${mcp.pluginName} · ${mcp.transport}`,
pluginName: mcp.pluginName,
meta: `${mcp.transport} · ${mcp.toolCount} tool${mcp.toolCount === 1 ? "" : "s"}`,
href: getPluginRoute(orgSlug, mcp.pluginId),
}))}
/>
@@ -293,17 +388,21 @@ type PrimitiveRow = {
id: string;
title: string;
description: string;
tag: string;
pluginName: string;
meta?: string;
monospacedTitle?: boolean;
href: string;
};
function PrimitiveList({
icon: Icon,
rows,
unfilteredCount,
emptyLabel,
emptyDescriptionEmpty,
emptyDescriptionFiltered,
}: {
icon: React.ComponentType<{ className?: string }>;
rows: PrimitiveRow[];
unfilteredCount: number;
emptyLabel: string;
@@ -320,22 +419,43 @@ function PrimitiveList({
}
return (
<div className="grid gap-1.5">
<div className="grid gap-3 md:grid-cols-2">
{rows.map((row) => (
<Link
key={row.id}
href={row.href}
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 bg-white px-4 py-3 transition hover:border-gray-200 hover:bg-gray-50/60"
className="group flex min-w-0 flex-col gap-3 rounded-2xl border border-gray-100 bg-white px-4 py-4 transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-12px_rgba(15,23,42,0.08)]"
>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{row.title}</p>
{row.description ? (
<p className="mt-0.5 truncate text-[12px] text-gray-400">{row.description}</p>
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-gray-50 text-gray-500 group-hover:bg-gray-100 group-hover:text-gray-700">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={`truncate text-[14px] font-semibold tracking-[-0.01em] text-gray-900 ${
row.monospacedTitle ? "font-mono" : ""
}`}
>
{row.title}
</p>
{row.description ? (
<p className="mt-0.5 line-clamp-2 text-[12.5px] leading-[1.55] text-gray-500">
{row.description}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 border-t border-gray-50 pt-2.5">
<span className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-500">
<Puzzle className="h-3 w-3 text-gray-400" aria-hidden />
<span className="max-w-[160px] truncate">{row.pluginName}</span>
</span>
{row.meta ? (
<span className="rounded-full bg-gray-50 px-2 py-0.5 text-[11px] text-gray-500">
{row.meta}
</span>
) : null}
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">
{row.tag}
</span>
</Link>
))}
</div>

View File

@@ -0,0 +1,12 @@
import { Suspense } from "react";
import { GithubIntegrationScreen } from "../../_components/github-integration-screen";
export const dynamic = "force-dynamic";
export default function GithubIntegrationPage() {
return (
<Suspense fallback={null}>
<GithubIntegrationScreen />
</Suspense>
);
}

View File

@@ -0,0 +1,10 @@
import { MarketplaceDetailScreen } from "../../_components/marketplace-detail-screen";
export default async function MarketplaceDetailPage({
params,
}: {
params: Promise<{ marketplaceId: string }>;
}) {
const { marketplaceId } = await params;
return <MarketplaceDetailScreen marketplaceId={marketplaceId} />;
}

View File

@@ -0,0 +1,5 @@
import { MarketplacesScreen } from "../_components/marketplaces-screen";
export default function MarketplacesPage() {
return <MarketplacesScreen />;
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

File diff suppressed because one or more lines are too long

View File

@@ -204,7 +204,7 @@ So the recommended shape is:
Recommended public endpoint:
- `POST /api/webhooks/connectors/github`
- `POST /v1/webhooks/connectors/github`
Recommended internal handler split:
@@ -222,7 +222,7 @@ If we want subpath-style organization inside the app, we can still do that after
Example internal structure:
- public ingress: `POST /api/webhooks/connectors/github`
- public ingress: `POST /v1/webhooks/connectors/github`
- internal modules:
- `webhooks/connectors/github/push`
- `webhooks/connectors/github/installation`
@@ -401,7 +401,7 @@ This is the recommended API contract shape around the webhook ingress and async
Endpoint:
- `POST /api/webhooks/connectors/github`
- `POST /v1/webhooks/connectors/github`
Input:

View File

@@ -0,0 +1,320 @@
# GitHub Instructions
This document lists exactly what you need to configure for the GitHub App connection flow and where each value should go.
## Goal
After this setup:
1. You open `Integrations` in Den Web.
2. You click `Connect` on GitHub.
3. GitHub shows the GitHub App install flow.
4. GitHub redirects back to OpenWork.
5. OpenWork shows the repositories visible to that installation.
6. You select one repo.
## Where to put the local server values
Fill these values in:
`ee/apps/den-api/.env.local`
That file is loaded by Den API in this order:
1. `ee/apps/den-api/.env.local`
2. `ee/apps/den-api/.env`
3. existing shell environment
## Values you need from GitHub
You need to create or update a GitHub App and collect these values:
- GitHub App ID
- GitHub App Client ID
- GitHub App Client Secret
- GitHub App Private Key
- GitHub App Webhook Secret
- GitHub Installation ID
- Test repository ID
- Test repository full name (`owner/repo`)
- Test branch
- Test ref (`refs/heads/<branch>`)
## Exactly where each value goes
Put these in `ee/apps/den-api/.env.local`:
```env
# Required Den API basics
PORT=8790
OPENWORK_DEV_MODE=1
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3005
BETTER_AUTH_URL=http://localhost:8790
BETTER_AUTH_SECRET=<generate-a-32-plus-char-secret>
DEN_DB_ENCRYPTION_KEY=<generate-a-32-plus-char-secret>
DATABASE_URL=mysql://root:password@127.0.0.1:3306/den
# Existing user auth GitHub values. These are separate from the connector app.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# GitHub connector app values
GITHUB_CONNECTOR_APP_ID=<github-app-id>
GITHUB_CONNECTOR_APP_CLIENT_ID=<github-app-client-id>
GITHUB_CONNECTOR_APP_CLIENT_SECRET=<github-app-client-secret>
GITHUB_CONNECTOR_APP_PRIVATE_KEY=<github-private-key-with-escaped-newlines>
GITHUB_CONNECTOR_APP_WEBHOOK_SECRET=<github-webhook-secret>
# Handy local test values
GITHUB_TEST_INSTALLATION_ID=<installation-id>
GITHUB_TEST_REPOSITORY_ID=<repository-id>
GITHUB_TEST_REPOSITORY_FULL_NAME=<owner/repo>
GITHUB_TEST_BRANCH=main
GITHUB_TEST_REF=refs/heads/main
```
## Important private key formatting
For `GITHUB_CONNECTOR_APP_PRIVATE_KEY`, paste the private key as one line with `\n` escapes.
Example:
```env
GITHUB_CONNECTOR_APP_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----
```
Do not paste raw multi-line PEM text directly unless you know the env loader path is handling it the way you expect.
## GitHub App setup
Go to:
`GitHub -> Settings -> Developer settings -> GitHub Apps -> New GitHub App`
Use these settings.
### Basic info
- App name: choose any unique name, for example `OpenWork Den Local`
- Homepage URL: use your local/public Den Web URL
- local example: `http://localhost:3005`
- public example: your deployed Den Web URL
- Description: optional
### Webhooks
- Webhooks: enabled
- Webhook URL:
- for webhook deliveries themselves, use:
- `https://<your-public-den-web-host>/api/den/v1/webhooks/connectors/github`
- or the public Den API URL if you are not proxying through Den Web
- Webhook secret:
- set this to the same value you put in `GITHUB_CONNECTOR_APP_WEBHOOK_SECRET`
## Important: Setup URL vs Webhook URL
GitHub App has two different relevant URLs:
1. `Setup URL`
2. `Webhook URL`
### Setup URL
This is where GitHub sends the user's browser back after installation.
This should be an actual Den Web page, not a den-api callback route.
Set it to:
`https://<your-public-den-web-host>/dashboard/integrations/github`
GitHub will append values like:
- `installation_id`
- `setup_action`
- `state`
Den Web reads those query params and then calls Den API to validate the signed state and load the repositories for that installation.
Do not point the Setup URL at Den API for this flow.
### Webhook URL
This is where GitHub sends push/install webhook events.
Set it to:
`https://<your-public-den-web-host>/api/den/v1/webhooks/connectors/github`
If your public entrypoint is Den API directly, use:
`https://<your-public-den-api-host>/v1/webhooks/connectors/github`
## Repository permissions
Set these GitHub App repository permissions:
- `Metadata`: `Read-only`
- `Contents`: `Read-only`
That is the minimum needed for the current repo-listing and validation flow.
## Organization permissions
None are strictly required for the current slice.
## Subscribe to these webhook events
Enable these events:
- `Push`
- `Installation`
- `Installation target`
- `Repository`
## Install the app
After creating the app:
1. Generate a client secret.
2. Generate a private key.
3. Install the app on the user or org that owns the repo you want to test.
4. Grant access to the repo you want to test.
## How to collect the values after setup
### App ID
From the GitHub App settings page.
Put in:
`GITHUB_CONNECTOR_APP_ID`
### Client ID
From the GitHub App settings page.
Put in:
`GITHUB_CONNECTOR_APP_CLIENT_ID`
### Client Secret
Generate from the GitHub App settings page.
Put in:
`GITHUB_CONNECTOR_APP_CLIENT_SECRET`
### Private Key
Generate from the GitHub App settings page.
Put in:
`GITHUB_CONNECTOR_APP_PRIVATE_KEY`
### Webhook Secret
From the GitHub App webhook configuration.
Put in:
`GITHUB_CONNECTOR_APP_WEBHOOK_SECRET`
### Installation ID
You can get it from the GitHub install redirect/callback, or via `gh`:
```bash
gh api repos/<owner>/<repo>/installation --jq '.id'
```
Put in:
`GITHUB_TEST_INSTALLATION_ID`
### Repository ID
```bash
gh api repos/<owner>/<repo> --jq '.id'
```
Put in:
`GITHUB_TEST_REPOSITORY_ID`
### Repository full name
Format:
`owner/repo`
Put in:
`GITHUB_TEST_REPOSITORY_FULL_NAME`
### Branch and ref
Examples:
- branch: `main`
- ref: `refs/heads/main`
Put in:
- `GITHUB_TEST_BRANCH`
- `GITHUB_TEST_REF`
## Local run commands
From the repo root:
```bash
pnpm --filter @openwork-ee/den-api dev
pnpm --filter @openwork-ee/den-web dev
```
Den Web default local URL in this repo is:
`http://localhost:3005`
Den API default local URL in this repo is:
`http://localhost:8790`
## Public URL requirement
GitHub must be able to reach your callback and webhook endpoints.
That means for real testing you need a public URL, usually via a tunnel or deployed environment.
Examples:
- `ngrok`
- `cloudflared`
- deployed Den Web / Den API host
## What to do after env is filled
1. Start Den API.
2. Start Den Web.
3. Confirm the GitHub App `Setup URL` points to the Den Web GitHub setup page.
4. Confirm the GitHub App `Webhook URL` points to the webhook endpoint.
5. Go to Den Web `Integrations`.
6. Click `Connect` on GitHub.
7. Finish the GitHub App install flow.
8. GitHub should return to `/dashboard/integrations/github` in Den Web.
9. Den Web should show the repository selection screen.
## Current scope note
This phase currently gets you to:
- GitHub App install redirect
- return to OpenWork
- repository list
- selecting one repo to create a connector instance
It does not yet complete full content ingestion from the selected repository.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
# GitHub Connection UX Plan
## Goal
Define the desired user experience for connecting GitHub to OpenWork in den-web (cloud), independent of the current implementation state.
This flow is for the den-api plugin connector system and GitHub App based connector onboarding.
## Desired user flow
1. User is in OpenWork den-web (cloud).
2. User sees an `Integrations` entry point.
3. User opens `Integrations`.
4. User sees a GitHub integration card.
5. User clicks `Connect` on GitHub.
6. OpenWork sends the user to the GitHub App install/authorize flow.
7. User completes the normal GitHub steps on GitHub.
8. GitHub returns the user to OpenWork.
9. OpenWork recognizes the completed GitHub App installation for the current org/user context.
10. OpenWork shows the user the list of repositories available through that installation.
11. User selects one repository.
12. OpenWork creates a new GitHub connector instance for that selected repository.
13. OpenWork configures webhook-driven sync for that repository.
14. Future pushes to the connected repository trigger OpenWork sync behavior through the connector pipeline.
## Product expectations
### Integrations surface
- den-web should expose a clear `Integrations` UI in cloud mode.
- GitHub should appear as a first-class integration option.
- The user should not need to manually paste GitHub installation ids or repository ids.
### Connect action
- Clicking `Connect` should start a GitHub App flow, not a legacy OAuth-only flow.
- The flow should preserve enough OpenWork context to return the user to the correct org and screen after GitHub finishes.
- The GitHub-side step should feel like a normal GitHub App installation flow.
### Return to OpenWork
- After GitHub redirects back, OpenWork should detect the installation that was just created or updated.
- If installation state is incomplete or ambiguous, OpenWork should guide the user instead of silently failing.
- The user should land back in the GitHub integration flow, not on a generic page with no next step.
### Repository selection
- OpenWork should list repositories available to the installation.
- The user should be able to pick one repository as the first connected source.
- Selecting a repository should create a connector instance for that repo in the current OpenWork org.
- The UX may later support branch choice and mapping choice, but repository selection is the minimum required step.
### Webhook + sync expectation
- Once connected, OpenWork should be ready to receive GitHub App webhooks for the selected repository.
- Pushes on the tracked branch should enter the connector sync pipeline.
- The system should present this as a connected integration, not as a hidden backend-only setup.
## User-facing behavior requirements
- The user should not need to know what an installation id is.
- The user should not need to call admin APIs manually.
- The user should not need to configure webhooks manually in normal product usage.
- The user should be able to understand whether GitHub is:
- not connected
- connected but no repository selected
- connected and repository syncing
- connected but needs attention
## Desired backend behavior
To support the UX above, the backend flow should conceptually do the following:
1. Generate or expose the GitHub App install URL.
2. Preserve OpenWork return context across the redirect.
3. Handle the GitHub return/callback.
4. Resolve the GitHub App installation id associated with the user action.
5. Create or update the corresponding `connector_account`.
6. List repositories accessible through that installation.
7. On repo selection, create:
- a `connector_instance`
- a `connector_target` for the repo/branch
- any initial mappings needed for ingestion
8. Ensure webhook events can resolve that connector target.
9. Queue sync work when relevant webhook events arrive.
## UX principles
- Prefer a short, guided flow over a configuration-heavy admin experience.
- Favor product language like `Connect GitHub` over backend nouns like `connector account`.
- Hide raw GitHub/App identifiers from the normal UX unless needed for support/debugging.
- Keep the first-run flow focused on success: install, return, pick repo, connected.
- Advanced settings can exist later, but should not block first connection.
## Success criteria
The experience is successful when:
1. A cloud user can start from den-web without using terminal commands.
2. The user can complete GitHub App installation from the app.
3. The user returns to OpenWork automatically.
4. OpenWork shows repositories from that installation.
5. The user selects a repo.
6. OpenWork creates a connector instance for that repo.
7. GitHub webhooks for that repo can be accepted and associated to the instance.
8. The connection state is visible in the product UI.
## Non-goals for this document
- Exact API shapes for every route.
- Full ingestion/reconciliation design details.
- Delivery/install runtime behavior for connected content.
- Final UI layout or visual design.
## Next planning step
Translate this desired UX into an implementation plan that maps:
- den-web screens and states
- den-api routes and callback behavior
- GitHub App configuration requirements
- connector-account / connector-instance creation behavior
- webhook readiness and initial sync behavior

View File

@@ -0,0 +1,773 @@
# GitHub Repo Discovery Plan
## Goal
Define the discovery phase that happens after a user connects a GitHub repo and returns to Den Web.
This phase should:
1. inspect the connected repository structure;
2. determine whether the repo is a Claude-compatible marketplace repo, a Claude-compatible single-plugin repo, or a looser folder-based repo;
3. present the discovered plugins to the user in a setup flow;
4. let the user choose which discovered plugins should map into OpenWork;
5. translate the selected discovery result into OpenWork connector records and future ingestion work.
This document covers:
- the discovery UX;
- the GitHub-side reads we need;
- how we detect supported repo shapes;
- how we infer plugins when no manifest exists;
- how the result maps into OpenWork internal structures.
Related:
- `prds/new-plugin-arch/github-connection/plan.md`
- `prds/new-plugin-arch/github-connection/connectors.md`
- `prds/new-plugin-arch/GitHub-connector.md`
## Why a discovery phase exists
The current post-connect flow stops at repository selection.
That is enough to create:
- a `connector_account`;
- a `connector_instance`;
- a `connector_target`;
- webhook-triggered `connector_sync_event` rows.
It is not enough to understand the shape of the repo and convert that shape into useful OpenWork mappings.
The discovery phase fills that gap.
Instead of immediately asking the user to author raw path mappings, OpenWork should first inspect the repo and propose a structured interpretation of what it found.
## Desired user flow
### Updated high-level flow
1. User connects GitHub.
2. User selects a repository.
3. OpenWork creates the connector instance and target.
4. OpenWork routes the user into a dedicated `Setup` / `Discovery` page for that connector instance.
5. OpenWork reads the repository tree and shows progress steps in the UI.
6. OpenWork classifies the repo shape.
7. OpenWork shows discovered plugins, preselected by default.
8. User confirms or deselects discovered plugins.
9. OpenWork creates the initial connector mappings and plugin records from that discovery result.
10. OpenWork is ready for initial ingestion/sync.
### User-facing setup steps
The setup page should feel like a guided scan.
Suggested steps:
1. `Reading repository structure`
2. `Checking for Claude marketplace manifest`
3. `Checking for plugin manifests`
4. `Looking for known component folders`
5. `Preparing discovered plugins`
The UI should show:
- which step is currently running;
- success/failure state per step;
- the discovered plugins list when ready;
- clear empty-state or unsupported-shape messaging when nothing useful is found.
## Reference conventions
### Official Claude plugin conventions
Based on the Claude plugin docs and reference repo:
- plugin manifest lives at `.claude-plugin/plugin.json`;
- marketplace manifest lives at `.claude-plugin/marketplace.json`;
- plugin components live at the plugin root, not inside `.claude-plugin/`;
- common plugin root folders include:
- `skills/`
- `commands/`
- `agents/`
- `hooks/`
- `.mcp.json`
- `.lsp.json`
- `monitors/`
- `settings.json`
- standalone Claude configuration can also live under `.claude/`, especially:
- `.claude/skills/`
- `.claude/agents/`
- `.claude/commands/`
### Reference repo
Use `https://github.com/anthropics/claude-plugins-official` as a reference shape for marketplace repos.
Important observations:
- the repo has a root `.claude-plugin/marketplace.json`;
- it contains multiple plugin entries;
- many entries point at local paths inside the repo such as `./plugins/...` or `./external_plugins/...`;
- some entries point at external git URLs or subdirs.
That means OpenWork discovery should treat marketplace repos as a first-class shape, but be explicit about what is in-scope for a connected single repo.
## Discovery output model
The discovery phase should produce an explicit, structured result.
Suggested conceptual result:
```ts
type RepoDiscoveryResult = {
connectorInstanceId: string
connectorTargetId: string
repositoryFullName: string
ref: string
treeSummary: {
scannedEntryCount: number
truncated: boolean
strategy: "git-tree-recursive" | "contents-bfs"
}
classification:
| "claude_marketplace_repo"
| "claude_multi_plugin_repo"
| "claude_single_plugin_repo"
| "folder_inferred_repo"
| "unsupported"
discoveredPlugins: DiscoveredPlugin[]
warnings: DiscoveryWarning[]
}
type DiscoveredPlugin = {
key: string
sourceKind:
| "marketplace_entry"
| "plugin_manifest"
| "standalone_claude"
| "folder_inference"
rootPath: string
displayName: string
description: string | null
selectedByDefault: boolean
manifestPath: string | null
componentKinds: Array<"skill" | "command" | "agent" | "hook" | "mcp_server" | "lsp_server" | "monitor" | "settings">
componentPaths: {
skills: string[]
commands: string[]
agents: string[]
hooks: string[]
mcpServers: string[]
lspServers: string[]
monitors: string[]
settings: string[]
}
metadata: Record<string, unknown>
}
```
This result is intentionally separate from final ingestion. Discovery should be cheap to recompute and safe to show in the UI.
## API surface
## Requirements
We need an API that can, given the selected connector instance/target, read GitHub and return a normalized view of the repository tree and discovery result.
The tree can be large, so the API must not assume that the full repo listing is always tiny.
### Recommended endpoints
#### 1. Start or refresh discovery
`POST /v1/connector-instances/:connectorInstanceId/discovery/refresh`
Purpose:
- read GitHub using the installation token;
- build or refresh the discovery snapshot;
- persist the result for the UI;
- return the current discovery state.
Recommended response:
- current step/state;
- summary counts;
- discovered plugins if already complete.
#### 2. Get discovery state
`GET /v1/connector-instances/:connectorInstanceId/discovery`
Purpose:
- return the last computed discovery result;
- support polling while the discovery scan runs;
- drive the setup page without recomputing every request.
#### 3. Page through the normalized repo tree
`GET /v1/connector-instances/:connectorInstanceId/discovery/tree?cursor=&limit=&prefix=`
Purpose:
- expose the discovered file list for debugging and future advanced UX;
- avoid forcing the UI to load every path at once;
- support drill-down into a directory prefix.
### Why a persisted snapshot is better than live-only reads
Discovery is more than a raw file listing.
It is a structured interpretation step.
Persisting the latest snapshot gives us:
- deterministic UI reload behavior;
- auditability of what the repo looked like when discovery ran;
- a clean handoff from discovery UI to mapping creation;
- a place to store warnings and unsupported cases.
## GitHub reading strategy
### Primary strategy
Use the GitHub Git Trees API against the selected branch head commit.
Preferred read path:
1. fetch the tracked branch head SHA;
2. fetch the recursive tree for that commit;
3. normalize to a path list with type metadata.
Advantages:
- one request gives the full tree in the common case;
- easy to search for known files;
- easy to infer folder groupings;
- deterministic against a known commit SHA.
### Fallback strategy for large repos
GitHub recursive tree responses can be truncated.
If the recursive tree response is truncated:
1. store that truncated flag;
2. fall back to directory-by-directory `contents` traversal using BFS;
3. page the normalized result by `prefix + cursor`;
4. cap the total scan budget for one discovery run.
### Suggested limits
For v1:
- default API page size: `200` normalized entries;
- default max discovery scan budget: `10,000` paths;
- stop scanning further when:
- we exceed budget;
- or we have enough evidence to classify the repo and build the discovered plugin list.
### Practical optimization
We do not need the full contents of every file during discovery.
We mostly need:
- the path list;
- whether certain files exist;
- the content of a small number of manifest files.
So discovery should:
- list tree entries first;
- only fetch file contents for:
- `.claude-plugin/marketplace.json`
- any `.claude-plugin/plugin.json`
- any root-level `plugin.json` used as a metadata hint
- `.mcp.json`
- `.lsp.json`
- `hooks/hooks.json`
- `monitors/monitors.json`
- `settings.json`
Do not eagerly fetch SKILL/agent/command content during the discovery phase.
## Classification algorithm
Discovery should classify the repo in this priority order.
### 1. Marketplace repo
Check for root:
- `.claude-plugin/marketplace.json`
If present:
- classify as `claude_marketplace_repo`;
- parse marketplace entries;
- attempt to resolve entries that point to local repo paths;
- present the listed plugins to the user, ticked by default.
### 2. Explicit plugin manifests
If no marketplace manifest exists, search for all instances of:
- `.claude-plugin/plugin.json`
If one or more are found:
- classify as:
- `claude_single_plugin_repo` if exactly one plugin manifest exists and it is at repo root;
- `claude_multi_plugin_repo` if more than one plugin manifest exists or plugin roots live in subdirectories.
- create one `DiscoveredPlugin` per manifest.
### 3. Standalone Claude folders
If no marketplace manifest and no plugin manifest is found, check for standalone Claude paths:
- `.claude/skills/**`
- `.claude/commands/**`
- `.claude/agents/**`
If present:
- classify as `standalone_claude` in the discovered plugin source kind;
- infer a single plugin rooted at repo root unless stronger folder grouping is present.
### 4. Folder inference
If none of the explicit Claude shapes exist, infer plugin candidates from known component folders.
Known folders:
- `skills/`
- `commands/`
- `agents/`
Rule:
- for each match, examine its parent folder;
- group sibling component folders by that parent;
- create one discovered plugin per parent folder.
Example:
```text
Sales/skills
Sales/commands
finance/agents
finance/commands
```
Discovery result:
- plugin `Sales`
- plugin `finance`
This becomes:
- one plugin candidate rooted at `Sales/`
- one plugin candidate rooted at `finance/`
If the repo itself has root-level `skills/`, `commands/`, or `agents/`, that should infer one root plugin using the repo name as the display name unless better metadata exists.
## Plugin metadata resolution
For each discovered plugin candidate, resolve metadata in this order.
### 1. Official Claude plugin manifest
Check:
- `<root>/.claude-plugin/plugin.json`
If present, use:
- `name`
- `description`
- `version`
- `author`
- other supported metadata as hints
### 2. Loose metadata hint
If no official manifest exists, optionally check:
- `<root>/plugin.json`
This is not an official Claude plugin location.
Treat it as a metadata hint only.
Use:
- `name`
- `description`
Do not treat it as proof that the repo is a Claude plugin.
### 3. Folder-name fallback
If no metadata file exists:
- use the folder name as `displayName`;
- derive a human-friendly label from that folder name.
For a root plugin with no folder name beyond the repo itself, use the repo name.
## Marketplace repo handling
Marketplace repos need special treatment.
### What we should support in v1
Support marketplace entries whose source resolves inside the currently connected repo.
Examples:
- `./plugins/example-plugin`
- `./external_plugins/something`
For these entries:
- resolve the local plugin root;
- inspect that root for components;
- create one `DiscoveredPlugin` for each entry.
### What we should not silently fake in v1
Marketplace entries that point to external URLs or other repos should not be treated as if they were fully present in the current repo.
Examples:
- `source.url = https://github.com/...`
- `source.source = git-subdir`
For those entries, discovery should either:
- mark them as `external source not yet supported in repo discovery`; or
- hide them unless we explicitly decide to support cross-repo expansion.
Recommended v1 behavior:
- show them in the discovery result but disable selection;
- explain that they require external source expansion, which is out of scope for the current single-repo connector flow.
This keeps the behavior honest and still lets users understand what OpenWork detected.
## Inferred plugin rules
### Known component directories
The discovery system should recognize these as plugin-like components:
- `skills/`
- `commands/`
- `agents/`
- `.claude/skills/`
- `.claude/commands/`
- `.claude/agents/`
Optional later additions:
- `hooks/`
- `.mcp.json`
- `.lsp.json`
- `monitors/`
- `settings.json`
### Grouping rules
Group by the nearest plugin root candidate.
Examples:
#### Case A: explicit manifest
```text
plugins/sales/.claude-plugin/plugin.json
plugins/sales/skills
plugins/sales/commands
```
Result:
- one discovered plugin rooted at `plugins/sales`
#### Case B: inferred sibling grouping
```text
Sales/skills
Sales/commands
Finance/agents
Finance/commands
```
Result:
- one discovered plugin rooted at `Sales`
- one discovered plugin rooted at `Finance`
#### Case C: root standalone repo
```text
.claude/skills
.claude/commands
```
Result:
- one discovered plugin rooted at repo root
## UI plan
## Setup page states
Suggested states:
1. `loading`
2. `discovery_running`
3. `discovery_ready`
4. `discovery_empty`
5. `discovery_error`
### discovery_running
Show:
- progress steps;
- current repo name/branch;
- a short explanation that OpenWork is figuring out how to map this repo.
### discovery_ready
Show:
- discovered plugins list;
- each item ticked by default if supported;
- description/metadata when available;
- badges for detected component kinds:
- skills
- commands
- agents
- hooks
- MCP
- warnings for unsupported marketplace entries or ambiguous structure.
Primary CTA:
- `Continue with selected plugins`
Secondary CTA:
- `Review file structure`
### discovery_empty
Show:
- no supported plugin structure found;
- what OpenWork looked for;
- option to create manual mappings.
### discovery_error
Show:
- discovery failed;
- which step failed;
- retry action.
## What the user selects
The user should select plugin groups, not raw files.
Each selected discovered plugin becomes a proposal for:
- one OpenWork `plugin` row;
- a set of `connector_mapping` rows covering that plugin's component folders.
This matches the product goal better than asking the user to map individual folders one by one on first run.
## Mapping discovered plugins to OpenWork internal data
## Internal objects we already have
- `connector_account`
- `connector_instance`
- `connector_target`
- `connector_mapping`
- `connector_sync_event`
- `connector_source_binding`
- `connector_source_tombstone`
- `plugin`
- plugin membership tables
- `config_object`
## Discovery-to-internal mapping
### Discovery phase output
Before the user confirms selection, discovery should exist as draft state.
Recommended persistence model:
- `connector_discovery_run`
- `connector_discovery_candidate`
Conceptually:
```text
connector_discovery_run
- id
- organization_id
- connector_instance_id
- connector_target_id
- source_revision_ref
- status
- classification
- tree_summary_json
- warnings_json
- created_at
- updated_at
connector_discovery_candidate
- id
- discovery_run_id
- key
- source_kind
- root_path
- display_name
- description
- manifest_path
- component_summary_json
- selection_state
- supported
- warnings_json
```
Why add dedicated discovery tables instead of jumping straight to `connector_mapping`?
- discovery is provisional;
- the user may deselect some plugin candidates;
- we want to store unsupported candidates and warnings;
- we want a clean boundary between `what we saw` and `what the user approved`.
### After user confirms selection
For each selected discovered plugin:
1. create or upsert an OpenWork `plugin` row;
2. create one `connector_mapping` per detected component kind/path;
3. set `auto_add_to_plugin = true` for those mappings;
4. link the mapping to the selected OpenWork plugin id;
5. enqueue an initial discovery-approved ingestion sync.
### Example mapping
Repo:
```text
Sales/skills
Sales/commands
finance/agents
finance/commands
```
Discovery result:
- plugin candidate `Sales`
- plugin candidate `finance`
Internal translation after user confirms:
- create OpenWork plugin `Sales`
- create OpenWork plugin `finance`
- create mappings:
- `Sales/skills/**` -> `skill` -> plugin `Sales`
- `Sales/commands/**` -> `command` -> plugin `Sales`
- `finance/agents/**` -> `agent` -> plugin `finance`
- `finance/commands/**` -> `command` -> plugin `finance`
### Marketplace mapping
For a local marketplace entry rooted at `plugins/feature-dev`:
- create one OpenWork plugin from the marketplace/plugin metadata;
- create mappings for each detected component path under that root;
- preserve the marketplace entry metadata as origin/discovery metadata.
## Discovery does not ingest content yet
Discovery should stop short of full content ingestion.
It should:
- inspect paths;
- read manifests and small metadata files;
- infer plugin groups;
- help the user approve a mapping shape.
It should not yet:
- parse every SKILL/agent/command file body;
- create `config_object` rows;
- create `connector_source_binding` rows;
- create tombstones.
Those belong to the subsequent ingestion/reconciliation phase.
## Relationship to initial sync
The initial sync should happen after discovery is approved.
Suggested flow:
1. repo selected
2. connector instance created
3. discovery run computes candidates
4. user confirms selections
5. OpenWork creates plugin rows + connector mappings
6. OpenWork enqueues initial full sync
7. sync executor reads repo contents and materializes config objects
This sequencing is important because ingestion needs the mapping decisions.
## v1 scope
### In scope
- dedicated setup/discovery page after repo selection;
- repo tree listing API with pagination/limits;
- root marketplace detection;
- `.claude-plugin/plugin.json` discovery anywhere in the repo;
- `.claude/skills`, `.claude/commands`, `.claude/agents` support;
- folder-based inference from known component paths;
- user selection UI for discovered plugins;
- translation from selected candidates into plugin rows + connector mappings.
### Explicitly out of scope for this phase
- full content ingestion;
- recursive external marketplace source expansion across other repos;
- hooks-to-OpenWork runtime semantics beyond discovery;
- automatic parsing of every skill/agent/command file body during discovery.
## Open questions
1. Should discovery run synchronously for small repos and asynchronously for larger repos, or always be modeled as a background run?
2. Do we want to persist discovery results in dedicated tables immediately, or temporarily store the first version inside connector metadata while the shape is still changing?
3. For marketplace repos with external URL entries, should we show unsupported entries disabled, or hide them entirely in v1?
4. Should root-level `plugin.json` remain a metadata hint only, or do we want to formalize it as an OpenWork-specific compatibility rule?
5. When multiple discovered plugin candidates have the same normalized name, what is the preferred display/slug collision strategy?
## Recommended next implementation order
1. Add a discovery result model and API endpoints.
2. Implement GitHub tree listing with truncation-aware fallback.
3. Implement classification + candidate extraction.
4. Update the GitHub setup page to become the discovery page.
5. Add the discovered plugin selection UI.
6. Convert approved candidates into `plugin` + `connector_mapping` rows.
7. Then implement initial ingestion against those mappings.

View File

@@ -27,6 +27,19 @@ Use this shape for new entries:
## Current entries
## 2026-04-21 Step 2 - GitHub App connect + repo selection slice
- The existing Den `/integrations` UI already had the right shell, but the GitHub path was a pure client-side preview. The cleanest upgrade path is to keep Bitbucket on the mock dialog for now while sending GitHub through a real App install redirect and a dedicated post-return repo-selection screen.
- GitHub App install does not need the normal Better Auth GitHub social login flow. The updated working slice is: den-web calls `POST /v1/connectors/github/install/start`, GitHub redirects the browser to the Den Web setup page, then den-web calls `POST /v1/connectors/github/install/complete` with `installation_id + state` so den-api can validate the signed state and load repos.
- A signed state token based on `BETTER_AUTH_SECRET` is enough for the current redirect round-trip and is simpler than introducing a new persistence table for short-lived install state in this phase.
- The GitHub App `Setup URL` should point at a real web page in Den Web, e.g. `/dashboard/integrations/github`, not a backend callback route.
- Workspace dependency installation was the original gating build blocker, but after `pnpm install` the den-api build, den-web typecheck, and focused den-api tests all run in this worktree.
## 2026-04-21 Step 1 - Live GitHub App admin validation
- The GitHub-specific admin path was more stubbed than it looked: `listGithubRepositories()` only echoed cached connector-account metadata and `validateGithubTarget()` only checked whether `ref === refs/heads/${branch}` without contacting GitHub.
- A small dedicated helper module at `ee/apps/den-api/src/routes/org/plugin-system/github-app.ts` keeps the real GitHub App mechanics isolated: normalize multiline private keys, mint an app JWT, exchange it for an installation token, then call GitHub APIs for repository listing and branch validation.
- For real connector setup testing, the minimally required live server secrets are `GITHUB_CONNECTOR_APP_ID`, `GITHUB_CONNECTOR_APP_PRIVATE_KEY`, and `GITHUB_CONNECTOR_APP_WEBHOOK_SECRET`; `GITHUB_CONNECTOR_APP_CLIENT_ID` / `CLIENT_SECRET` are still part of the app registration but are not yet consumed by the current den-api admin flow.
- Workspace dependency installation is still a gating factor for broader den-api tests in this worktree: pure helper tests can run with Bun, but route/store tests that import `hono` or `@openwork-ee/den-db/*` still fail until the workspace dependencies are installed.
## 2026-04-17 Post-step cleanup - Type tightening and naming
- The route directory is now `ee/apps/den-api/src/routes/org/plugin-system/`; `plugin-arch` was only the planning nickname and was too confusing as a long-lived API module name.
- The plugin-system route wrapper can stay type-safe enough without `@ts-nocheck` by isolating Hono middleware registration behind a tiny `withPluginArchOrgContext()` helper and using explicit request-part adapters for `param`, `query`, and `json` reads.

View File

@@ -15,7 +15,7 @@ Normal authenticated admin APIs are documented in `prds/new-plugin-arch/admin-ap
### GitHub webhook ingress
- `POST /api/webhooks/connectors/github`
- `POST /v1/webhooks/connectors/github`
Purpose: