Unified · Split
| @@ -50,5 +50,18 @@ export const SourcesHandlers = HttpApiBuilder.group( · 49 unmodified lines | |||
| 50 | mayElicit: t.mayElicit, | 50 | mayElicit: t.mayElicit, |
| 51 | })); | 51 | })); |
| 52 | }), | 52 | }), |
| 53 | ) | ||
| 54 | .handle("detect", ({ path, payload }) => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const executor = yield* ExecutorService; | ||
| 57 | const results = yield* executor.sources.detect(payload.url); | ||
| 58 | return results.map((r) => ({ | ||
| 59 | kind: r.kind, | ||
| 60 | confidence: r.confidence, | ||
| 61 | endpoint: r.endpoint, | ||
| 62 | name: r.name, | ||
| 63 | namespace: r.namespace, | ||
| 64 | })); | ||
| 65 | }), | ||
| 53 | ), | 66 | ), |
| 54 | ); | 67 | ); |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { useState, Suspense } from "react"; | 1 | import { useReducer, useCallback, Suspense } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/react"; | 3 | import { Result, useAtomValue, useAtomRefresh, useAtomSet, sourcesAtom, detectSource } from "@executor/react"; |
| 4 | import type { SourcePlugin } from "@executor/react"; | 4 | import type { SourcePlugin } from "@executor/react"; |
| 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| @@ -19,78 +19,113 @@ const sourcePlugins: SourcePlugin[] = [ · 12 unmodified lines | |||
| 19 | graphqlSourcePlugin, | 19 | graphqlSourcePlugin, |
| 20 | ]; | 20 | ]; |
| 21 | 21 | ||
| 22 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | ||
| 23 | openapi: "openapi", | ||
| 24 | mcp: "mcp", | ||
| 25 | graphql: "graphql", | ||
| 26 | googleDiscovery: "googleDiscovery", | ||
| 27 | }; | ||
| 28 | |||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | // State machine | ||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | |||
| 33 | type State = { | ||
| 34 | step: "list" | "detecting" | "adding"; | ||
| 35 | url: string; | ||
| 36 | error: string | null; | ||
| 37 | pluginKey: string | null; | ||
| 38 | initialUrl: string | undefined; | ||
| 39 | }; | ||
| 40 | |||
| 41 | type Action = | ||
| 42 | | { type: "set-url"; url: string } | ||
| 43 | | { type: "detect-start" } | ||
| 44 | | { type: "detect-ok"; pluginKey: string; url: string } | ||
| 45 | | { type: "detect-no-match" } | ||
| 46 | | { type: "detect-unknown-kind"; kind: string } | ||
| 47 | | { type: "detect-fail" } | ||
| 48 | | { type: "add-manual"; pluginKey: string } | ||
| 49 | | { type: "back" }; | ||
| 50 | |||
| 51 | const init: State = { step: "list", url: "", error: null, pluginKey: null, initialUrl: undefined }; | ||
| 52 | |||
| 53 | function reducer(state: State, action: Action): State { | ||
| 54 | switch (action.type) { | ||
| 55 | case "set-url": | ||
| 56 | return { ...state, step: "list", url: action.url, error: null }; | ||
| 57 | case "detect-start": | ||
| 58 | return { ...state, step: "detecting", error: null }; | ||
| 59 | case "detect-ok": | ||
| 60 | return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: action.url }; | ||
| 61 | case "detect-no-match": | ||
| 62 | return { ...state, step: "list", error: "Could not detect a source type from this URL. Try adding manually." }; | ||
| 63 | case "detect-unknown-kind": | ||
| 64 | return { ...state, step: "list", error: `Detected source type "${action.kind}" but no plugin is available for it.` }; | ||
| 65 | case "detect-fail": | ||
| 66 | return { ...state, step: "list", error: "Detection failed. Try adding a source manually." }; | ||
| 67 | case "add-manual": | ||
| 68 | return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: undefined }; | ||
| 69 | case "back": | ||
| 70 | return init; | ||
| 71 | default: | ||
| 72 | return state; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 22 | // --------------------------------------------------------------------------- | 76 | // --------------------------------------------------------------------------- |
| 23 | // Page | 77 | // Page |
| 24 | // --------------------------------------------------------------------------- | 78 | // --------------------------------------------------------------------------- |
| 25 | 79 | ||
| 26 | export function SourcesPage() { | 80 | export function SourcesPage() { |
| 27 | const [adding, setAdding] = useState<string | null>(null); | 81 | const [state, dispatch] = useReducer(reducer, init); |
| 28 | const sources = useAtomValue(sourcesAtom()); | 82 | const sources = useAtomValue(sourcesAtom()); |
| 29 | const refreshSources = useAtomRefresh(sourcesAtom()); | 83 | const refreshSources = useAtomRefresh(sourcesAtom()); |
| 84 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); | ||
| 30 | 85 | ||
| 31 | const plugin = adding | 86 | const handleDetect = useCallback(async () => { |
| 32 | ? sourcePlugins.find((p) => p.key === adding) | 87 | const trimmed = state.url.trim(); |
| 33 | : undefined; | 88 | if (!trimmed) return; |
| 89 | dispatch({ type: "detect-start" }); | ||
| 90 | try { | ||
| 91 | const results = await doDetect({ | ||
| 92 | path: { scopeId: "default" as any }, | ||
| 93 | payload: { url: trimmed }, | ||
| 94 | }); | ||
| 95 | if (results.length === 0) { | ||
| 96 | dispatch({ type: "detect-no-match" }); | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | const pluginKey = KIND_TO_PLUGIN_KEY[results[0].kind]; | ||
| 100 | if (pluginKey) { | ||
| 101 | dispatch({ type: "detect-ok", pluginKey, url: trimmed }); | ||
| 102 | } else { | ||
| 103 | dispatch({ type: "detect-unknown-kind", kind: results[0].kind }); | ||
| 104 | } | ||
| 105 | } catch { | ||
| 106 | dispatch({ type: "detect-fail" }); | ||
| 107 | } | ||
| 108 | }, [state.url, doDetect]); | ||
| 34 | 109 | ||
| 35 | const renderSourceGrid = (sources: readonly { | 110 | // --------------------------------------------------------------------------- |
| 36 | id: string; | 111 | // Adding view |
| 37 | name: string; | 112 | // --------------------------------------------------------------------------- |
| 38 | kind: string; | ||
| 39 | runtime?: boolean; | ||
| 40 | }[]) => ( | ||
| 41 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | ||
| 42 | {sources.map((s) => ( | ||
| 43 | <Link | ||
| 44 | key={s.id} | ||
| 45 | to="/sources/$namespace" | ||
| 46 | params={{ namespace: s.id }} | ||
| 47 | className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90" | ||
| 48 | > | ||
| 49 | <div className="flex items-start gap-3"> | ||
| 50 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | ||
| 51 | <svg viewBox="0 0 16 16" className="size-4"> | ||
| 52 | <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" /> | ||
| 53 | <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| 54 | </svg> | ||
| 55 | </div> | ||
| 56 | <div className="min-w-0 flex-1"> | ||
| 57 | <div className="flex items-start justify-between gap-2"> | ||
| 58 | <div className="truncate text-sm font-semibold text-foreground"> | ||
| 59 | {s.name} | ||
| 60 | </div> | ||
| 61 | <div className="flex shrink-0 items-center gap-1.5"> | ||
| 62 | {s.runtime && ( | ||
| 63 | <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> | ||
| 64 | built-in | ||
| 65 | </span> | ||
| 66 | )} | ||
| 67 | <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | ||
| 68 | {s.kind} | ||
| 69 | </span> | ||
| 70 | </div> | ||
| 71 | </div> | ||
| 72 | <div className="mt-0.5 text-xs text-muted-foreground"> | ||
| 73 | {s.id} | ||
| 74 | </div> | ||
| 75 | </div> | ||
| 76 | </div> | ||
| 77 | </Link> | ||
| 78 | ))} | ||
| 79 | </div> | ||
| 80 | ); | ||
| 81 | 113 | ||
| 82 | if (plugin) { | 114 | if (state.step === "adding" && state.pluginKey) { |
| 115 | const plugin = sourcePlugins.find((p) => p.key === state.pluginKey); | ||
| 116 | if (!plugin) return null; | ||
| 83 | const AddComponent = plugin.add; | 117 | const AddComponent = plugin.add; |
| 84 | return ( | 118 | return ( |
| 85 | <div className="min-h-0 flex-1 overflow-y-auto"> | 119 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 86 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 120 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 87 | <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}> | 121 | <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}> |
| 88 | <AddComponent | 122 | <AddComponent |
| 123 | initialUrl={state.initialUrl} | ||
| 89 | onComplete={() => { | 124 | onComplete={() => { |
| 90 | setAdding(null); | 125 | dispatch({ type: "back" }); |
| 91 | refreshSources(); | 126 | refreshSources(); |
| 92 | }} | 127 | }} |
| 93 | onCancel={() => setAdding(null)} | 128 | onCancel={() => dispatch({ type: "back" })} |
| 94 | /> | 129 | /> |
| 95 | </Suspense> | 130 | </Suspense> |
| 96 | </div> | 131 | </div> |
| @@ -98,32 +133,64 @@ export function SourcesPage() { · 1 unmodified line | |||
| 98 | ); | 133 | ); |
| 99 | } | 134 | } |
| 100 | 135 | ||
| 136 | // --------------------------------------------------------------------------- | ||
| 137 | // List view | ||
| 138 | // --------------------------------------------------------------------------- | ||
| 139 | |||
| 140 | const isDetecting = state.step === "detecting"; | ||
| 141 | const error = state.step === "list" ? state.error : null; | ||
| 142 | |||
| 101 | return ( | 143 | return ( |
| 102 | <div className="min-h-0 flex-1 overflow-y-auto"> | 144 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 103 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 145 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 104 | {/* Header */} | 146 | {/* Header */} |
| 105 | <div className="flex items-end justify-between mb-8"> | 147 | <div className="mb-8"> |
| 106 | <div> | 148 | <div className="flex items-end justify-between"> |
| 107 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 149 | <div> |
| 108 | Sources | 150 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 109 | </h1> | 151 | Sources |
| 110 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | 152 | </h1> |
| 111 | Tool providers available in this workspace. | 153 | <p className="mt-1.5 text-[14px] text-muted-foreground"> |
| 112 | </p> | 154 | Tool providers available in this workspace. |
| 155 | </p> | ||
| 156 | </div> | ||
| 113 | </div> | 157 | </div> |
| 114 | <div className="flex gap-2"> | 158 | |
| 115 | {sourcePlugins.map((p) => ( | 159 | {/* URL detection input */} |
| 160 | <div className="mt-5"> | ||
| 161 | <div className="flex gap-2"> | ||
| 162 | <input | ||
| 163 | type="url" | ||
| 164 | value={state.url} | ||
| 165 | onChange={(e) => dispatch({ type: "set-url", url: e.target.value })} | ||
| 166 | onKeyDown={(e) => { if (e.key === "Enter") handleDetect(); }} | ||
| 167 | placeholder="Paste a URL to auto-detect source type..." | ||
| 168 | disabled={isDetecting} | ||
| 169 | className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" | ||
| 170 | /> | ||
| 116 | <button | 171 | <button |
| 117 | key={p.key} | 172 | onClick={handleDetect} |
| 118 | onClick={() => setAdding(p.key)} | 173 | disabled={isDetecting || !state.url.trim()} |
| 119 | className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" | 174 | className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none" |
| 120 | > | 175 | > |
| 121 | <svg viewBox="0 0 16 16" fill="none" className="size-3.5"> | 176 | {isDetecting ? "Detecting..." : "Detect"} |
| 122 | <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> | ||
| 123 | </svg> | ||
| 124 | Add {p.label} | ||
| 125 | </button> | 177 | </button> |
| 126 | ))} | 178 | </div> |
| 179 | {error && ( | ||
| 180 | <p className="mt-2 text-sm text-destructive">{error}</p> | ||
| 181 | )} | ||
| 182 | <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground"> | ||
| 183 | <span>Or add manually:</span> | ||
| 184 | {sourcePlugins.map((p) => ( | ||
| 185 | <button | ||
| 186 | key={p.key} | ||
| 187 | onClick={() => dispatch({ type: "add-manual", pluginKey: p.key })} | ||
| 188 | className="rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted" | ||
| 189 | > | ||
| 190 | {p.label} | ||
| 191 | </button> | ||
| 192 | ))} | ||
| 193 | </div> | ||
| 127 | </div> | 194 | </div> |
| 128 | </div> | 195 | </div> |
| 129 | 196 | ||
| @@ -166,7 +233,7 @@ export function SourcesPage() { · 36 unmodified lines | |||
| 166 | Runtime sources exposed by the loaded executor plugins. | 233 | Runtime sources exposed by the loaded executor plugins. |
| 167 | </p> | 234 | </p> |
| 168 | </div> | 235 | </div> |
| 169 | {renderSourceGrid(builtInSources)} | 236 | <SourceGrid sources={builtInSources} /> |
| 170 | </section> | 237 | </section> |
| 171 | )} | 238 | )} |
| 172 | 239 | ||
| @@ -180,7 +247,7 @@ export function SourcesPage() { · 7 unmodified lines | |||
| 180 | User-configured sources available in this workspace. | 247 | User-configured sources available in this workspace. |
| 181 | </p> | 248 | </p> |
| 182 | </div> | 249 | </div> |
| 183 | {renderSourceGrid(connectedSources)} | 250 | <SourceGrid sources={connectedSources} /> |
| 184 | </section> | 251 | </section> |
| 185 | )} | 252 | )} |
| 186 | </div> | 253 | </div> |
| @@ -191,3 +258,53 @@ export function SourcesPage() { · 4 unmodified lines | |||
| 191 | </div> | 258 | </div> |
| 192 | ); | 259 | ); |
| 193 | } | 260 | } |
| 261 | |||
| 262 | // --------------------------------------------------------------------------- | ||
| 263 | // Source grid | ||
| 264 | // --------------------------------------------------------------------------- | ||
| 265 | |||
| 266 | function SourceGrid(props: { | ||
| 267 | sources: readonly { id: string; name: string; kind: string; runtime?: boolean }[]; | ||
| 268 | }) { | ||
| 269 | return ( | ||
| 270 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | ||
| 271 | {props.sources.map((s) => ( | ||
| 272 | <Link | ||
| 273 | key={s.id} | ||
| 274 | to="/sources/$namespace" | ||
| 275 | params={{ namespace: s.id }} | ||
| 276 | className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90" | ||
| 277 | > | ||
| 278 | <div className="flex items-start gap-3"> | ||
| 279 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | ||
| 280 | <svg viewBox="0 0 16 16" className="size-4"> | ||
| 281 | <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" /> | ||
| 282 | <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| 283 | </svg> | ||
| 284 | </div> | ||
| 285 | <div className="min-w-0 flex-1"> | ||
| 286 | <div className="flex items-start justify-between gap-2"> | ||
| 287 | <div className="truncate text-sm font-semibold text-foreground"> | ||
| 288 | {s.name} | ||
| 289 | </div> | ||
| 290 | <div className="flex shrink-0 items-center gap-1.5"> | ||
| 291 | {s.runtime && ( | ||
| 292 | <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> | ||
| 293 | built-in | ||
| 294 | </span> | ||
| 295 | )} | ||
| 296 | <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | ||
| 297 | {s.kind} | ||
| 298 | </span> | ||
| 299 | </div> | ||
| 300 | </div> | ||
| 301 | <div className="mt-0.5 text-xs text-muted-foreground"> | ||
| 302 | {s.id} | ||
| 303 | </div> | ||
| 304 | </div> | ||
| 305 | </div> | ||
| 306 | </Link> | ||
| 307 | ))} | ||
| 308 | </div> | ||
| 309 | ); | ||
| 310 | } | ||
| @@ -69,3 +69,5 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); · 68 unmodified lines | |||
| 69 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); | 69 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); |
| 70 | 70 | ||
| 71 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); | 71 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); |
| 72 | |||
| 73 | export const detectSource = ExecutorApiClient.mutation("sources", "detect"); | ||
| @@ -33,6 +33,7 @@ export { · 32 unmodified lines | |||
| 33 | removeSecret, | 33 | removeSecret, |
| 34 | removeSource, | 34 | removeSource, |
| 35 | refreshSource, | 35 | refreshSource, |
| 36 | detectSource, | ||
| 36 | } from "./atoms"; | 37 | } from "./atoms"; |
| 37 | 38 | ||
| 38 | // Provider | 39 | // Provider |
| @@ -24,10 +24,12 @@ export interface SourcePlugin { · 23 unmodified lines | |||
| 24 | * The "add source" flow. | 24 | * The "add source" flow. |
| 25 | * Plugin controls the entire experience. | 25 | * Plugin controls the entire experience. |
| 26 | * Call `onComplete` when done, `onCancel` to bail out. | 26 | * Call `onComplete` when done, `onCancel` to bail out. |
| 27 | * `initialUrl` is provided when the user arrived via URL auto-detection. | ||
| 27 | */ | 28 | */ |
| 28 | readonly add: ComponentType<{ | 29 | readonly add: ComponentType<{ |
| 29 | readonly onComplete: () => void; | 30 | readonly onComplete: () => void; |
| 30 | readonly onCancel: () => void; | 31 | readonly onCancel: () => void; |
| 32 | readonly initialUrl?: string; | ||
| 31 | }>; | 33 | }>; |
| 32 | 34 | ||
| 33 | /** | 35 | /** |
| @@ -39,6 +39,18 @@ const ToolMetadataResponse = Schema.Struct({ · 38 unmodified lines | |||
| 39 | mayElicit: Schema.optional(Schema.Boolean), | 39 | mayElicit: Schema.optional(Schema.Boolean), |
| 40 | }); | 40 | }); |
| 41 | 41 | ||
| 42 | const DetectRequest = Schema.Struct({ | ||
| 43 | url: Schema.String, | ||
| 44 | }); | ||
| 45 | |||
| 46 | const DetectResultResponse = Schema.Struct({ | ||
| 47 | kind: Schema.String, | ||
| 48 | confidence: Schema.Literal("high", "medium", "low"), | ||
| 49 | endpoint: Schema.String, | ||
| 50 | name: Schema.String, | ||
| 51 | namespace: Schema.String, | ||
| 52 | }); | ||
| 53 | |||
| 42 | // --------------------------------------------------------------------------- | 54 | // --------------------------------------------------------------------------- |
| 43 | // Group | 55 | // Group |
| 44 | // --------------------------------------------------------------------------- | 56 | // --------------------------------------------------------------------------- |
| @@ -60,4 +72,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 15 unmodified lines | |||
| 60 | HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools` | 72 | HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools` |
| 61 | .addSuccess(Schema.Array(ToolMetadataResponse)), | 73 | .addSuccess(Schema.Array(ToolMetadataResponse)), |
| 62 | ) | 74 | ) |
| 75 | .add( | ||
| 76 | HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect` | ||
| 77 | .setPayload(DetectRequest) | ||
| 78 | .addSuccess(Schema.Array(DetectResultResponse)), | ||
| 79 | ) | ||
| 63 | .prefix("/v1") {} | 80 | .prefix("/v1") {} |
| @@ -10,7 +10,7 @@ import type { · 9 unmodified lines | |||
| 10 | ToolListFilter, | 10 | ToolListFilter, |
| 11 | InvokeOptions, | 11 | InvokeOptions, |
| 12 | } from "./tools"; | 12 | } from "./tools"; |
| 13 | import type { Source, SourceRegistry } from "./sources"; | 13 | import type { Source, SourceDetectionResult, SourceRegistry } from "./sources"; |
| 14 | import type { Policy, PolicyEngine } from "./policies"; | 14 | import type { Policy, PolicyEngine } from "./policies"; |
| 15 | import type { Scope } from "./scope"; | 15 | import type { Scope } from "./scope"; |
| 16 | import type { | 16 | import type { |
| @@ -70,6 +70,7 @@ export type Executor< · 53 unmodified lines | |||
| 70 | readonly list: () => Effect.Effect<readonly Source[]>; | 70 | readonly list: () => Effect.Effect<readonly Source[]>; |
| 71 | readonly remove: (sourceId: string) => Effect.Effect<void>; | 71 | readonly remove: (sourceId: string) => Effect.Effect<void>; |
| 72 | readonly refresh: (sourceId: string) => Effect.Effect<void>; | 72 | readonly refresh: (sourceId: string) => Effect.Effect<void>; |
| 73 | readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>; | ||
| 73 | }; | 74 | }; |
| 74 | 75 | ||
| 75 | readonly policies: { | 76 | readonly policies: { |
| @@ -193,6 +194,7 @@ export const createExecutor = < · 117 unmodified lines | |||
| 193 | list: () => sources.list(), | 194 | list: () => sources.list(), |
| 194 | remove: (sourceId: string) => sources.remove(sourceId), | 195 | remove: (sourceId: string) => sources.remove(sourceId), |
| 195 | refresh: (sourceId: string) => sources.refresh(sourceId), | 196 | refresh: (sourceId: string) => sources.refresh(sourceId), |
| 197 | detect: (url: string) => sources.detect(url), | ||
| 196 | }, | 198 | }, |
| 197 | 199 | ||
| 198 | policies: { | 200 | policies: { |
| @@ -27,6 +27,7 @@ export { · 26 unmodified lines | |||
| 27 | // Sources | 27 | // Sources |
| 28 | export { | 28 | export { |
| 29 | Source, | 29 | Source, |
| 30 | SourceDetectionResult, | ||
| 30 | SourceRegistry, | 31 | SourceRegistry, |
| 31 | makeInMemorySourceRegistry, | 32 | makeInMemorySourceRegistry, |
| 32 | type SourceManager, | 33 | type SourceManager, |
| @@ -19,6 +19,23 @@ export class Source extends Schema.Class<Source>("Source")({ · 18 unmodified lines | |||
| 19 | canRefresh: Schema.optional(Schema.Boolean), | 19 | canRefresh: Schema.optional(Schema.Boolean), |
| 20 | }) {} | 20 | }) {} |
| 21 | 21 | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | // SourceDetectionResult — returned by detect() on a SourceManager | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | export class SourceDetectionResult extends Schema.Class<SourceDetectionResult>("SourceDetectionResult")({ | ||
| 27 | /** Plugin kind that detected this source */ | ||
| 28 | kind: Schema.String, | ||
| 29 | /** How confident the plugin is that the URL matches */ | ||
| 30 | confidence: Schema.Literal("high", "medium", "low"), | ||
| 31 | /** The URL that was probed */ | ||
| 32 | endpoint: Schema.String, | ||
| 33 | /** Suggested human-readable name */ | ||
| 34 | name: Schema.String, | ||
| 35 | /** Suggested namespace */ | ||
| 36 | namespace: Schema.String, | ||
| 37 | }) {} | ||
| 38 | |||
| 22 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| 23 | // SourceManager — plugin-provided source lifecycle handler | 40 | // SourceManager — plugin-provided source lifecycle handler |
| 24 | // | 41 | // |
| @@ -38,6 +55,9 @@ export interface SourceManager { · 13 unmodified lines | |||
| 38 | 55 | ||
| 39 | /** Re-fetch / re-register tools for a source */ | 56 | /** Re-fetch / re-register tools for a source */ |
| 40 | readonly refresh?: (sourceId: string) => Effect.Effect<void>; | 57 | readonly refresh?: (sourceId: string) => Effect.Effect<void>; |
| 58 | |||
| 59 | /** Detect whether a URL matches this plugin's source type */ | ||
| 60 | readonly detect?: (url: string) => Effect.Effect<SourceDetectionResult | null>; | ||
| 41 | } | 61 | } |
| 42 | 62 | ||
| 43 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| @@ -66,6 +86,9 @@ export class SourceRegistry extends Context.Tag( · 22 unmodified lines | |||
| 66 | 86 | ||
| 67 | /** Refresh a source by id. Finds the owning manager and delegates. */ | 87 | /** Refresh a source by id. Finds the owning manager and delegates. */ |
| 68 | readonly refresh: (sourceId: string) => Effect.Effect<void>; | 88 | readonly refresh: (sourceId: string) => Effect.Effect<void>; |
| 89 | |||
| 90 | /** Detect source type from a URL by probing all registered plugins */ | ||
| 91 | readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>; | ||
| 69 | } | 92 | } |
| 70 | >() {} | 93 | >() {} |
| 71 | 94 | ||
| @@ -139,5 +162,25 @@ export const makeInMemorySourceRegistry = () => { · 67 unmodified lines | |||
| 139 | } | 162 | } |
| 140 | } | 163 | } |
| 141 | }), | 164 | }), |
| 165 | |||
| 166 | detect: (url: string) => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const detectors = [...managers.values()] | ||
| 169 | .filter((m) => m.detect) | ||
| 170 | .map((m) => | ||
| 171 | m.detect!(url).pipe( | ||
| 172 | Effect.timeout("5 seconds"), | ||
| 173 | Effect.catchAll(() => Effect.succeed(null)), | ||
| 174 | ), | ||
| 175 | ); | ||
| 176 | |||
| 177 | const results = yield* Effect.all(detectors, { concurrency: "unbounded" }); | ||
| 178 | return results | ||
| 179 | .filter((r): r is SourceDetectionResult => r !== null) | ||
| 180 | .sort((a, b) => { | ||
| 181 | const order = { high: 0, medium: 1, low: 2 }; | ||
| 182 | return order[a.confidence] - order[b.confidence]; | ||
| 183 | }); | ||
| 184 | }), | ||
| 142 | }; | 185 | }; |
| 143 | }; | 186 | }; |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| @@ -263,15 +263,16 @@ function openOAuthPopup( · 258 unmodified lines | |||
| 263 | export default function AddGoogleDiscoverySource(props: { | 263 | export default function AddGoogleDiscoverySource(props: { |
| 264 | readonly onComplete: () => void; | 264 | readonly onComplete: () => void; |
| 265 | readonly onCancel: () => void; | 265 | readonly onCancel: () => void; |
| 266 | readonly initialUrl?: string; | ||
| 266 | }) { | 267 | }) { |
| 267 | const defaultTemplate = | 268 | const defaultTemplate = |
| 268 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") | 269 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") |
| 269 | ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; | 270 | ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; |
| 270 | const [discoveryUrl, setDiscoveryUrl] = useState( | 271 | const [discoveryUrl, setDiscoveryUrl] = useState( |
| 271 | defaultTemplate.discoveryUrl, | 272 | props.initialUrl ?? defaultTemplate.discoveryUrl, |
| 272 | ); | 273 | ); |
| 273 | const [name, setName] = useState(defaultTemplate.name); | 274 | const [name, setName] = useState(props.initialUrl ? "" : defaultTemplate.name); |
| 274 | const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id); | 275 | const [selectedTemplateId, setSelectedTemplateId] = useState(props.initialUrl ? "" : defaultTemplate.id); |
| 275 | const selectedTemplate = | 276 | const selectedTemplate = |
| 276 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) | 277 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) |
| 277 | ?? null; | 278 | ?? null; |
| @@ -343,6 +344,14 @@ export default function AddGoogleDiscoverySource(props: { · 65 unmodified lines | |||
| 343 | } | 344 | } |
| 344 | }, [discoveryUrl, doProbe, name]); | 345 | }, [discoveryUrl, doProbe, name]); |
| 345 | 346 | ||
| 347 | const autoProbed = useRef(false); | ||
| 348 | useEffect(() => { | ||
| 349 | if (props.initialUrl && !autoProbed.current) { | ||
| 350 | autoProbed.current = true; | ||
| 351 | handleProbe(); | ||
| 352 | } | ||
| 353 | }, []); // eslint-disable-line react-hooks/exhaustive-deps | ||
| 354 | |||
| 346 | const handleStartOAuth = useCallback(async () => { | 355 | const handleStartOAuth = useCallback(async () => { |
| 347 | if (!probe) return; | 356 | if (!probe) return; |
| 348 | setStartingOAuth(true); | 357 | setStartingOAuth(true); |
| @@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | Source, | 6 | Source, |
| 7 | SourceDetectionResult, | ||
| 7 | definePlugin, | 8 | definePlugin, |
| 8 | type ExecutorPlugin, | 9 | type ExecutorPlugin, |
| 9 | type PluginContext, | 10 | type PluginContext, |
| @@ -306,6 +307,41 @@ export const googleDiscoveryPlugin = (options?: { · 296 unmodified lines | |||
| 306 | yield* bindingStore.removeSourceMeta(sourceId); | 307 | yield* bindingStore.removeSourceMeta(sourceId); |
| 307 | yield* bindingStore.removeSourceData(sourceId); | 308 | yield* bindingStore.removeSourceData(sourceId); |
| 308 | }), | 309 | }), |
| 310 | detect: (url: string) => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const trimmed = url.trim(); | ||
| 313 | if (!trimmed) return null; | ||
| 314 | try { new URL(trimmed); } catch { return null; } | ||
| 315 | |||
| 316 | // Only probe URLs that look like Google Discovery docs | ||
| 317 | const isGoogleUrl = trimmed.includes("googleapis.com"); | ||
| 318 | const isDiscoveryPath = trimmed.includes("/discovery/") || trimmed.includes("$discovery"); | ||
| 319 | if (!isGoogleUrl && !isDiscoveryPath) return null; | ||
| 320 | |||
| 321 | const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe( | ||
| 322 | Effect.catchAll(() => Effect.succeed(null)), | ||
| 323 | ); | ||
| 324 | if (!discoveryText) return null; | ||
| 325 | |||
| 326 | const manifest = yield* extractGoogleDiscoveryManifest(discoveryText).pipe( | ||
| 327 | Effect.catchAll(() => Effect.succeed(null)), | ||
| 328 | ); | ||
| 329 | if (!manifest) return null; | ||
| 330 | |||
| 331 | const name = Option.getOrElse(manifest.title, () => `${manifest.service} ${manifest.version}`); | ||
| 332 | |||
| 333 | return new SourceDetectionResult({ | ||
| 334 | kind: "googleDiscovery", | ||
| 335 | confidence: "high", | ||
| 336 | endpoint: trimmed, | ||
| 337 | name, | ||
| 338 | namespace: deriveNamespace({ | ||
| 339 | name, | ||
| 340 | service: manifest.service, | ||
| 341 | version: manifest.version, | ||
| 342 | }), | ||
| 343 | }); | ||
| 344 | }), | ||
| 309 | refresh: (sourceId) => | 345 | refresh: (sourceId) => |
| 310 | Effect.gen(function* () { | 346 | Effect.gen(function* () { |
| 311 | const sourceData = yield* bindingStore.getSourceData(sourceId); | 347 | const sourceData = yield* bindingStore.getSourceData(sourceId); |
| @@ -157,8 +157,9 @@ function AuthHeaderRow(props: { · 156 unmodified lines | |||
| 157 | export default function AddGraphqlSource(props: { | 157 | export default function AddGraphqlSource(props: { |
| 158 | onComplete: () => void; | 158 | onComplete: () => void; |
| 159 | onCancel: () => void; | 159 | onCancel: () => void; |
| 160 | initialUrl?: string; | ||
| 160 | }) { | 161 | }) { |
| 161 | const [endpoint, setEndpoint] = useState(""); | 162 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 162 | const [namespace, setNamespace] = useState(""); | 163 | const [namespace, setNamespace] = useState(""); |
| 163 | const [authSecretId, setAuthSecretId] = useState<string | null>(null); | 164 | const [authSecretId, setAuthSecretId] = useState<string | null>(null); |
| 164 | const [adding, setAdding] = useState(false); | 165 | const [adding, setAdding] = useState(false); |
| @@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | Source, | 6 | Source, |
| 7 | SourceDetectionResult, | ||
| 7 | definePlugin, | 8 | definePlugin, |
| 8 | registerRuntimeTools, | 9 | registerRuntimeTools, |
| 9 | runtimeTool, | 10 | runtimeTool, |
| @@ -233,6 +234,30 @@ export const graphqlPlugin = (options?: { · 223 unmodified lines | |||
| 233 | yield* operationStore.removeSourceMeta(sourceId); | 234 | yield* operationStore.removeSourceMeta(sourceId); |
| 234 | yield* ctx.tools.unregisterBySource(sourceId); | 235 | yield* ctx.tools.unregisterBySource(sourceId); |
| 235 | }), | 236 | }), |
| 237 | |||
| 238 | detect: (url: string) => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const trimmed = url.trim(); | ||
| 241 | if (!trimmed) return null; | ||
| 242 | try { new URL(trimmed); } catch { return null; } | ||
| 243 | |||
| 244 | const ok = yield* introspect(trimmed).pipe( | ||
| 245 | Effect.provide(httpClientLayer), | ||
| 246 | Effect.map(() => true), | ||
| 247 | Effect.catchAll(() => Effect.succeed(false)), | ||
| 248 | ); | ||
| 249 | |||
| 250 | if (!ok) return null; | ||
| 251 | |||
| 252 | const name = namespaceFromEndpoint(trimmed); | ||
| 253 | return new SourceDetectionResult({ | ||
| 254 | kind: "graphql", | ||
| 255 | confidence: "high", | ||
| 256 | endpoint: trimmed, | ||
| 257 | name, | ||
| 258 | namespace: name, | ||
| 259 | }); | ||
| 260 | }), | ||
| 236 | }); | 261 | }); |
| 237 | 262 | ||
| 238 | const addSourceInternal = (config: GraphqlSourceConfig) => | 263 | const addSourceInternal = (config: GraphqlSourceConfig) => |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useReducer, useCallback } from "react"; | 1 | import { useReducer, useCallback, useEffect, useRef } from "react"; |
| 2 | import { useAtomSet } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { ScopeId } from "@executor/react"; | 4 | import { ScopeId } from "@executor/react"; |
| @@ -164,8 +164,12 @@ function openOAuthPopup( · 159 unmodified lines | |||
| 164 | export default function AddMcpSource(props: { | 164 | export default function AddMcpSource(props: { |
| 165 | onComplete: () => void; | 165 | onComplete: () => void; |
| 166 | onCancel: () => void; | 166 | onCancel: () => void; |
| 167 | initialUrl?: string; | ||
| 167 | }) { | 168 | }) { |
| 168 | const [state, dispatch] = useReducer(reducer, init); | 169 | const [state, dispatch] = useReducer( |
| 170 | reducer, | ||
| 171 | props.initialUrl ? { step: "url" as const, url: props.initialUrl } : init, | ||
| 172 | ); | ||
| 169 | 173 | ||
| 170 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); | 174 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); |
| 171 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); | 175 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); |
| @@ -196,6 +200,14 @@ export default function AddMcpSource(props: { · 24 unmodified lines | |||
| 196 | } | 200 | } |
| 197 | }, [state.url, doProbe]); | 201 | }, [state.url, doProbe]); |
| 198 | 202 | ||
| 203 | const autoProbed = useRef(false); | ||
| 204 | useEffect(() => { | ||
| 205 | if (props.initialUrl && !autoProbed.current) { | ||
| 206 | autoProbed.current = true; | ||
| 207 | handleProbe(); | ||
| 208 | } | ||
| 209 | }, []); // eslint-disable-line react-hooks/exhaustive-deps | ||
| 210 | |||
| 199 | const handleOAuth = useCallback(async () => { | 211 | const handleOAuth = useCallback(async () => { |
| 200 | dispatch({ type: "oauth-start" }); | 212 | dispatch({ type: "oauth-start" }); |
| 201 | try { | 213 | try { |
| @@ -4,6 +4,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 3 unmodified lines | |||
| 4 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; | 4 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 5 | import { | 5 | import { |
| 6 | Source, | 6 | Source, |
| 7 | SourceDetectionResult, | ||
| 7 | definePlugin, | 8 | definePlugin, |
| 8 | type ExecutorPlugin, | 9 | type ExecutorPlugin, |
| 9 | type PluginContext, | 10 | type PluginContext, |
| @@ -378,6 +379,60 @@ export const mcpPlugin = (options?: { · 368 unmodified lines | |||
| 378 | addedSources.delete(sourceId); | 379 | addedSources.delete(sourceId); |
| 379 | }).pipe(Effect.catchAll(() => Effect.void)), | 380 | }).pipe(Effect.catchAll(() => Effect.void)), |
| 380 | 381 | ||
| 382 | detect: (url: string) => | ||
| 383 | Effect.gen(function* () { | ||
| 384 | const trimmed = url.trim(); | ||
| 385 | if (!trimmed) return null; | ||
| 386 | try { new URL(trimmed); } catch { return null; } | ||
| 387 | |||
| 388 | const name = (() => { | ||
| 389 | try { return new URL(trimmed).hostname; } catch { return "mcp"; } | ||
| 390 | })(); | ||
| 391 | const namespace = deriveMcpNamespace({ endpoint: trimmed }); | ||
| 392 | |||
| 393 | const connector = createMcpConnector({ | ||
| 394 | transport: "remote", | ||
| 395 | endpoint: trimmed, | ||
| 396 | }); | ||
| 397 | |||
| 398 | const connected = yield* discoverTools(connector).pipe( | ||
| 399 | Effect.map(() => true), | ||
| 400 | Effect.catchAll(() => Effect.succeed(false)), | ||
| 401 | ); | ||
| 402 | |||
| 403 | if (connected) { | ||
| 404 | return new SourceDetectionResult({ | ||
| 405 | kind: "mcp", | ||
| 406 | confidence: "high", | ||
| 407 | endpoint: trimmed, | ||
| 408 | name, | ||
| 409 | namespace, | ||
| 410 | }); | ||
| 411 | } | ||
| 412 | |||
| 413 | // Probe for OAuth — still means it's an MCP server | ||
| 414 | const hasOAuth = yield* startMcpOAuthAuthorization({ | ||
| 415 | endpoint: trimmed, | ||
| 416 | redirectUrl: "http://127.0.0.1/executor/discovery/oauth/probe", | ||
| 417 | state: "probe", | ||
| 418 | }).pipe( | ||
| 419 | Effect.map(() => true), | ||
| 420 | Effect.catchAll(() => Effect.succeed(false)), | ||
| 421 | ); | ||
| 422 | |||
| 423 | if (hasOAuth) { | ||
| 424 | return new SourceDetectionResult({ | ||
| 425 | kind: "mcp", | ||
| 426 | confidence: "high", | ||
| 427 | endpoint: trimmed, | ||
| 428 | name, | ||
| 429 | namespace, | ||
| 430 | }); | ||
| 431 | } | ||
| 432 | |||
| 433 | return null; | ||
| 434 | }), | ||
| 435 | |||
| 381 | refresh: (sourceId: string) => | 436 | refresh: (sourceId: string) => |
| 382 | Effect.gen(function* () { | 437 | Effect.gen(function* () { |
| 383 | const sd = yield* bindingStore.getSourceData(sourceId); | 438 | const sd = yield* bindingStore.getSourceData(sourceId); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState, useEffect, useRef } from "react"; |
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 3 | import { Option } from "effect"; | 3 | import { Option } from "effect"; |
| 4 | 4 | ||
| @@ -413,9 +413,10 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 408 unmodified lines | |||
| 413 | export default function AddOpenApiSource(props: { | 413 | export default function AddOpenApiSource(props: { |
| 414 | onComplete: () => void; | 414 | onComplete: () => void; |
| 415 | onCancel: () => void; | 415 | onCancel: () => void; |
| 416 | initialUrl?: string; | ||
| 416 | }) { | 417 | }) { |
| 417 | // Spec input | 418 | // Spec input |
| 418 | const [specUrl, setSpecUrl] = useState(""); | 419 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); |
| 419 | const [analyzing, setAnalyzing] = useState(false); | 420 | const [analyzing, setAnalyzing] = useState(false); |
| 420 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); | 421 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 421 | 422 | ||
| @@ -435,6 +436,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines | |||
| 435 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); | 436 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); |
| 436 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); | 437 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 437 | const secrets = useAtomValue(secretsAtom()); | 438 | const secrets = useAtomValue(secretsAtom()); |
| 439 | const autoAnalyzed = useRef(false); | ||
| 440 | |||
| 441 | useEffect(() => { | ||
| 442 | if (props.initialUrl && !autoAnalyzed.current) { | ||
| 443 | autoAnalyzed.current = true; | ||
| 444 | handleAnalyze(); | ||
| 445 | } | ||
| 446 | }, []); // eslint-disable-line react-hooks/exhaustive-deps | ||
| 438 | 447 | ||
| 439 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { | 448 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { |
| 440 | onInitial: () => [] as SecretPickerSecret[], | 449 | onInitial: () => [] as SecretPickerSecret[], |
| @@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | Source, | 6 | Source, |
| 7 | SourceDetectionResult, | ||
| 7 | definePlugin, | 8 | definePlugin, |
| 8 | registerRuntimeTools, | 9 | registerRuntimeTools, |
| 9 | runtimeTool, | 10 | runtimeTool, |
| @@ -201,7 +202,37 @@ export const openApiPlugin = (options?: { · 191 unmodified lines | |||
| 201 | yield* ctx.tools.unregisterBySource(sourceId); | 202 | yield* ctx.tools.unregisterBySource(sourceId); |
| 202 | }), | 203 | }), |
| 203 | 204 | ||
| 204 | // TODO: refresh requires storing original config per namespace | 205 | detect: (url: string) => |
| 206 | Effect.gen(function* () { | ||
| 207 | const trimmed = url.trim(); | ||
| 208 | if (!trimmed) return null; | ||
| 209 | try { new URL(trimmed); } catch { return null; } | ||
| 210 | |||
| 211 | // Try fetching the URL and parsing as OpenAPI spec | ||
| 212 | // parse() handles both URLs directly and spec text | ||
| 213 | const doc = yield* parse(trimmed).pipe( | ||
| 214 | Effect.catchAll(() => Effect.succeed(null)), | ||
| 215 | ); | ||
| 216 | if (!doc) return null; | ||
| 217 | |||
| 218 | const result = yield* extract(doc).pipe( | ||
| 219 | Effect.catchAll(() => Effect.succeed(null)), | ||
| 220 | ); | ||
| 221 | if (!result) return null; | ||
| 222 | |||
| 223 | const namespace = Option.getOrElse(result.title, () => "api") | ||
| 224 | .toLowerCase() | ||
| 225 | .replace(/[^a-z0-9]+/g, "_"); | ||
| 226 | const name = Option.getOrElse(result.title, () => namespace); | ||
| 227 | |||
| 228 | return new SourceDetectionResult({ | ||
| 229 | kind: "openapi", | ||
| 230 | confidence: "high", | ||
| 231 | endpoint: trimmed, | ||
| 232 | name, | ||
| 233 | namespace, | ||
| 234 | }); | ||
| 235 | }), | ||
| 205 | }); | 236 | }); |
| 206 | 237 | ||
| 207 | const addSpecInternal = (config: OpenApiSpecConfig) => | 238 | const addSpecInternal = (config: OpenApiSpecConfig) => |