chore: generate
This commit is contained in:
@@ -67,169 +67,166 @@ const AgentCreateCommand = effectCmd({
|
|||||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||||
const ctx = maybeCtx
|
const ctx = maybeCtx
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
const cliPath = args.path
|
const cliPath = args.path
|
||||||
const cliDescription = args.description
|
const cliDescription = args.description
|
||||||
const cliMode = args.mode as AgentMode | undefined
|
const cliMode = args.mode as AgentMode | undefined
|
||||||
const perms = args.permissions
|
const perms = args.permissions
|
||||||
|
|
||||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||||
|
|
||||||
if (!isFullyNonInteractive) {
|
if (!isFullyNonInteractive) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Create agent")
|
prompts.intro("Create agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = ctx.project
|
const project = ctx.project
|
||||||
|
|
||||||
// Determine scope/path
|
// Determine scope/path
|
||||||
let targetPath: string
|
let targetPath: string
|
||||||
if (cliPath) {
|
if (cliPath) {
|
||||||
targetPath = path.join(cliPath, "agent")
|
targetPath = path.join(cliPath, "agent")
|
||||||
} else {
|
} else {
|
||||||
let scope: "global" | "project" = "global"
|
let scope: "global" | "project" = "global"
|
||||||
if (project.vcs === "git") {
|
if (project.vcs === "git") {
|
||||||
const scopeResult = await prompts.select({
|
const scopeResult = await prompts.select({
|
||||||
message: "Location",
|
message: "Location",
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: "Current project",
|
|
||||||
value: "project" as const,
|
|
||||||
hint: ctx.worktree,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Global",
|
|
||||||
value: "global" as const,
|
|
||||||
hint: Global.Path.config,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
|
||||||
scope = scopeResult
|
|
||||||
}
|
|
||||||
targetPath = path.join(
|
|
||||||
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
|
|
||||||
"agent",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get description
|
|
||||||
let description: string
|
|
||||||
if (cliDescription) {
|
|
||||||
description = cliDescription
|
|
||||||
} else {
|
|
||||||
const query = await prompts.text({
|
|
||||||
message: "Description",
|
|
||||||
placeholder: "What should this agent do?",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
|
||||||
description = query
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate agent
|
|
||||||
const spinner = prompts.spinner()
|
|
||||||
spinner.start("Generating agent configuration...")
|
|
||||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
|
||||||
const generated = await AppRuntime.runPromise(
|
|
||||||
Agent.Service.use((svc) => svc.generate({ description, model })),
|
|
||||||
).catch((error) => {
|
|
||||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
|
||||||
if (isFullyNonInteractive) process.exit(1)
|
|
||||||
throw new UI.CancelledError()
|
|
||||||
})
|
|
||||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
|
||||||
|
|
||||||
// Select permissions to allow
|
|
||||||
let selected: string[]
|
|
||||||
if (perms !== undefined) {
|
|
||||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
|
||||||
} else {
|
|
||||||
const result = await prompts.multiselect({
|
|
||||||
message: "Select permissions to allow (Space to toggle)",
|
|
||||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
|
||||||
label: permission,
|
|
||||||
value: permission,
|
|
||||||
})),
|
|
||||||
initialValues: AVAILABLE_PERMISSIONS,
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
|
||||||
selected = result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get mode
|
|
||||||
let mode: AgentMode
|
|
||||||
if (cliMode) {
|
|
||||||
mode = cliMode
|
|
||||||
} else {
|
|
||||||
const modeResult = await prompts.select({
|
|
||||||
message: "Agent mode",
|
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: "All",
|
label: "Current project",
|
||||||
value: "all" as const,
|
value: "project" as const,
|
||||||
hint: "Can function in both primary and subagent roles",
|
hint: ctx.worktree,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Primary",
|
label: "Global",
|
||||||
value: "primary" as const,
|
value: "global" as const,
|
||||||
hint: "Acts as a primary/main agent",
|
hint: Global.Path.config,
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Subagent",
|
|
||||||
value: "subagent" as const,
|
|
||||||
hint: "Can be used as a subagent by other agents",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
initialValue: "all" as const,
|
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||||
mode = modeResult
|
scope = scopeResult
|
||||||
}
|
}
|
||||||
|
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
|
||||||
|
}
|
||||||
|
|
||||||
// Build permissions config — deny anything not explicitly selected.
|
// Get description
|
||||||
const permissions: Record<string, "deny"> = {}
|
let description: string
|
||||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
if (cliDescription) {
|
||||||
if (!selected.includes(permission)) {
|
description = cliDescription
|
||||||
permissions[permission] = "deny"
|
} else {
|
||||||
}
|
const query = await prompts.text({
|
||||||
|
message: "Description",
|
||||||
|
placeholder: "What should this agent do?",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||||
|
description = query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate agent
|
||||||
|
const spinner = prompts.spinner()
|
||||||
|
spinner.start("Generating agent configuration...")
|
||||||
|
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||||
|
const generated = await AppRuntime.runPromise(
|
||||||
|
Agent.Service.use((svc) => svc.generate({ description, model })),
|
||||||
|
).catch((error) => {
|
||||||
|
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||||
|
if (isFullyNonInteractive) process.exit(1)
|
||||||
|
throw new UI.CancelledError()
|
||||||
|
})
|
||||||
|
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||||
|
|
||||||
|
// Select permissions to allow
|
||||||
|
let selected: string[]
|
||||||
|
if (perms !== undefined) {
|
||||||
|
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||||
|
} else {
|
||||||
|
const result = await prompts.multiselect({
|
||||||
|
message: "Select permissions to allow (Space to toggle)",
|
||||||
|
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||||
|
label: permission,
|
||||||
|
value: permission,
|
||||||
|
})),
|
||||||
|
initialValues: AVAILABLE_PERMISSIONS,
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||||
|
selected = result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mode
|
||||||
|
let mode: AgentMode
|
||||||
|
if (cliMode) {
|
||||||
|
mode = cliMode
|
||||||
|
} else {
|
||||||
|
const modeResult = await prompts.select({
|
||||||
|
message: "Agent mode",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "All",
|
||||||
|
value: "all" as const,
|
||||||
|
hint: "Can function in both primary and subagent roles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Primary",
|
||||||
|
value: "primary" as const,
|
||||||
|
hint: "Acts as a primary/main agent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subagent",
|
||||||
|
value: "subagent" as const,
|
||||||
|
hint: "Can be used as a subagent by other agents",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "all" as const,
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||||
|
mode = modeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build permissions config — deny anything not explicitly selected.
|
||||||
|
const permissions: Record<string, "deny"> = {}
|
||||||
|
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||||
|
if (!selected.includes(permission)) {
|
||||||
|
permissions[permission] = "deny"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build frontmatter
|
// Build frontmatter
|
||||||
const frontmatter: {
|
const frontmatter: {
|
||||||
description: string
|
description: string
|
||||||
mode: AgentMode
|
mode: AgentMode
|
||||||
permission?: Record<string, "deny">
|
permission?: Record<string, "deny">
|
||||||
} = {
|
} = {
|
||||||
description: generated.whenToUse,
|
description: generated.whenToUse,
|
||||||
mode,
|
mode,
|
||||||
}
|
}
|
||||||
if (Object.keys(permissions).length > 0) {
|
if (Object.keys(permissions).length > 0) {
|
||||||
frontmatter.permission = permissions
|
frontmatter.permission = permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file
|
// Write file
|
||||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||||
|
|
||||||
await fs.mkdir(targetPath, { recursive: true })
|
await fs.mkdir(targetPath, { recursive: true })
|
||||||
|
|
||||||
if (await Filesystem.exists(filePath)) {
|
|
||||||
if (isFullyNonInteractive) {
|
|
||||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
|
||||||
throw new UI.CancelledError()
|
|
||||||
}
|
|
||||||
|
|
||||||
await Filesystem.write(filePath, content)
|
|
||||||
|
|
||||||
|
if (await Filesystem.exists(filePath)) {
|
||||||
if (isFullyNonInteractive) {
|
if (isFullyNonInteractive) {
|
||||||
console.log(filePath)
|
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||||
} else {
|
process.exit(1)
|
||||||
prompts.log.success(`Agent created: ${filePath}`)
|
|
||||||
prompts.outro("Done")
|
|
||||||
}
|
}
|
||||||
|
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||||
|
throw new UI.CancelledError()
|
||||||
|
}
|
||||||
|
|
||||||
|
await Filesystem.write(filePath, content)
|
||||||
|
|
||||||
|
if (isFullyNonInteractive) {
|
||||||
|
console.log(filePath)
|
||||||
|
} else {
|
||||||
|
prompts.log.success(`Agent created: ${filePath}`)
|
||||||
|
prompts.outro("Done")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -440,158 +440,158 @@ export const McpAddCommand = effectCmd({
|
|||||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||||
const ctx = maybeCtx
|
const ctx = maybeCtx
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add MCP server")
|
prompts.intro("Add MCP server")
|
||||||
|
|
||||||
const project = ctx.project
|
const project = ctx.project
|
||||||
|
|
||||||
// Resolve config paths eagerly for hints
|
// Resolve config paths eagerly for hints
|
||||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||||
resolveConfigPath(ctx.worktree),
|
resolveConfigPath(ctx.worktree),
|
||||||
resolveConfigPath(Global.Path.config, true),
|
resolveConfigPath(Global.Path.config, true),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Determine scope
|
// Determine scope
|
||||||
let configPath = globalConfigPath
|
let configPath = globalConfigPath
|
||||||
if (project.vcs === "git") {
|
if (project.vcs === "git") {
|
||||||
const scopeResult = await prompts.select({
|
const scopeResult = await prompts.select({
|
||||||
message: "Location",
|
message: "Location",
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: "Current project",
|
|
||||||
value: projectConfigPath,
|
|
||||||
hint: projectConfigPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Global",
|
|
||||||
value: globalConfigPath,
|
|
||||||
hint: globalConfigPath,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
|
||||||
configPath = scopeResult
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = await prompts.text({
|
|
||||||
message: "Enter MCP server name",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
const type = await prompts.select({
|
|
||||||
message: "Select MCP server type",
|
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: "Local",
|
label: "Current project",
|
||||||
value: "local",
|
value: projectConfigPath,
|
||||||
hint: "Run a local command",
|
hint: projectConfigPath,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Remote",
|
label: "Global",
|
||||||
value: "remote",
|
value: globalConfigPath,
|
||||||
hint: "Connect to a remote URL",
|
hint: globalConfigPath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||||
|
configPath = scopeResult
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "local") {
|
const name = await prompts.text({
|
||||||
const command = await prompts.text({
|
message: "Enter MCP server name",
|
||||||
message: "Enter command to run",
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
})
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||||
})
|
|
||||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
const mcpConfig: ConfigMCP.Info = {
|
const type = await prompts.select({
|
||||||
type: "local",
|
message: "Select MCP server type",
|
||||||
command: command.split(" "),
|
options: [
|
||||||
}
|
{
|
||||||
|
label: "Local",
|
||||||
|
value: "local",
|
||||||
|
hint: "Run a local command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remote",
|
||||||
|
value: "remote",
|
||||||
|
hint: "Connect to a remote URL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||||
|
|
||||||
await addMcpToConfig(name, mcpConfig, configPath)
|
if (type === "local") {
|
||||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
const command = await prompts.text({
|
||||||
prompts.outro("MCP server added successfully")
|
message: "Enter command to run",
|
||||||
return
|
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||||
|
|
||||||
|
const mcpConfig: ConfigMCP.Info = {
|
||||||
|
type: "local",
|
||||||
|
command: command.split(" "),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "remote") {
|
await addMcpToConfig(name, mcpConfig, configPath)
|
||||||
const url = await prompts.text({
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||||
message: "Enter MCP server URL",
|
prompts.outro("MCP server added successfully")
|
||||||
placeholder: "e.g., https://example.com/mcp",
|
return
|
||||||
validate: (x) => {
|
}
|
||||||
if (!x) return "Required"
|
|
||||||
if (x.length === 0) return "Required"
|
|
||||||
const isValid = URL.canParse(x)
|
|
||||||
return isValid ? undefined : "Invalid URL"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
const useOAuth = await prompts.confirm({
|
if (type === "remote") {
|
||||||
message: "Does this server require OAuth authentication?",
|
const url = await prompts.text({
|
||||||
|
message: "Enter MCP server URL",
|
||||||
|
placeholder: "e.g., https://example.com/mcp",
|
||||||
|
validate: (x) => {
|
||||||
|
if (!x) return "Required"
|
||||||
|
if (x.length === 0) return "Required"
|
||||||
|
const isValid = URL.canParse(x)
|
||||||
|
return isValid ? undefined : "Invalid URL"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||||
|
|
||||||
|
const useOAuth = await prompts.confirm({
|
||||||
|
message: "Does this server require OAuth authentication?",
|
||||||
|
initialValue: false,
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||||
|
|
||||||
|
let mcpConfig: ConfigMCP.Info
|
||||||
|
|
||||||
|
if (useOAuth) {
|
||||||
|
const hasClientId = await prompts.confirm({
|
||||||
|
message: "Do you have a pre-registered client ID?",
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||||
|
|
||||||
let mcpConfig: ConfigMCP.Info
|
if (hasClientId) {
|
||||||
|
const clientId = await prompts.text({
|
||||||
|
message: "Enter client ID",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||||
|
|
||||||
if (useOAuth) {
|
const hasSecret = await prompts.confirm({
|
||||||
const hasClientId = await prompts.confirm({
|
message: "Do you have a client secret?",
|
||||||
message: "Do you have a pre-registered client ID?",
|
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||||
|
|
||||||
if (hasClientId) {
|
let clientSecret: string | undefined
|
||||||
const clientId = await prompts.text({
|
if (hasSecret) {
|
||||||
message: "Enter client ID",
|
const secret = await prompts.password({
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
message: "Enter client secret",
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||||
|
clientSecret = secret
|
||||||
|
}
|
||||||
|
|
||||||
const hasSecret = await prompts.confirm({
|
mcpConfig = {
|
||||||
message: "Do you have a client secret?",
|
type: "remote",
|
||||||
initialValue: false,
|
url,
|
||||||
})
|
oauth: {
|
||||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
clientId,
|
||||||
|
...(clientSecret && { clientSecret }),
|
||||||
let clientSecret: string | undefined
|
},
|
||||||
if (hasSecret) {
|
|
||||||
const secret = await prompts.password({
|
|
||||||
message: "Enter client secret",
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
|
||||||
clientSecret = secret
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpConfig = {
|
|
||||||
type: "remote",
|
|
||||||
url,
|
|
||||||
oauth: {
|
|
||||||
clientId,
|
|
||||||
...(clientSecret && { clientSecret }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mcpConfig = {
|
|
||||||
type: "remote",
|
|
||||||
url,
|
|
||||||
oauth: {},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mcpConfig = {
|
mcpConfig = {
|
||||||
type: "remote",
|
type: "remote",
|
||||||
url,
|
url,
|
||||||
|
oauth: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
await addMcpToConfig(name, mcpConfig, configPath)
|
mcpConfig = {
|
||||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
type: "remote",
|
||||||
|
url,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro("MCP server added successfully")
|
await addMcpToConfig(name, mcpConfig, configPath)
|
||||||
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro("MCP server added successfully")
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -607,177 +607,177 @@ export const McpDebugCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("MCP OAuth Debug")
|
prompts.intro("MCP OAuth Debug")
|
||||||
|
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
const mcpServers = config.mcp ?? {}
|
const mcpServers = config.mcp ?? {}
|
||||||
const serverName = args.name
|
const serverName = args.name
|
||||||
|
|
||||||
const serverConfig = mcpServers[serverName]
|
const serverConfig = mcpServers[serverName]
|
||||||
if (!serverConfig) {
|
if (!serverConfig) {
|
||||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMcpRemote(serverConfig)) {
|
if (!isMcpRemote(serverConfig)) {
|
||||||
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverConfig.oauth === false) {
|
if (serverConfig.oauth === false) {
|
||||||
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.log.info(`Server: ${serverName}`)
|
prompts.log.info(`Server: ${serverName}`)
|
||||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||||
|
|
||||||
// Check stored auth status
|
// Check stored auth status
|
||||||
const { authStatus, entry } = await AppRuntime.runPromise(
|
const { authStatus, entry } = await AppRuntime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const auth = yield* McpAuth.Service
|
const auth = yield* McpAuth.Service
|
||||||
return {
|
return {
|
||||||
authStatus: yield* mcp.getAuthStatus(serverName),
|
authStatus: yield* mcp.getAuthStatus(serverName),
|
||||||
entry: yield* auth.get(serverName),
|
entry: yield* auth.get(serverName),
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
|
||||||
|
|
||||||
if (entry?.tokens) {
|
|
||||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
|
||||||
if (entry.tokens.expiresAt) {
|
|
||||||
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
|
||||||
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
|
||||||
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
|
||||||
}
|
}
|
||||||
if (entry.tokens.refreshToken) {
|
}),
|
||||||
prompts.log.info(` Refresh token: present`)
|
)
|
||||||
}
|
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||||
}
|
|
||||||
if (entry?.clientInfo) {
|
|
||||||
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
|
||||||
if (entry.clientInfo.clientSecretExpiresAt) {
|
|
||||||
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
|
||||||
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const spinner = prompts.spinner()
|
if (entry?.tokens) {
|
||||||
spinner.start("Testing connection...")
|
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||||
|
if (entry.tokens.expiresAt) {
|
||||||
|
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
||||||
|
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
||||||
|
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
||||||
|
}
|
||||||
|
if (entry.tokens.refreshToken) {
|
||||||
|
prompts.log.info(` Refresh token: present`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry?.clientInfo) {
|
||||||
|
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
||||||
|
if (entry.clientInfo.clientSecretExpiresAt) {
|
||||||
|
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
||||||
|
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test basic HTTP connectivity first
|
const spinner = prompts.spinner()
|
||||||
try {
|
spinner.start("Testing connection...")
|
||||||
const response = await fetch(serverConfig.url, {
|
|
||||||
method: "POST",
|
// Test basic HTTP connectivity first
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json",
|
const response = await fetch(serverConfig.url, {
|
||||||
Accept: "application/json, text/event-stream",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
id: 1,
|
||||||
jsonrpc: "2.0",
|
}),
|
||||||
method: "initialize",
|
})
|
||||||
params: {
|
|
||||||
protocolVersion: "2024-11-05",
|
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
// Check for WWW-Authenticate header
|
||||||
},
|
const wwwAuth = response.headers.get("www-authenticate")
|
||||||
id: 1,
|
if (wwwAuth) {
|
||||||
|
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
prompts.log.warn("Server returned 401 Unauthorized")
|
||||||
|
|
||||||
|
// Try to discover OAuth metadata
|
||||||
|
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||||
|
const auth = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return yield* McpAuth.Service
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
const authProvider = new McpOAuthProvider(
|
||||||
|
serverName,
|
||||||
|
serverConfig.url,
|
||||||
|
{
|
||||||
|
clientId: oauthConfig?.clientId,
|
||||||
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRedirect: async () => {},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||||
|
|
||||||
|
// Try creating transport with auth provider to trigger discovery
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||||
|
authProvider,
|
||||||
})
|
})
|
||||||
|
|
||||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
try {
|
||||||
|
const client = new Client({
|
||||||
// Check for WWW-Authenticate header
|
name: "opencode-debug",
|
||||||
const wwwAuth = response.headers.get("www-authenticate")
|
version: InstallationVersion,
|
||||||
if (wwwAuth) {
|
|
||||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
prompts.log.warn("Server returned 401 Unauthorized")
|
|
||||||
|
|
||||||
// Try to discover OAuth metadata
|
|
||||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
|
||||||
const auth = await AppRuntime.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
return yield* McpAuth.Service
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const authProvider = new McpOAuthProvider(
|
|
||||||
serverName,
|
|
||||||
serverConfig.url,
|
|
||||||
{
|
|
||||||
clientId: oauthConfig?.clientId,
|
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
|
||||||
scope: oauthConfig?.scope,
|
|
||||||
redirectUri: oauthConfig?.redirectUri,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onRedirect: async () => {},
|
|
||||||
},
|
|
||||||
auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
|
||||||
|
|
||||||
// Try creating transport with auth provider to trigger discovery
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
|
||||||
authProvider,
|
|
||||||
})
|
})
|
||||||
|
await client.connect(transport)
|
||||||
|
prompts.log.success("Connection successful (already authenticated)")
|
||||||
|
await client.close()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UnauthorizedError) {
|
||||||
|
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||||
|
|
||||||
try {
|
// Check if dynamic registration would be attempted
|
||||||
const client = new Client({
|
const clientInfo = await authProvider.clientInformation()
|
||||||
name: "opencode-debug",
|
if (clientInfo) {
|
||||||
version: InstallationVersion,
|
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||||
})
|
|
||||||
await client.connect(transport)
|
|
||||||
prompts.log.success("Connection successful (already authenticated)")
|
|
||||||
await client.close()
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedError) {
|
|
||||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
|
||||||
|
|
||||||
// Check if dynamic registration would be attempted
|
|
||||||
const clientInfo = await authProvider.clientInformation()
|
|
||||||
if (clientInfo) {
|
|
||||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
|
||||||
} else {
|
|
||||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else if (response.status >= 200 && response.status < 300) {
|
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
|
||||||
const body = await response.text()
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(body)
|
|
||||||
if (json.result?.serverInfo) {
|
|
||||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not JSON, ignore
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
|
||||||
const body = await response.text().catch(() => "")
|
|
||||||
if (body) {
|
|
||||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else if (response.status >= 200 && response.status < 300) {
|
||||||
spinner.stop("Connection failed", 1)
|
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
const body = await response.text()
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(body)
|
||||||
|
if (json.result?.serverInfo) {
|
||||||
|
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||||
|
const body = await response.text().catch(() => "")
|
||||||
|
if (body) {
|
||||||
|
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
spinner.stop("Connection failed", 1)
|
||||||
|
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
prompts.outro("Debug complete")
|
prompts.outro("Debug complete")
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -240,49 +240,49 @@ export const ProvidersListCommand = effectCmd({
|
|||||||
instance: false,
|
instance: false,
|
||||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const authPath = path.join(Global.Path.data, "auth.json")
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
const homedir = os.homedir()
|
const homedir = os.homedir()
|
||||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||||
const results = await AppRuntime.runPromise(
|
const results = await AppRuntime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
return Object.entries(yield* auth.all())
|
return Object.entries(yield* auth.all())
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const database = await getModels()
|
const database = await getModels()
|
||||||
|
|
||||||
for (const [providerID, result] of results) {
|
for (const [providerID, result] of results) {
|
||||||
const name = database[providerID]?.name || providerID
|
const name = database[providerID]?.name || providerID
|
||||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${results.length} credentials`)
|
prompts.outro(`${results.length} credentials`)
|
||||||
|
|
||||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||||
|
|
||||||
for (const [providerID, provider] of Object.entries(database)) {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
for (const envVar of provider.env) {
|
for (const envVar of provider.env) {
|
||||||
if (process.env[envVar]) {
|
if (process.env[envVar]) {
|
||||||
activeEnvVars.push({
|
activeEnvVars.push({
|
||||||
provider: provider.name || providerID,
|
provider: provider.name || providerID,
|
||||||
envVar,
|
envVar,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (activeEnvVars.length > 0) {
|
if (activeEnvVars.length > 0) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Environment")
|
prompts.intro("Environment")
|
||||||
|
|
||||||
for (const { provider, envVar } of activeEnvVars) {
|
for (const { provider, envVar } of activeEnvVars) {
|
||||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -308,187 +308,187 @@ export const ProvidersLoginCommand = effectCmd({
|
|||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add credential")
|
prompts.intro("Add credential")
|
||||||
if (args.url) {
|
if (args.url) {
|
||||||
const url = args.url.replace(/\/+$/, "")
|
const url = args.url.replace(/\/+$/, "")
|
||||||
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
||||||
auth: { command: string[]; env: string }
|
auth: { command: string[]; env: string }
|
||||||
}
|
}
|
||||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
const proc = Process.spawn(wellknown.auth.command, {
|
const proc = Process.spawn(wellknown.auth.command, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
})
|
})
|
||||||
if (!proc.stdout) {
|
if (!proc.stdout) {
|
||||||
prompts.log.error("Failed")
|
prompts.log.error("Failed")
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
|
||||||
if (exit !== 0) {
|
|
||||||
prompts.log.error("Failed")
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await put(url, {
|
|
||||||
type: "wellknown",
|
|
||||||
key: wellknown.auth.env,
|
|
||||||
token: token.trim(),
|
|
||||||
})
|
|
||||||
prompts.log.success("Logged into " + url)
|
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshModels().catch(() => {})
|
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||||
|
if (exit !== 0) {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
prompts.log.error("Failed")
|
||||||
|
prompts.outro("Done")
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
return
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
|
||||||
|
|
||||||
const providers = await getModels().then((x) => {
|
|
||||||
const filtered: Record<string, (typeof x)[string]> = {}
|
|
||||||
for (const [key, value] of Object.entries(x)) {
|
|
||||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
|
||||||
filtered[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
})
|
|
||||||
const hooks = await AppRuntime.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
return yield* plugin.list()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const priority: Record<string, number> = {
|
|
||||||
opencode: 0,
|
|
||||||
openai: 1,
|
|
||||||
"github-copilot": 2,
|
|
||||||
google: 3,
|
|
||||||
anthropic: 4,
|
|
||||||
openrouter: 5,
|
|
||||||
vercel: 6,
|
|
||||||
}
|
}
|
||||||
const pluginProviders = resolvePluginProviders({
|
await put(url, {
|
||||||
hooks,
|
type: "wellknown",
|
||||||
existingProviders: providers,
|
key: wellknown.auth.env,
|
||||||
disabled,
|
token: token.trim(),
|
||||||
enabled,
|
|
||||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
|
||||||
})
|
})
|
||||||
const options = [
|
prompts.log.success("Logged into " + url)
|
||||||
...pipe(
|
prompts.outro("Done")
|
||||||
providers,
|
return
|
||||||
values(),
|
}
|
||||||
sortBy(
|
await refreshModels().catch(() => {})
|
||||||
(x) => priority[x.id] ?? 99,
|
|
||||||
(x) => x.name ?? x.id,
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||||
),
|
|
||||||
map((x) => ({
|
const disabled = new Set(config.disabled_providers ?? [])
|
||||||
label: x.name,
|
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||||
value: x.id,
|
|
||||||
hint: {
|
const providers = await getModels().then((x) => {
|
||||||
opencode: "recommended",
|
const filtered: Record<string, (typeof x)[string]> = {}
|
||||||
openai: "ChatGPT Plus/Pro or API key",
|
for (const [key, value] of Object.entries(x)) {
|
||||||
}[x.id],
|
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||||
})),
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
})
|
||||||
|
const hooks = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
return yield* plugin.list()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const priority: Record<string, number> = {
|
||||||
|
opencode: 0,
|
||||||
|
openai: 1,
|
||||||
|
"github-copilot": 2,
|
||||||
|
google: 3,
|
||||||
|
anthropic: 4,
|
||||||
|
openrouter: 5,
|
||||||
|
vercel: 6,
|
||||||
|
}
|
||||||
|
const pluginProviders = resolvePluginProviders({
|
||||||
|
hooks,
|
||||||
|
existingProviders: providers,
|
||||||
|
disabled,
|
||||||
|
enabled,
|
||||||
|
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||||
|
})
|
||||||
|
const options = [
|
||||||
|
...pipe(
|
||||||
|
providers,
|
||||||
|
values(),
|
||||||
|
sortBy(
|
||||||
|
(x) => priority[x.id] ?? 99,
|
||||||
|
(x) => x.name ?? x.id,
|
||||||
),
|
),
|
||||||
...pluginProviders.map((x) => ({
|
map((x) => ({
|
||||||
label: x.name,
|
label: x.name,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
hint: "plugin",
|
hint: {
|
||||||
|
opencode: "recommended",
|
||||||
|
openai: "ChatGPT Plus/Pro or API key",
|
||||||
|
}[x.id],
|
||||||
})),
|
})),
|
||||||
]
|
),
|
||||||
|
...pluginProviders.map((x) => ({
|
||||||
|
label: x.name,
|
||||||
|
value: x.id,
|
||||||
|
hint: "plugin",
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
let provider: string
|
let provider: string
|
||||||
if (args.provider) {
|
if (args.provider) {
|
||||||
const input = args.provider
|
const input = args.provider
|
||||||
const byID = options.find((x) => x.value === input)
|
const byID = options.find((x) => x.value === input)
|
||||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||||
const match = byID ?? byName
|
const match = byID ?? byName
|
||||||
if (!match) {
|
if (!match) {
|
||||||
prompts.log.error(`Unknown provider "${input}"`)
|
prompts.log.error(`Unknown provider "${input}"`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
|
||||||
provider = match.value
|
|
||||||
} else {
|
|
||||||
const selected = await prompts.autocomplete({
|
|
||||||
message: "Select provider",
|
|
||||||
maxItems: 8,
|
|
||||||
options: [
|
|
||||||
...options,
|
|
||||||
{
|
|
||||||
value: "other",
|
|
||||||
label: "Other",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
|
||||||
provider = selected as string
|
|
||||||
}
|
}
|
||||||
|
provider = match.value
|
||||||
|
} else {
|
||||||
|
const selected = await prompts.autocomplete({
|
||||||
|
message: "Select provider",
|
||||||
|
maxItems: 8,
|
||||||
|
options: [
|
||||||
|
...options,
|
||||||
|
{
|
||||||
|
value: "other",
|
||||||
|
label: "Other",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||||
|
provider = selected as string
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
if (plugin && plugin.auth) {
|
if (plugin && plugin.auth) {
|
||||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||||
|
if (handled) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "other") {
|
||||||
|
const custom = await prompts.text({
|
||||||
|
message: "Enter provider id",
|
||||||
|
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||||
|
provider = custom.replace(/^@ai-sdk\//, "")
|
||||||
|
|
||||||
|
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||||
|
if (customPlugin && customPlugin.auth) {
|
||||||
|
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||||
if (handled) return
|
if (handled) return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "other") {
|
prompts.log.warn(
|
||||||
const custom = await prompts.text({
|
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||||
message: "Enter provider id",
|
)
|
||||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
}
|
||||||
})
|
|
||||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
|
||||||
provider = custom.replace(/^@ai-sdk\//, "")
|
|
||||||
|
|
||||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
if (provider === "amazon-bedrock") {
|
||||||
if (customPlugin && customPlugin.auth) {
|
prompts.log.info(
|
||||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
"Amazon Bedrock authentication priority:\n" +
|
||||||
if (handled) return
|
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||||
}
|
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||||
|
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||||
|
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
prompts.log.warn(
|
if (provider === "opencode") {
|
||||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "amazon-bedrock") {
|
if (provider === "vercel") {
|
||||||
prompts.log.info(
|
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||||
"Amazon Bedrock authentication priority:\n" +
|
}
|
||||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
|
||||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
|
||||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
|
||||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "opencode") {
|
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
prompts.log.info(
|
||||||
}
|
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "vercel") {
|
const key = await prompts.password({
|
||||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
message: "Enter your API key",
|
||||||
}
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
|
await put(provider, {
|
||||||
|
type: "api",
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
|
||||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
prompts.outro("Done")
|
||||||
prompts.log.info(
|
|
||||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = await prompts.password({
|
|
||||||
message: "Enter your API key",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
|
||||||
await put(provider, {
|
|
||||||
type: "api",
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
|
|
||||||
prompts.outro("Done")
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -500,35 +500,35 @@ export const ProvidersLogoutCommand = effectCmd({
|
|||||||
instance: false,
|
instance: false,
|
||||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
return Object.entries(yield* auth.all())
|
return Object.entries(yield* auth.all())
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
prompts.intro("Remove credential")
|
prompts.intro("Remove credential")
|
||||||
if (credentials.length === 0) {
|
if (credentials.length === 0) {
|
||||||
prompts.log.error("No credentials found")
|
prompts.log.error("No credentials found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const database = await getModels()
|
const database = await getModels()
|
||||||
const selected = await prompts.select({
|
const selected = await prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
options: credentials.map(([key, value]) => ({
|
options: credentials.map(([key, value]) => ({
|
||||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||||
value: key,
|
value: key,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||||
const providerID = selected as string
|
const providerID = selected as string
|
||||||
await AppRuntime.runPromise(
|
await AppRuntime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
yield* auth.remove(providerID)
|
yield* auth.remove(providerID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
prompts.outro("Logout successful")
|
prompts.outro("Logout successful")
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user