Skip to content

Commit c2eadad

Browse files
committed
feat(cli): server mode client — login/workspace 命令 + 远程 transport
- dacode login/logout: gateway /control/v1/auth/login, 凭证落盘 0600 - dacode workspace list/use: 选定 workspace, baseUrl 定格 /w/:id - Connection 服务统一 local daemon 与 server-mode 远程 transport - Bearer 注入自定义 fetch, 401→单飞 refresh→重试, TUI 经 fetch 注入复用 - DEEPAGENT_GATEWAY_URL 环境变量置顶 gateway (server-v1 §20.3)
1 parent e6bcf6a commit c2eadad

11 files changed

Lines changed: 468 additions & 18 deletions

File tree

packages/cli/src/commands/commands.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ export const Commands = Spec.make(
1212
description: "Debugging and troubleshooting tools",
1313
commands: [Spec.make("agents", { description: "List all agents" })],
1414
}),
15+
Spec.make("login", {
16+
description: "Log in to a DeepAgent Server gateway (server mode)",
17+
params: {
18+
gateway: Argument.string("gateway").pipe(Argument.optional),
19+
email: Flag.string("email").pipe(Flag.optional),
20+
password: Flag.string("password").pipe(Flag.optional),
21+
},
22+
}),
23+
Spec.make("logout", { description: "Log out of the DeepAgent Server gateway" }),
24+
Spec.make("workspace", {
25+
description: "Manage DeepAgent Server workspaces (server mode)",
26+
commands: [
27+
Spec.make("list", { description: "List workspaces on the gateway" }),
28+
Spec.make("use", {
29+
description: "Select the workspace to connect to",
30+
params: { id: Argument.string("id") },
31+
}),
32+
],
33+
}),
1534
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
1635
Spec.make("service", {
1736
description: "Manage the background server",

packages/cli/src/commands/handlers/default.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Commands } from "../commands"
22
import { Runtime } from "../../framework/runtime"
33
import { Effect } from "effect"
4-
import { Daemon } from "../../services/daemon"
4+
import { Connection } from "../../services/connection"
55

66
export default Runtime.handler(Commands, () =>
77
Effect.gen(function* () {
8-
const daemon = yield* Daemon.Service
9-
const transport = yield* daemon.transport()
8+
const connection = yield* Connection.Service
9+
const transport = yield* connection.transport()
1010
const { runTui } = yield* Effect.promise(() => import("../../tui"))
1111
yield* runTui(transport)
1212
}),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import * as NodeServices from "@effect/platform-node/NodeServices"
4+
import { Option, Redacted } from "effect"
5+
import { Prompt } from "effect/unstable/cli"
6+
import { Commands } from "../commands"
7+
import { Runtime } from "../../framework/runtime"
8+
import { ServerMode } from "../../services/server-mode"
9+
10+
export default Runtime.handler(
11+
Commands.commands.login,
12+
Effect.fn("cli.login")(function* (input) {
13+
const serverMode = yield* ServerMode.Service
14+
const gateway = Option.getOrUndefined(input.gateway) ?? process.env.DEEPAGENT_GATEWAY_URL
15+
if (gateway === undefined)
16+
return yield* Effect.fail(
17+
new Error("Gateway URL required. Pass it as an argument or set DEEPAGENT_GATEWAY_URL."),
18+
)
19+
const url = gateway.replace(/\/+$/, "")
20+
const email = yield* Option.match(input.email, {
21+
onNone: () =>
22+
Prompt.run(Prompt.text({ message: "Email" })).pipe(
23+
Effect.provide(NodeServices.layer),
24+
Effect.mapError(() => new Error("Login cancelled")),
25+
),
26+
onSome: Effect.succeed,
27+
})
28+
const password = yield* Option.match(input.password, {
29+
onNone: () =>
30+
Prompt.run(Prompt.password({ message: "Password" })).pipe(
31+
Effect.provide(NodeServices.layer),
32+
Effect.map(Redacted.value),
33+
Effect.mapError(() => new Error("Login cancelled")),
34+
),
35+
onSome: Effect.succeed,
36+
})
37+
const state = yield* serverMode.login(url, email, password)
38+
process.stdout.write(
39+
`Logged in to ${state.gatewayUrl} as ${email}.${state.workspaceId ? "" : " Run `dacode workspace list` to pick a workspace."}` +
40+
EOL,
41+
)
42+
}),
43+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Commands } from "../commands"
4+
import { Runtime } from "../../framework/runtime"
5+
import { ServerMode } from "../../services/server-mode"
6+
7+
export default Runtime.handler(
8+
Commands.commands.logout,
9+
Effect.fn("cli.logout")(function* () {
10+
yield* (yield* ServerMode.Service).logout()
11+
process.stdout.write("Logged out." + EOL)
12+
}),
13+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Option } from "effect"
4+
import { Commands } from "../../commands"
5+
import { Runtime } from "../../../framework/runtime"
6+
import { ServerMode } from "../../../services/server-mode"
7+
8+
export default Runtime.handler(
9+
Commands.commands.workspace.commands.list,
10+
Effect.fn("cli.workspace.list")(function* () {
11+
const serverMode = yield* ServerMode.Service
12+
const list = yield* serverMode.workspaces()
13+
const current = yield* serverMode
14+
.status()
15+
.pipe(Effect.map((state) => (Option.isSome(state) ? state.value.workspaceId : undefined)))
16+
for (const workspace of list) {
17+
const selected = current === workspace.id ? "*" : " "
18+
process.stdout.write(
19+
`${selected} ${workspace.id} ${workspace.name ?? "-"} ${workspace.status ?? "-"}` + EOL,
20+
)
21+
}
22+
}),
23+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Commands } from "../../commands"
4+
import { Runtime } from "../../../framework/runtime"
5+
import { ServerMode } from "../../../services/server-mode"
6+
7+
export default Runtime.handler(
8+
Commands.commands.workspace.commands.use,
9+
Effect.fn("cli.workspace.use")(function* (input) {
10+
const workspace = yield* (yield* ServerMode.Service).useWorkspace(input.id)
11+
process.stdout.write(`Using workspace ${workspace.id}${workspace.name ? ` (${workspace.name})` : ""}.` + EOL)
12+
}),
13+
)

packages/cli/src/framework/runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as Effect from "effect/Effect"
22
import * as Command from "effect/unstable/cli/Command"
33
import { Spec } from "./spec"
44
import { Daemon } from "../services/daemon"
5+
import { ServerMode } from "../services/server-mode"
6+
import { Connection } from "../services/connection"
57

68
export type Input<Value> =
79
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +12,13 @@ export type Input<Value> =
1012
? Input
1113
: never
1214

13-
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
15+
export type Services = Daemon.Service | ServerMode.Service | Connection.Service
16+
17+
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Services>
1418
type Loader<Node extends Spec.Any> = () => Promise<{
15-
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
19+
default: (input: Input<Node>) => Effect.Effect<void, any, Services>
1620
}>
17-
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
21+
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Services>
1822

1923
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
2024
? Loader<Node>

packages/cli/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"
55
import * as Effect from "effect/Effect"
66
import { Commands } from "./commands/commands"
77
import { Runtime } from "./framework/runtime"
8+
import { Connection } from "./services/connection"
89
import { Daemon } from "./services/daemon"
10+
import { ServerMode } from "./services/server-mode"
911

1012
const Handlers = Runtime.handlers(Commands, {
1113
$: () => import("./commands/handlers/default"),
1214
debug: {
1315
agents: () => import("./commands/handlers/debug/agents"),
1416
},
17+
login: () => import("./commands/handlers/login"),
18+
logout: () => import("./commands/handlers/logout"),
19+
workspace: {
20+
list: () => import("./commands/handlers/workspace/list"),
21+
use: () => import("./commands/handlers/workspace/use"),
22+
},
1523
migrate: () => import("./commands/handlers/migrate"),
1624
service: {
1725
start: () => import("./commands/handlers/service/start"),
@@ -24,6 +32,8 @@ const Handlers = Runtime.handlers(Commands, {
2432
})
2533

2634
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
35+
Effect.provide(Connection.defaultLayer),
36+
Effect.provide(ServerMode.defaultLayer),
2737
Effect.provide(Daemon.defaultLayer),
2838
Effect.provide(NodeServices.layer),
2939
Effect.scoped,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Context, Effect, Layer, Option } from "effect"
2+
import { Daemon } from "./daemon"
3+
import { ServerMode } from "./server-mode"
4+
5+
export interface Transport {
6+
readonly url: string
7+
readonly headers: RequestInit["headers"]
8+
readonly fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
9+
}
10+
11+
export interface Interface {
12+
readonly transport: () => Effect.Effect<Transport, unknown>
13+
}
14+
15+
export class Service extends Context.Service<Service, Interface>()("@deepagent-code/cli/Connection") {}
16+
17+
// Server mode (DeepAgent Server Edition gateway) takes precedence when active:
18+
// either DEEPAGENT_GATEWAY_URL is pinned or a server-mode login exists. Otherwise
19+
// fall back to the local background daemon.
20+
export const layer = Layer.effect(
21+
Service,
22+
Effect.gen(function* () {
23+
const daemon = yield* Daemon.Service
24+
const serverMode = yield* ServerMode.Service
25+
26+
const transport = Effect.fn("cli.connection.transport")(function* () {
27+
const remote = yield* serverMode.transport()
28+
if (Option.isSome(remote)) return remote.value
29+
return yield* daemon.transport()
30+
})
31+
32+
return Service.of({ transport })
33+
}),
34+
)
35+
36+
export const defaultLayer = layer
37+
38+
export * as Connection from "./connection"

0 commit comments

Comments
 (0)