Bladeren bron

feat(api): add command authentication attempts

Dax Raad 1 maand geleden
bovenliggende
commit
b5177adb5b

+ 39 - 0
packages/client/src/effect/api/api.ts

@@ -466,6 +466,40 @@ export type IntegrationOauthCancelOperation<E = never> = (
   input: Endpoint10_6Input,
 ) => Effect.Effect<Endpoint10_6Output, E>
 
+type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
+export type Endpoint10_7Input = {
+  readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
+  readonly location?: Endpoint10_7Request["query"]["location"]
+  readonly methodID: Endpoint10_7Request["payload"]["methodID"]
+  readonly label?: Endpoint10_7Request["payload"]["label"]
+}
+export type Endpoint10_7Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.connect"]>>
+export type IntegrationCommandConnectOperation<E = never> = (
+  input: Endpoint10_7Input,
+) => Effect.Effect<Endpoint10_7Output, E>
+
+type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
+export type Endpoint10_8Input = {
+  readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
+  readonly attemptID: Endpoint10_8Request["params"]["attemptID"]
+  readonly location?: Endpoint10_8Request["query"]["location"]
+}
+export type Endpoint10_8Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.status"]>>
+export type IntegrationCommandStatusOperation<E = never> = (
+  input: Endpoint10_8Input,
+) => Effect.Effect<Endpoint10_8Output, E>
+
+type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
+export type Endpoint10_9Input = {
+  readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
+  readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
+  readonly location?: Endpoint10_9Request["query"]["location"]
+}
+export type Endpoint10_9Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.cancel"]>>
+export type IntegrationCommandCancelOperation<E = never> = (
+  input: Endpoint10_9Input,
+) => Effect.Effect<Endpoint10_9Output, E>
+
 export interface IntegrationApi<E = never> {
   readonly list: IntegrationListOperation<E>
   readonly get: IntegrationGetOperation<E>
@@ -476,6 +510,11 @@ export interface IntegrationApi<E = never> {
     readonly complete: IntegrationOauthCompleteOperation<E>
     readonly cancel: IntegrationOauthCancelOperation<E>
   }
+  readonly command: {
+    readonly connect: IntegrationCommandConnectOperation<E>
+    readonly status: IntegrationCommandStatusOperation<E>
+    readonly cancel: IntegrationCommandCancelOperation<E>
+  }
 }
 
 type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]

+ 39 - 0
packages/client/src/effect/generated/client.ts

@@ -571,6 +571,44 @@ const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint1
     query: { location: input["location"] },
   }).pipe(Effect.mapError(mapClientError))
 
+type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
+type Endpoint10_7Input = {
+  readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
+  readonly location?: Endpoint10_7Request["query"]["location"]
+  readonly methodID: Endpoint10_7Request["payload"]["methodID"]
+  readonly label?: Endpoint10_7Request["payload"]["label"]
+}
+const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) =>
+  raw["integration.command.connect"]({
+    params: { integrationID: input["integrationID"] },
+    query: { location: input["location"] },
+    payload: { methodID: input["methodID"], label: input["label"] },
+  }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
+type Endpoint10_8Input = {
+  readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
+  readonly attemptID: Endpoint10_8Request["params"]["attemptID"]
+  readonly location?: Endpoint10_8Request["query"]["location"]
+}
+const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) =>
+  raw["integration.command.status"]({
+    params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
+    query: { location: input["location"] },
+  }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
+type Endpoint10_9Input = {
+  readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
+  readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
+  readonly location?: Endpoint10_9Request["query"]["location"]
+}
+const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) =>
+  raw["integration.command.cancel"]({
+    params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
+    query: { location: input["location"] },
+  }).pipe(Effect.mapError(mapClientError))
+
 const adaptGroup10 = (raw: RawClient["server.integration"]) => ({
   list: Endpoint10_0(raw),
   get: Endpoint10_1(raw),
@@ -581,6 +619,7 @@ const adaptGroup10 = (raw: RawClient["server.integration"]) => ({
     complete: Endpoint10_5(raw),
     cancel: Endpoint10_6(raw),
   },
+  command: { connect: Endpoint10_7(raw), status: Endpoint10_8(raw), cancel: Endpoint10_9(raw) },
 })
 
 type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]

+ 45 - 0
packages/client/src/promise/generated/client.ts

@@ -92,6 +92,12 @@ import type {
   IntegrationOauthCompleteOutput,
   IntegrationOauthCancelInput,
   IntegrationOauthCancelOutput,
+  IntegrationCommandConnectInput,
+  IntegrationCommandConnectOutput,
+  IntegrationCommandStatusInput,
+  IntegrationCommandStatusOutput,
+  IntegrationCommandCancelInput,
+  IntegrationCommandCancelOutput,
   McpListInput,
   McpListOutput,
   McpResourceCatalogInput,
@@ -954,6 +960,45 @@ export function make(options: ClientOptions) {
             requestOptions,
           ),
       },
+      command: {
+        connect: (input: IntegrationCommandConnectInput, requestOptions?: RequestOptions) =>
+          request<IntegrationCommandConnectOutput>(
+            {
+              method: "POST",
+              path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command`,
+              query: { location: input["location"] },
+              body: { methodID: input["methodID"], label: input["label"] },
+              successStatus: 200,
+              declaredStatuses: [400, 401],
+              empty: false,
+            },
+            requestOptions,
+          ),
+        status: (input: IntegrationCommandStatusInput, requestOptions?: RequestOptions) =>
+          request<IntegrationCommandStatusOutput>(
+            {
+              method: "GET",
+              path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
+              query: { location: input["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+        cancel: (input: IntegrationCommandCancelInput, requestOptions?: RequestOptions) =>
+          request<IntegrationCommandCancelOutput>(
+            {
+              method: "DELETE",
+              path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
+              query: { location: input["location"] },
+              successStatus: 204,
+              declaredStatuses: [401, 400],
+              empty: true,
+            },
+            requestOptions,
+          ),
+      },
     },
     mcp: {
       list: (input?: McpListInput, requestOptions?: RequestOptions) =>

+ 69 - 1
packages/client/src/promise/generated/types.ts

@@ -187,6 +187,8 @@ export type ProviderV2Info = {
 
 export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
 
+export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
+
 export type IntegrationKeyMethod = { type: "key"; label?: string }
 
 export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
@@ -214,6 +216,31 @@ export type IntegrationAttemptStatus =
       time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
     }
 
+export type IntegrationCommandAttempt = {
+  attemptID: string
+  time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
+}
+
+export type IntegrationCommandAttemptStatus =
+  | {
+      status: "pending"
+      message?: string
+      time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
+    }
+  | {
+      status: "complete"
+      time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
+    }
+  | {
+      status: "failed"
+      message: string
+      time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
+    }
+  | {
+      status: "expired"
+      time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
+    }
+
 export type McpStatusConnected = { status: "connected" }
 
 export type McpStatusPending = { status: "pending" }
@@ -1992,7 +2019,11 @@ export type SessionMessageAssistantTool = {
   time: { created: number; ran?: number; completed?: number }
 }
 
-export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
 
 export type FormFields = [FormField, ...Array<FormField>]
 
@@ -3351,6 +3382,43 @@ export type IntegrationOauthCancelInput = {
 
 export type IntegrationOauthCancelOutput = void
 
+export type IntegrationCommandConnectInput = {
+  readonly integrationID: { readonly integrationID: string }["integrationID"]
+  readonly location?: {
+    readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+  }["location"]
+  readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
+  readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
+}
+
+export type IntegrationCommandConnectOutput = {
+  location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+  data: IntegrationCommandAttempt
+}
+
+export type IntegrationCommandStatusInput = {
+  readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
+  readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
+  readonly location?: {
+    readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+  }["location"]
+}
+
+export type IntegrationCommandStatusOutput = {
+  location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+  data: IntegrationCommandAttemptStatus
+}
+
+export type IntegrationCommandCancelInput = {
+  readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
+  readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
+  readonly location?: {
+    readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+  }["location"]
+}
+
+export type IntegrationCommandCancelOutput = void
+
 export type McpListInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

+ 2 - 1
packages/client/test/promise.test.ts

@@ -36,9 +36,10 @@ test("exposes every standard HTTP API group", () => {
   expect(Object.keys(client.debug)).toEqual(["location"])
   expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
   expect(Object.keys(client.message)).toEqual(["list"])
-  expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth"])
+  expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth", "command"])
   expect(Object.keys(client.integration.connect)).toEqual(["key"])
   expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
+  expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
   expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
   expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
   expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])

+ 232 - 6
packages/core/src/integration.ts

@@ -12,6 +12,7 @@ import {
   Schedule,
   Schema,
   Scope,
+  Stream,
   SynchronizedRef,
   Types,
 } from "effect"
@@ -20,6 +21,8 @@ import { Credential } from "./credential"
 import { State } from "./state"
 import { EventV2 } from "./event"
 import { IntegrationConnection } from "./integration/connection"
+import { AppProcess } from "./process"
+import { ChildProcess } from "effect/unstable/process"
 
 export const ID = Integration.ID
 export type ID = Integration.ID
@@ -45,6 +48,9 @@ export type Prompt = Integration.Prompt
 export const OAuthMethod = Integration.OAuthMethod
 export type OAuthMethod = Integration.OAuthMethod
 
+export const CommandMethod = Integration.CommandMethod
+export type CommandMethod = Integration.CommandMethod
+
 export const KeyMethod = Integration.KeyMethod
 export type KeyMethod = Integration.KeyMethod
 
@@ -87,12 +93,17 @@ export interface KeyImplementation {
   readonly method: KeyMethod
 }
 
+export interface CommandImplementation {
+  readonly integrationID: ID
+  readonly method: CommandMethod
+}
+
 export interface EnvImplementation {
   readonly integrationID: ID
   readonly method: EnvMethod
 }
 
-export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
+export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation
 
 export const Attempt = Integration.Attempt
 export type Attempt = Integration.Attempt
@@ -100,6 +111,12 @@ export type Attempt = Integration.Attempt
 export const AttemptStatus = Integration.AttemptStatus
 export type AttemptStatus = typeof AttemptStatus.Type
 
+export const CommandAttempt = Integration.CommandAttempt
+export type CommandAttempt = Integration.CommandAttempt
+
+export const CommandAttemptStatus = Integration.CommandAttemptStatus
+export type CommandAttemptStatus = Integration.CommandAttemptStatus
+
 export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
   attemptID: AttemptID,
 }) {}
@@ -189,6 +206,18 @@ export interface Interface extends State.Transformable<Draft> {
     /** Cancels an attempt and releases its resources. */
     readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
   }
+  readonly command: {
+    readonly connect: (input: {
+      readonly integrationID: ID
+      readonly methodID: MethodID
+      readonly label?: string
+    }) => Effect.Effect<CommandAttempt, AuthorizationError>
+    readonly status: (input: {
+      readonly integrationID: ID
+      readonly attemptID: AttemptID
+    }) => Effect.Effect<CommandAttemptStatus>
+    readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
+  }
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
@@ -218,13 +247,33 @@ type TerminalAttempt = {
 }
 type AttemptEntry = PendingAttempt | TerminalAttempt
 
+type PendingCommandAttempt = {
+  status: "pending"
+  integrationID: ID
+  label?: string
+  message?: string
+  persisting: boolean
+  scope: Scope.Closeable
+  time: AttemptTime
+}
+type TerminalCommandAttempt = {
+  status: "complete" | "failed" | "expired"
+  integrationID: ID
+  message?: string
+  removeAt: number
+  time: AttemptTime
+}
+type CommandAttemptEntry = PendingCommandAttempt | TerminalCommandAttempt
+
 const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const credentials = yield* Credential.Service
     const events = yield* EventV2.Service
+    const processes = yield* AppProcess.Service
     const scope = yield* Scope.Scope
     const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
+    const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
     const state = State.create<Data, Draft>({
       name: "integration",
       initial: () => ({ integrations: new Map<ID, Entry>() }),
@@ -258,8 +307,11 @@ const layer = Layer.effect(
             }
             const index = current.methods.findIndex((method) => {
               if (method.type !== implementation.method.type) return false
-              if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
-              return method.id === implementation.method.id
+              if (method.type === "oauth" && implementation.method.type === "oauth")
+                return method.id === implementation.method.id
+              if (method.type === "command" && implementation.method.type === "command")
+                return method.id === implementation.method.id
+              return true
             })
             if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
             else current.methods[index] = implementation.method as Types.DeepMutable<Method>
@@ -275,8 +327,9 @@ const layer = Layer.effect(
             if (!current) return
             const index = current.methods.findIndex((candidate) => {
               if (candidate.type !== method.type) return false
-              if (candidate.type !== "oauth" || method.type !== "oauth") return true
-              return candidate.id === method.id
+              if (candidate.type === "oauth" && method.type === "oauth") return candidate.id === method.id
+              if (candidate.type === "command" && method.type === "command") return candidate.id === method.id
+              return true
             })
             if (index !== -1) current.methods.splice(index, 1)
             if (method.type === "oauth") current.implementations.delete(method.id)
@@ -385,6 +438,61 @@ const layer = Layer.effect(
       )
     })
 
+    const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
+      return yield* Effect.uninterruptible(
+        Effect.gen(function* () {
+          const now = yield* Clock.currentTimeMillis
+          const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
+            const match = current.get(attemptID)
+            if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
+            const next = Exit.isSuccess(exit)
+              ? { ...match, persisting: true }
+              : {
+                  status: "failed" as const,
+                  integrationID: match.integrationID,
+                  message: message(exit.cause),
+                  time: match.time,
+                  removeAt: now + terminalRetention,
+                }
+            return [match, new Map(current).set(attemptID, next)]
+          })
+          if (!attempt) return
+          if (Exit.isFailure(exit)) {
+            yield* close(attempt.scope)
+            return
+          }
+
+          const persistence = yield* credentials
+            .create({
+              integrationID: attempt.integrationID,
+              label: attempt.label,
+              value: Credential.Key.make({ type: "key", key: exit.value }),
+            })
+            .pipe(Effect.asVoid, Effect.exit)
+          const settledAt = yield* Clock.currentTimeMillis
+          const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
+            ? {
+                status: "complete",
+                integrationID: attempt.integrationID,
+                time: attempt.time,
+                removeAt: settledAt + terminalRetention,
+              }
+            : {
+                status: "failed",
+                integrationID: attempt.integrationID,
+                message: message(persistence.cause),
+                time: attempt.time,
+                removeAt: settledAt + terminalRetention,
+              }
+          yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
+          yield* close(attempt.scope)
+          if (Exit.isFailure(persistence)) return
+          yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID })
+          yield* events.publish(Event.Updated, {})
+        }),
+      )
+    })
+
     const scrub = Effect.fnUntraced(function* () {
       const now = yield* Clock.currentTimeMillis
       const expired = yield* SynchronizedRef.modify(attempts, (current) => {
@@ -408,7 +516,31 @@ const layer = Layer.effect(
       yield* Effect.forEach(expired, close, { discard: true })
     })
 
+    const scrubCommands = Effect.fnUntraced(function* () {
+      const now = yield* Clock.currentTimeMillis
+      const expired = yield* SynchronizedRef.modify(commandAttempts, (current) => {
+        const next = new Map(current)
+        const scopes: Scope.Closeable[] = []
+        for (const [id, attempt] of current) {
+          if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) {
+            scopes.push(attempt.scope)
+            next.set(id, {
+              status: "expired",
+              integrationID: attempt.integrationID,
+              time: attempt.time,
+              removeAt: now + terminalRetention,
+            })
+            continue
+          }
+          if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id)
+        }
+        return [scopes, next]
+      })
+      yield* Effect.forEach(expired, close, { discard: true })
+    })
+
     yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
+    yield* scrubCommands().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
 
     const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
       readonly integrationID: ID
@@ -457,6 +589,66 @@ const layer = Layer.effect(
       })
     })
 
+    const connectCommand = Effect.fn("Integration.command.connect")(function* (input: {
+      readonly integrationID: ID
+      readonly methodID: MethodID
+      readonly label?: string
+    }) {
+      const method = state
+        .get()
+        .integrations.get(input.integrationID)
+        ?.methods.find((method) => method.type === "command" && method.id === input.methodID)
+      if (!method || method.type !== "command" || !method.command[0]) {
+        return yield* Effect.die(new Error(`Command method not found: ${input.integrationID}/${input.methodID}`))
+      }
+
+      const attemptScope = yield* Scope.fork(scope)
+      const attemptID = AttemptID.create()
+      const created = yield* Clock.currentTimeMillis
+      const time = { created, expires: created + attemptLifetime }
+      yield* SynchronizedRef.update(commandAttempts, (current) =>
+        new Map(current).set(attemptID, {
+          status: "pending",
+          integrationID: input.integrationID,
+          label: input.label,
+          persisting: false,
+          scope: attemptScope,
+          time,
+        }),
+      )
+
+      yield* processes
+        .runStream(
+          ChildProcess.make(method.command[0], method.command.slice(1), {
+            extendEnv: true,
+            stdin: "ignore",
+          }),
+          { okExitCodes: [0] },
+        )
+        .pipe(
+          Stream.tap((line) =>
+            SynchronizedRef.update(commandAttempts, (current) => {
+              const attempt = current.get(attemptID)
+              if (!attempt || attempt.status !== "pending") return current
+              const message = attempt.message ? `${attempt.message}\n${line}` : line
+              return new Map(current).set(attemptID, { ...attempt, message })
+            }),
+          ),
+          Stream.runCollect,
+          Effect.flatMap((lines) => {
+            const credential = Array.from(lines).at(-1)
+            return credential
+              ? Effect.succeed(credential)
+              : Effect.fail(new Error("Authentication command returned no credential"))
+          }),
+          Effect.exit,
+          Effect.flatMap((exit) => settleCommand(attemptID, exit)),
+          Effect.forkIn(attemptScope, { startImmediately: true }),
+        )
+
+      return CommandAttempt.make({ attemptID, time })
+    })
+
     return Service.of({
       transform: state.transform,
       reload: state.reload,
@@ -572,8 +764,42 @@ const layer = Layer.effect(
           if (attempt) yield* Scope.close(attempt.scope, Exit.void)
         }),
       },
+      command: {
+        connect: connectCommand,
+        status: Effect.fn("Integration.command.status")(function* (input) {
+          const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
+          if (!attempt || attempt.integrationID !== input.integrationID)
+            return yield* Effect.die(new Error(`Command attempt not found: ${input.attemptID}`))
+          if (attempt.status === "pending") {
+            return {
+              status: attempt.status,
+              ...(attempt.message ? { message: attempt.message } : {}),
+              time: attempt.time,
+            }
+          }
+          if (attempt.status === "failed") {
+            return { status: attempt.status, message: attempt.message ?? "Authentication failed", time: attempt.time }
+          }
+          return { status: attempt.status, time: attempt.time }
+        }),
+        cancel: Effect.fn("Integration.command.cancel")(function* (input) {
+          const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
+            const match = current.get(input.attemptID)
+            if (!match || match.integrationID !== input.integrationID || match.status !== "pending" || match.persisting)
+              return [undefined, current]
+            const next = new Map(current)
+            next.delete(input.attemptID)
+            return [match, next]
+          })
+          if (attempt) yield* Scope.close(attempt.scope, Exit.void)
+        }),
+      },
     })
   }),
 )
 
-export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
+export const node = makeLocationNode({
+  service: Service,
+  layer,
+  deps: [Credential.node, EventV2.node, AppProcess.node],
+})

+ 29 - 0
packages/core/src/plugin/host.ts

@@ -203,6 +203,28 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
             attemptID: Integration.AttemptID.make(input.attemptID),
           }),
       },
+      command: {
+        connect: (input) =>
+          response(
+            integration.command.connect({
+              integrationID: Integration.ID.make(input.integrationID),
+              methodID: Integration.MethodID.make(input.methodID),
+              label: input.label,
+            }),
+          ),
+        status: (input) =>
+          response(
+            integration.command.status({
+              integrationID: Integration.ID.make(input.integrationID),
+              attemptID: Integration.AttemptID.make(input.attemptID),
+            }),
+          ),
+        cancel: (input) =>
+          integration.command.cancel({
+            integrationID: Integration.ID.make(input.integrationID),
+            attemptID: Integration.AttemptID.make(input.attemptID),
+          }),
+      },
       reload: integration.reload,
       connection: {
         active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -281,6 +303,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
                   })
                   return
                 }
+                if (input.method.type === "command") {
+                  draft.method.update({
+                    integrationID: Integration.ID.make(input.integrationID),
+                    method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
+                  })
+                  return
+                }
                 draft.method.update({
                   integrationID: Integration.ID.make(input.integrationID),
                   method: { type: "key", label: input.method.label },

+ 26 - 0
packages/core/src/plugin/promise.ts

@@ -135,6 +135,32 @@ export function fromPromise(plugin: Plugin) {
                   }),
                 ),
             },
+            command: {
+              connect: (input) =>
+                run(
+                  host.integration.command.connect({
+                    ...input,
+                    integrationID: Integration.ID.make(input.integrationID),
+                    methodID: Integration.MethodID.make(input.methodID),
+                  }),
+                ),
+              status: (input) =>
+                run(
+                  host.integration.command.status({
+                    ...input,
+                    integrationID: Integration.ID.make(input.integrationID),
+                    attemptID: Integration.AttemptID.make(input.attemptID),
+                  }),
+                ),
+              cancel: (input) =>
+                run(
+                  host.integration.command.cancel({
+                    ...input,
+                    integrationID: Integration.ID.make(input.integrationID),
+                    attemptID: Integration.AttemptID.make(input.attemptID),
+                  }),
+                ),
+            },
             transform: transform(host.integration),
             reload: () => run(host.integration.reload()),
             connection: {

+ 59 - 0
packages/core/test/integration.test.ts

@@ -29,6 +29,20 @@ const failingIt = testEffect(
   AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
 )
 
+function eventually<A, E, R>(
+  effect: Effect.Effect<A, E, R>,
+  predicate: (value: A) => boolean,
+  remaining = 1000,
+): Effect.Effect<A, E | Error, R> {
+  return Effect.gen(function* () {
+    const value = yield* effect
+    if (predicate(value)) return value
+    if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
+    yield* Effect.promise(() => Bun.sleep(1))
+    return yield* eventually(effect, predicate, remaining - 1)
+  })
+}
+
 describe("Integration", () => {
   it.effect("registers integrations through the editor", () =>
     Effect.gen(function* () {
@@ -151,6 +165,51 @@ describe("Integration", () => {
     }),
   )
 
+  it.live("runs command authentication and stores the final output line", () =>
+    Effect.gen(function* () {
+      const integrations = yield* Integration.Service
+      const credentials = yield* Credential.Service
+      const integrationID = Integration.ID.make("company")
+      const methodID = Integration.MethodID.make("login")
+      yield* integrations.transform((editor) =>
+        editor.method.update({
+          integrationID,
+          method: {
+            id: methodID,
+            type: "command",
+            label: "Log in",
+            command: [
+              process.execPath,
+              "-e",
+              'console.log("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
+            ],
+          },
+        }),
+      )
+
+      const attempt = yield* integrations.command.connect({ integrationID, methodID, label: "Work" })
+      const pending = yield* eventually(
+        integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
+        (status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
+      )
+      expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login" })
+
+      expect(
+        yield* eventually(
+          integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
+          (status) => status.status === "complete",
+        ),
+      ).toEqual({ status: "complete", time: attempt.time })
+      expect(yield* credentials.list(integrationID)).toEqual([
+        expect.objectContaining({
+          integrationID,
+          label: "Work",
+          value: Credential.Key.make({ type: "key", key: "secret" }),
+        }),
+      ])
+    }),
+  )
+
   it.effect("completes code OAuth once and stores the credential", () =>
     Effect.gen(function* () {
       const integrations = yield* Integration.Service

+ 5 - 0
packages/core/test/mcp.test.ts

@@ -196,6 +196,11 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
             complete: unusedIntegration,
             cancel: unusedIntegration,
           },
+          command: {
+            connect: unusedIntegration,
+            status: unusedIntegration,
+            cancel: unusedIntegration,
+          },
         }),
         Layer.mock(Credential.Service, {}),
       ),

+ 36 - 2
packages/core/test/plugin/host.ts

@@ -5,7 +5,12 @@ import { Credential } from "@opencode-ai/core/credential"
 import { Integration } from "@opencode-ai/core/integration"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
-import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
+import type {
+  IntegrationCommandMethod,
+  IntegrationEnvMethod,
+  IntegrationKeyMethod,
+  IntegrationOAuthMethod,
+} from "@opencode-ai/sdk/v2/types"
 import { Effect, Stream } from "effect"
 
 type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
@@ -55,6 +60,11 @@ export function host(overrides: Overrides = {}): PluginContext {
         complete: () => Effect.die("unused integration.oauth.complete"),
         cancel: () => Effect.die("unused integration.oauth.cancel"),
       },
+      command: {
+        connect: () => Effect.die("unused integration.command.connect"),
+        status: () => Effect.die("unused integration.command.status"),
+        cancel: () => Effect.die("unused integration.command.cancel"),
+      },
       transform: () => Effect.die("unused integration.transform"),
       reload: () => Effect.die("unused integration.reload"),
       connection: {
@@ -200,6 +210,11 @@ export function integrationHost(integration: Integration.Interface): PluginConte
       complete: () => Effect.die("unused integration.oauth.complete"),
       cancel: () => Effect.die("unused integration.oauth.cancel"),
     },
+    command: {
+      connect: () => Effect.die("unused integration.command.connect"),
+      status: () => Effect.die("unused integration.command.status"),
+      cancel: () => Effect.die("unused integration.command.cancel"),
+    },
     reload: integration.reload,
     connection: {
       active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -281,6 +296,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte
                 })
                 return
               }
+              if (input.method.type === "command") {
+                draft.method.update({
+                  integrationID: Integration.ID.make(input.integrationID),
+                  method: {
+                    ...input.method,
+                    id: Integration.MethodID.make(input.method.id),
+                    command: [...input.method.command],
+                  },
+                })
+                return
+              }
               draft.method.update({
                 integrationID: Integration.ID.make(input.integrationID),
                 method: input.method,
@@ -296,6 +322,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
 function method(value: Integration.Method) {
   if (value.type === "env") return { type: value.type, names: [...value.names] }
   if (value.type === "key") return { type: value.type, label: value.label }
+  if (value.type === "command") return { ...value, command: [...value.command] }
   return {
     type: value.type,
     id: value.id,
@@ -308,10 +335,17 @@ function method(value: Integration.Method) {
 }
 
 function internalMethod(
-  value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod,
+  value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod,
 ): Integration.Method {
   if (value.type === "env") return value
   if (value.type === "key") return value
+  if (value.type === "command") {
+    return {
+      ...value,
+      id: Integration.MethodID.make(value.id),
+      command: [...value.command],
+    }
+  }
   return {
     ...value,
     id: Integration.MethodID.make(value.id),

+ 5 - 0
packages/plugin/src/v2/effect/integration.ts

@@ -2,6 +2,7 @@ import type {
   ConnectionInfo,
   CredentialOAuth,
   CredentialValue,
+  IntegrationCommandMethod,
   IntegrationEnvMethod,
   IntegrationInputs,
   IntegrationKeyMethod,
@@ -35,6 +36,10 @@ export type IntegrationOAuthMethodRegistration = {
 }
 export type IntegrationMethodRegistration =
   | IntegrationOAuthMethodRegistration
+  | {
+      readonly integrationID: string
+      readonly method: IntegrationCommandMethod
+    }
   | {
       readonly integrationID: string
       readonly method: IntegrationKeyMethod

+ 50 - 0
packages/protocol/src/groups/integration.ts

@@ -129,6 +129,56 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
         }),
       ),
   )
+  .add(
+    HttpApiEndpoint.post("integration.command.connect", "/api/integration/:integrationID/connect/command", {
+      params: { integrationID: Integration.ID },
+      query: LocationQuery,
+      payload: Schema.Struct({
+        methodID: Integration.MethodID,
+        label: Schema.optional(Schema.String),
+      }),
+      success: Location.response(Integration.CommandAttempt),
+      error: InvalidRequestError,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.integration.command.connect",
+          summary: "Begin command connection",
+          description: "Start a command authentication attempt.",
+        }),
+      ),
+  )
+  .add(
+    HttpApiEndpoint.get("integration.command.status", "/api/integration/:integrationID/connect/command/:attemptID", {
+      params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
+      query: LocationQuery,
+      success: Location.response(Integration.CommandAttemptStatus),
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.integration.command.status",
+          summary: "Get command attempt status",
+          description: "Poll the current status and output of a command authentication attempt.",
+        }),
+      ),
+  )
+  .add(
+    HttpApiEndpoint.delete("integration.command.cancel", "/api/integration/:integrationID/connect/command/:attemptID", {
+      params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
+      query: LocationQuery,
+      success: HttpApiSchema.NoContent,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.integration.command.cancel",
+          summary: "Cancel command connection",
+          description: "Cancel a command authentication attempt and terminate its process.",
+        }),
+      ),
+  )
   .annotateMerge(
     OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }),
   )

+ 25 - 1
packages/schema/src/integration.ts

@@ -56,6 +56,14 @@ export const OAuthMethod = Schema.Struct({
   prompts: optional(Schema.Array(Prompt)),
 }).annotate({ identifier: "Integration.OAuthMethod" })
 
+export interface CommandMethod extends Schema.Schema.Type<typeof CommandMethod> {}
+export const CommandMethod = Schema.Struct({
+  id: MethodID,
+  type: Schema.Literal("command"),
+  label: Schema.String,
+  command: Schema.Array(Schema.String),
+}).annotate({ identifier: "Integration.CommandMethod" })
+
 export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
 export const KeyMethod = Schema.Struct({
   type: Schema.Literal("key"),
@@ -68,7 +76,7 @@ export const EnvMethod = Schema.Struct({
   names: Schema.Array(Schema.String),
 }).annotate({ identifier: "Integration.EnvMethod" })
 
-export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod])
+export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod])
   .pipe(Schema.toTaggedUnion("type"))
   .annotate({ identifier: "Integration.Method" })
 export type Method = typeof Method.Type
@@ -128,3 +136,19 @@ export const AttemptStatus = Schema.Union([
   .pipe(Schema.toTaggedUnion("status"))
   .annotate({ identifier: "Integration.AttemptStatus" })
 export type AttemptStatus = typeof AttemptStatus.Type
+
+export interface CommandAttempt extends Schema.Schema.Type<typeof CommandAttempt> {}
+export const CommandAttempt = Schema.Struct({
+  attemptID: AttemptID,
+  time: AttemptTime,
+}).annotate({ identifier: "Integration.CommandAttempt" })
+
+export const CommandAttemptStatus = Schema.Union([
+  Schema.Struct({ status: Schema.Literal("pending"), message: optional(Schema.String), time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }),
+])
+  .pipe(Schema.toTaggedUnion("status"))
+  .annotate({ identifier: "Integration.CommandAttemptStatus" })
+export type CommandAttemptStatus = typeof CommandAttemptStatus.Type

+ 141 - 4
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -294,6 +294,12 @@ import type {
   V2HealthGetResponses,
   V2HealthStopErrors,
   V2HealthStopResponses,
+  V2IntegrationCommandCancelErrors,
+  V2IntegrationCommandCancelResponses,
+  V2IntegrationCommandConnectErrors,
+  V2IntegrationCommandConnectResponses,
+  V2IntegrationCommandStatusErrors,
+  V2IntegrationCommandStatusResponses,
   V2IntegrationConnectKeyErrors,
   V2IntegrationConnectKeyResponses,
   V2IntegrationGetErrors,
@@ -7005,6 +7011,132 @@ export class Oauth2 extends HeyApiClient {
   }
 }
 
+export class Command2 extends HeyApiClient {
+  /**
+   * Begin command connection
+   *
+   * Start a command authentication attempt.
+   */
+  public connect<ThrowOnError extends boolean = false>(
+    parameters: {
+      integrationID: string
+      location?: {
+        directory?: string | null
+        workspace?: string | null
+      } | null
+      methodID?: string
+      label?: string | null
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "integrationID" },
+            { in: "query", key: "location" },
+            { in: "body", key: "methodID" },
+            { in: "body", key: "label" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      V2IntegrationCommandConnectResponses,
+      V2IntegrationCommandConnectErrors,
+      ThrowOnError
+    >({
+      url: "/api/integration/{integrationID}/connect/command",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * Cancel command connection
+   *
+   * Cancel a command authentication attempt and terminate its process.
+   */
+  public cancel<ThrowOnError extends boolean = false>(
+    parameters: {
+      integrationID: string
+      attemptID: string
+      location?: {
+        directory?: string | null
+        workspace?: string | null
+      } | null
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "integrationID" },
+            { in: "path", key: "attemptID" },
+            { in: "query", key: "location" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).delete<
+      V2IntegrationCommandCancelResponses,
+      V2IntegrationCommandCancelErrors,
+      ThrowOnError
+    >({
+      url: "/api/integration/{integrationID}/connect/command/{attemptID}",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Get command attempt status
+   *
+   * Poll the current status and output of a command authentication attempt.
+   */
+  public status<ThrowOnError extends boolean = false>(
+    parameters: {
+      integrationID: string
+      attemptID: string
+      location?: {
+        directory?: string | null
+        workspace?: string | null
+      } | null
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "integrationID" },
+            { in: "path", key: "attemptID" },
+            { in: "query", key: "location" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<
+      V2IntegrationCommandStatusResponses,
+      V2IntegrationCommandStatusErrors,
+      ThrowOnError
+    >({
+      url: "/api/integration/{integrationID}/connect/command/{attemptID}",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Integration extends HeyApiClient {
   /**
    * List integrations
@@ -7070,6 +7202,11 @@ export class Integration extends HeyApiClient {
   get oauth(): Oauth2 {
     return (this._oauth ??= new Oauth2({ client: this.client }))
   }
+
+  private _command?: Command2
+  get command(): Command2 {
+    return (this._command ??= new Command2({ client: this.client }))
+  }
 }
 
 export class Resource2 extends HeyApiClient {
@@ -7492,7 +7629,7 @@ export class Fs extends HeyApiClient {
   }
 }
 
-export class Command2 extends HeyApiClient {
+export class Command3 extends HeyApiClient {
   /**
    * List commands
    *
@@ -8393,9 +8530,9 @@ export class V2 extends HeyApiClient {
     return (this._fs ??= new Fs({ client: this.client }))
   }
 
-  private _command?: Command2
-  get command(): Command2 {
-    return (this._command ??= new Command2({ client: this.client }))
+  private _command?: Command3
+  get command(): Command3 {
+    return (this._command ??= new Command3({ client: this.client }))
   }
 
   private _skill?: Skill

+ 175 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3195,7 +3195,11 @@ export type IntegrationInputs = {
   [key: string]: string
 }
 
-export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
 
 export type IntegrationRef = {
   id: string
@@ -5708,6 +5712,13 @@ export type IntegrationOAuthMethod = {
   prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
 }
 
+export type IntegrationCommandMethod = {
+  id: string
+  type: "command"
+  label: string
+  command: Array<string>
+}
+
 export type IntegrationKeyMethod = {
   type: "key"
   label?: string
@@ -5780,6 +5791,46 @@ export type IntegrationAttemptStatus =
       }
     }
 
+export type IntegrationCommandAttempt = {
+  attemptID: string
+  time: {
+    created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+    expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+  }
+}
+
+export type IntegrationCommandAttemptStatus =
+  | {
+      status: "pending"
+      message?: string
+      time: {
+        created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+        expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+      }
+    }
+  | {
+      status: "complete"
+      time: {
+        created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+        expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+      }
+    }
+  | {
+      status: "failed"
+      message: string
+      time: {
+        created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+        expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+      }
+    }
+  | {
+      status: "expired"
+      time: {
+        created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+        expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+      }
+    }
+
 export type McpStatusConnected2 = {
   status: "connected"
 }
@@ -17011,6 +17062,129 @@ export type V2IntegrationOauthCompleteResponses = {
 export type V2IntegrationOauthCompleteResponse =
   V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses]
 
+export type V2IntegrationCommandConnectData = {
+  body: {
+    methodID: string
+    label?: string | null
+  }
+  path: {
+    integrationID: string
+  }
+  query?: {
+    location?: {
+      directory?: string | null
+      workspace?: string | null
+    } | null
+  }
+  url: "/api/integration/{integrationID}/connect/command"
+}
+
+export type V2IntegrationCommandConnectErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError1 | InvalidRequestErrorV2
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2IntegrationCommandConnectError =
+  V2IntegrationCommandConnectErrors[keyof V2IntegrationCommandConnectErrors]
+
+export type V2IntegrationCommandConnectResponses = {
+  /**
+   * Success
+   */
+  200: {
+    location: LocationInfoV2
+    data: IntegrationCommandAttempt
+  }
+}
+
+export type V2IntegrationCommandConnectResponse =
+  V2IntegrationCommandConnectResponses[keyof V2IntegrationCommandConnectResponses]
+
+export type V2IntegrationCommandCancelData = {
+  body?: never
+  path: {
+    integrationID: string
+    attemptID: string
+  }
+  query?: {
+    location?: {
+      directory?: string | null
+      workspace?: string | null
+    } | null
+  }
+  url: "/api/integration/{integrationID}/connect/command/{attemptID}"
+}
+
+export type V2IntegrationCommandCancelErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestErrorV2
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2IntegrationCommandCancelError = V2IntegrationCommandCancelErrors[keyof V2IntegrationCommandCancelErrors]
+
+export type V2IntegrationCommandCancelResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2IntegrationCommandCancelResponse =
+  V2IntegrationCommandCancelResponses[keyof V2IntegrationCommandCancelResponses]
+
+export type V2IntegrationCommandStatusData = {
+  body?: never
+  path: {
+    integrationID: string
+    attemptID: string
+  }
+  query?: {
+    location?: {
+      directory?: string | null
+      workspace?: string | null
+    } | null
+  }
+  url: "/api/integration/{integrationID}/connect/command/{attemptID}"
+}
+
+export type V2IntegrationCommandStatusErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestErrorV2
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2IntegrationCommandStatusError = V2IntegrationCommandStatusErrors[keyof V2IntegrationCommandStatusErrors]
+
+export type V2IntegrationCommandStatusResponses = {
+  /**
+   * Success
+   */
+  200: {
+    location: LocationInfoV2
+    data: IntegrationCommandAttemptStatus
+  }
+}
+
+export type V2IntegrationCommandStatusResponse =
+  V2IntegrationCommandStatusResponses[keyof V2IntegrationCommandStatusResponses]
+
 export type V2McpListData = {
   body?: never
   path?: never

+ 38 - 0
packages/server/src/handlers/integration.ts

@@ -114,5 +114,43 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
           return HttpApiSchema.NoContent.make()
         }),
       )
+      .handle(
+        "integration.command.connect",
+        Effect.fn(function* (ctx) {
+          const service = yield* Integration.Service
+          return yield* response(
+            authorize(
+              service.command.connect({
+                integrationID: ctx.params.integrationID,
+                methodID: ctx.payload.methodID,
+                label: ctx.payload.label,
+              }),
+            ),
+          )
+        }),
+      )
+      .handle(
+        "integration.command.status",
+        Effect.fn(function* (ctx) {
+          const service = yield* Integration.Service
+          return yield* response(
+            service.command.status({
+              integrationID: ctx.params.integrationID,
+              attemptID: ctx.params.attemptID,
+            }),
+          )
+        }),
+      )
+      .handle(
+        "integration.command.cancel",
+        Effect.fn(function* (ctx) {
+          const service = yield* Integration.Service
+          yield* service.command.cancel({
+            integrationID: ctx.params.integrationID,
+            attemptID: ctx.params.attemptID,
+          })
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
   }),
 )

+ 123 - 0
packages/tui/src/component/dialog-integration.tsx

@@ -1,6 +1,7 @@
 import { TextAttributes } from "@opentui/core"
 import type {
   ConnectionInfo,
+  IntegrationCommandConnectOutput,
   IntegrationInfo,
   IntegrationOauthConnectOutput,
   IntegrationOAuthMethod,
@@ -28,6 +29,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
 
 type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
 type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
+type CommandAttempt = IntegrationCommandConnectOutput["data"]
 type OnIntegrationConnected = (providerID?: string) => void
 
 export function integrationOptions(list: IntegrationInfo[]) {
@@ -167,9 +169,130 @@ function openMethod(
     dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
     return
   }
+  if (method.type === "command") {
+    dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
+    return
+  }
   void beginOAuth(integration, method, dialog, onConnected)
 }
 
+function CommandStarting(props: {
+  integration: IntegrationInfo
+  method: Extract<ConnectMethod, { type: "command" }>
+  onConnected?: OnIntegrationConnected
+}) {
+  const data = useData()
+  const dialog = useDialog()
+  const client = useClient()
+  const toast = useToast()
+
+  onMount(() => {
+    void client.api.integration.command
+      .connect({
+        integrationID: props.integration.id,
+        methodID: props.method.id,
+        location: location(data),
+      })
+      .then((result) =>
+        dialog.replace(() => (
+          <CommandPending
+            integration={props.integration}
+            title={props.method.label}
+            attempt={result.data}
+            onConnected={props.onConnected}
+          />
+        )),
+      )
+      .catch((cause) => {
+        toast.show({ variant: "error", message: message(cause) })
+        dialog.clear()
+      })
+  })
+
+  return <CommandView title={props.method.label} output="" message="Starting command..." />
+}
+
+function CommandPending(props: {
+  integration: IntegrationInfo
+  title: string
+  attempt: CommandAttempt
+  onConnected?: OnIntegrationConnected
+}) {
+  const data = useData()
+  const dialog = useDialog()
+  const client = useClient()
+  const toast = useToast()
+  const [output, setOutput] = createSignal("")
+  let timer: ReturnType<typeof setTimeout> | undefined
+  let settled = false
+
+  const poll = () => {
+    void client.api.integration.command
+      .status({
+        integrationID: props.integration.id,
+        attemptID: props.attempt.attemptID,
+        location: location(data),
+      })
+      .then((result) => {
+        const status = result.data
+        if (status.status === "pending") {
+          setOutput(status.message ?? "")
+          timer = setTimeout(poll, 500)
+          return
+        }
+        settled = true
+        if (status.status === "complete") {
+          void connected(props.integration, data, dialog, toast, props.onConnected)
+          return
+        }
+        toast.show({
+          variant: "error",
+          message: status.status === "failed" ? status.message : "Authentication expired",
+        })
+        dialog.clear()
+      })
+      .catch((cause) => {
+        settled = true
+        toast.show({ variant: "error", message: message(cause) })
+        dialog.clear()
+      })
+  }
+
+  onMount(poll)
+  onCleanup(() => {
+    if (timer) clearTimeout(timer)
+    if (settled) return
+    void client.api.integration.command.cancel({
+      integrationID: props.integration.id,
+      attemptID: props.attempt.attemptID,
+      location: location(data),
+    })
+  })
+
+  return <CommandView title={props.title} output={output()} message="Waiting for command to finish..." />
+}
+
+function CommandView(props: { title: string; output: string; message: string }) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          {props.title}
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc close
+        </text>
+      </box>
+      <box backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
+        <text fg={theme.text}>{props.output}</text>
+      </box>
+      <text fg={theme.textMuted}>{props.message}</text>
+    </box>
+  )
+}
+
 function KeyMethod(props: {
   integration: IntegrationInfo
   method: Extract<ConnectMethod, { type: "key" }>