Skip to content

Commit 4e168c0

Browse files
committed
feat(daemon): 支持首次附带启动输入并新增 encode 命令
1 parent 7bbcbd1 commit 4e168c0

6 files changed

Lines changed: 477 additions & 11 deletions

File tree

internal/cli/daemon_commands.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net"
8+
neturl "net/url"
79
"os"
810
"strings"
911

@@ -17,6 +19,7 @@ var (
1719
runDaemonInstallCommand = defaultDaemonInstallCommandRunner
1820
runDaemonUninstallCommand = defaultDaemonUninstallCommandRunner
1921
runDaemonStatusCommand = defaultDaemonStatusCommandRunner
22+
runDaemonEncodeCommand = defaultDaemonEncodeCommandRunner
2023

2124
serveHTTPDaemon = urlscheme.ServeHTTPDaemon
2225
installHTTPDaemon = urlscheme.InstallHTTPDaemon
@@ -38,6 +41,15 @@ type daemonStatusCommandOptions struct {
3841
ListenAddress string
3942
}
4043

44+
type daemonEncodeCommandOptions struct {
45+
Action string
46+
Prompt string
47+
Path string
48+
Workdir string
49+
SessionID string
50+
ListenAddress string
51+
}
52+
4153
// newDaemonCommand 创建 daemon 命令组,承载 HTTP 唤醒服务与自启动管理能力。
4254
func newDaemonCommand() *cobra.Command {
4355
command := &cobra.Command{
@@ -55,6 +67,7 @@ func newDaemonCommand() *cobra.Command {
5567
newDaemonInstallCommand(),
5668
newDaemonUninstallCommand(),
5769
newDaemonStatusCommand(),
70+
newDaemonEncodeCommand(),
5871
)
5972
return command
6073
}
@@ -159,6 +172,84 @@ func newDaemonStatusCommand() *cobra.Command {
159172
return command
160173
}
161174

175+
// newDaemonEncodeCommand 创建 daemon encode 子命令组。
176+
func newDaemonEncodeCommand() *cobra.Command {
177+
command := &cobra.Command{
178+
Use: "encode",
179+
Short: "Generate clickable HTTP wake URL",
180+
SilenceUsage: true,
181+
SilenceErrors: true,
182+
Args: cobra.NoArgs,
183+
}
184+
command.AddCommand(
185+
newDaemonEncodeRunCommand(),
186+
newDaemonEncodeReviewCommand(),
187+
)
188+
return command
189+
}
190+
191+
// newDaemonEncodeRunCommand 创建 daemon encode run 子命令。
192+
func newDaemonEncodeRunCommand() *cobra.Command {
193+
options := &daemonEncodeCommandOptions{Action: "run"}
194+
command := &cobra.Command{
195+
Use: "run",
196+
Short: "Encode wake run URL",
197+
SilenceUsage: true,
198+
SilenceErrors: true,
199+
Args: cobra.NoArgs,
200+
RunE: func(cmd *cobra.Command, args []string) error {
201+
return runDaemonEncodeCommand(cmd.Context(), daemonEncodeCommandOptions{
202+
Action: "run",
203+
Prompt: strings.TrimSpace(options.Prompt),
204+
Workdir: strings.TrimSpace(options.Workdir),
205+
SessionID: strings.TrimSpace(options.SessionID),
206+
ListenAddress: strings.TrimSpace(options.ListenAddress),
207+
})
208+
},
209+
}
210+
command.Flags().StringVar(&options.Prompt, "prompt", "", "run prompt text")
211+
command.Flags().StringVar(&options.Workdir, "workdir", "", "workspace absolute path")
212+
command.Flags().StringVar(&options.SessionID, "session-id", "", "existing session id for resume-only wake")
213+
command.Flags().StringVar(
214+
&options.ListenAddress,
215+
"listen",
216+
urlscheme.DefaultHTTPDaemonListenAddress,
217+
"http daemon listen address",
218+
)
219+
return command
220+
}
221+
222+
// newDaemonEncodeReviewCommand 创建 daemon encode review 子命令。
223+
func newDaemonEncodeReviewCommand() *cobra.Command {
224+
options := &daemonEncodeCommandOptions{Action: "review"}
225+
command := &cobra.Command{
226+
Use: "review",
227+
Short: "Encode wake review URL",
228+
SilenceUsage: true,
229+
SilenceErrors: true,
230+
Args: cobra.NoArgs,
231+
RunE: func(cmd *cobra.Command, args []string) error {
232+
return runDaemonEncodeCommand(cmd.Context(), daemonEncodeCommandOptions{
233+
Action: "review",
234+
Path: strings.TrimSpace(options.Path),
235+
Workdir: strings.TrimSpace(options.Workdir),
236+
SessionID: strings.TrimSpace(options.SessionID),
237+
ListenAddress: strings.TrimSpace(options.ListenAddress),
238+
})
239+
},
240+
}
241+
command.Flags().StringVar(&options.Path, "path", "", "review file path")
242+
command.Flags().StringVar(&options.Workdir, "workdir", "", "workspace absolute path")
243+
command.Flags().StringVar(&options.SessionID, "session-id", "", "existing session id for resume-only wake")
244+
command.Flags().StringVar(
245+
&options.ListenAddress,
246+
"listen",
247+
urlscheme.DefaultHTTPDaemonListenAddress,
248+
"http daemon listen address",
249+
)
250+
return command
251+
}
252+
162253
// defaultDaemonServeCommandRunner 启动 HTTP daemon 主循环。
163254
func defaultDaemonServeCommandRunner(ctx context.Context, options daemonServeCommandOptions) error {
164255
if serveHTTPDaemon == nil {
@@ -239,3 +330,76 @@ func defaultDaemonStatusCommandRunner(ctx context.Context, options daemonStatusC
239330
"hosts_alias_configured": status.HostsAliasConfigured,
240331
})
241332
}
333+
334+
// defaultDaemonEncodeCommandRunner 生成 URL 编码后的 daemon 唤醒链接,并输出单行 URL。
335+
func defaultDaemonEncodeCommandRunner(_ context.Context, options daemonEncodeCommandOptions) error {
336+
urlText, err := buildDaemonEncodedWakeURL(options)
337+
if err != nil {
338+
return err
339+
}
340+
_, _ = fmt.Fprintln(os.Stdout, urlText)
341+
return nil
342+
}
343+
344+
// buildDaemonEncodedWakeURL 按 action 组装标准化的可点击 HTTP 唤醒链接。
345+
func buildDaemonEncodedWakeURL(options daemonEncodeCommandOptions) (string, error) {
346+
action := strings.ToLower(strings.TrimSpace(options.Action))
347+
if action != "run" && action != "review" {
348+
return "", fmt.Errorf("unsupported encode action: %s", options.Action)
349+
}
350+
351+
sessionID := strings.TrimSpace(options.SessionID)
352+
workdir := strings.TrimSpace(options.Workdir)
353+
query := neturl.Values{}
354+
switch action {
355+
case "run":
356+
prompt := strings.TrimSpace(options.Prompt)
357+
if sessionID == "" && prompt == "" {
358+
return "", errors.New("missing required flag: --prompt")
359+
}
360+
if prompt != "" {
361+
query.Set("prompt", prompt)
362+
}
363+
case "review":
364+
path := strings.TrimSpace(options.Path)
365+
if sessionID == "" && path == "" {
366+
return "", errors.New("missing required flag: --path")
367+
}
368+
if sessionID == "" && workdir == "" {
369+
return "", errors.New("missing required flag: --workdir (or provide --session-id)")
370+
}
371+
if path != "" {
372+
query.Set("path", path)
373+
}
374+
}
375+
376+
if workdir != "" {
377+
query.Set("workdir", workdir)
378+
}
379+
if sessionID != "" {
380+
query.Set("session_id", sessionID)
381+
}
382+
383+
hostPort := daemonEncodeHostPort(options.ListenAddress)
384+
return (&neturl.URL{
385+
Scheme: "http",
386+
Host: hostPort,
387+
Path: "/" + action,
388+
RawQuery: query.Encode(),
389+
}).String(), nil
390+
}
391+
392+
// daemonEncodeHostPort 将监听地址归一为对外文档可点击的 neocode 域名地址。
393+
func daemonEncodeHostPort(listenAddress string) string {
394+
normalized := strings.TrimSpace(listenAddress)
395+
if normalized == "" {
396+
normalized = urlscheme.DefaultHTTPDaemonListenAddress
397+
}
398+
if _, port, err := net.SplitHostPort(normalized); err == nil && strings.TrimSpace(port) != "" {
399+
return net.JoinHostPort(urlscheme.DaemonHostsAlias, strings.TrimSpace(port))
400+
}
401+
if strings.IndexByte(normalized, ':') < 0 && normalized != "" {
402+
return net.JoinHostPort(urlscheme.DaemonHostsAlias, normalized)
403+
}
404+
return net.JoinHostPort(urlscheme.DaemonHostsAlias, "18921")
405+
}

internal/cli/daemon_commands_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"context"
55
"errors"
6+
"net/url"
67
"os"
78
"strings"
89
"testing"
@@ -128,3 +129,136 @@ func TestDaemonSubcommandSkipsSilentUpdateCheck(t *testing.T) {
128129
t.Fatal("expected silent update check to be skipped for daemon command")
129130
}
130131
}
132+
133+
func TestDaemonEncodeRunSubcommandUsesFlags(t *testing.T) {
134+
originalRunner := runDaemonEncodeCommand
135+
t.Cleanup(func() { runDaemonEncodeCommand = originalRunner })
136+
137+
var captured daemonEncodeCommandOptions
138+
runDaemonEncodeCommand = func(_ context.Context, options daemonEncodeCommandOptions) error {
139+
captured = options
140+
return nil
141+
}
142+
143+
command := NewRootCommand()
144+
command.SetArgs([]string{
145+
"daemon", "encode", "run",
146+
"--prompt", " explain RESTful API ",
147+
"--workdir", ` C:\project `,
148+
"--session-id", " session-1 ",
149+
"--listen", " 127.0.0.1:19921 ",
150+
})
151+
if err := command.ExecuteContext(context.Background()); err != nil {
152+
t.Fatalf("ExecuteContext() error = %v", err)
153+
}
154+
if captured.Action != "run" {
155+
t.Fatalf("action = %q, want %q", captured.Action, "run")
156+
}
157+
if captured.Prompt != "explain RESTful API" {
158+
t.Fatalf("prompt = %q, want %q", captured.Prompt, "explain RESTful API")
159+
}
160+
if captured.Workdir != `C:\project` {
161+
t.Fatalf("workdir = %q, want %q", captured.Workdir, `C:\project`)
162+
}
163+
if captured.SessionID != "session-1" {
164+
t.Fatalf("session id = %q, want %q", captured.SessionID, "session-1")
165+
}
166+
if captured.ListenAddress != "127.0.0.1:19921" {
167+
t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "127.0.0.1:19921")
168+
}
169+
}
170+
171+
func TestDaemonEncodeReviewSubcommandUsesFlags(t *testing.T) {
172+
originalRunner := runDaemonEncodeCommand
173+
t.Cleanup(func() { runDaemonEncodeCommand = originalRunner })
174+
175+
var captured daemonEncodeCommandOptions
176+
runDaemonEncodeCommand = func(_ context.Context, options daemonEncodeCommandOptions) error {
177+
captured = options
178+
return nil
179+
}
180+
181+
command := NewRootCommand()
182+
command.SetArgs([]string{
183+
"daemon", "encode", "review",
184+
"--path", " internal/gateway/bootstrap.go ",
185+
"--workdir", " /repo ",
186+
})
187+
if err := command.ExecuteContext(context.Background()); err != nil {
188+
t.Fatalf("ExecuteContext() error = %v", err)
189+
}
190+
if captured.Action != "review" {
191+
t.Fatalf("action = %q, want %q", captured.Action, "review")
192+
}
193+
if captured.Path != "internal/gateway/bootstrap.go" {
194+
t.Fatalf("path = %q, want %q", captured.Path, "internal/gateway/bootstrap.go")
195+
}
196+
if captured.Workdir != "/repo" {
197+
t.Fatalf("workdir = %q, want %q", captured.Workdir, "/repo")
198+
}
199+
}
200+
201+
func TestBuildDaemonEncodedWakeURLRunEncodesPromptAndWorkdir(t *testing.T) {
202+
urlText, err := buildDaemonEncodedWakeURL(daemonEncodeCommandOptions{
203+
Action: "run",
204+
Prompt: "解释RESTful API",
205+
Workdir: `C:\project`,
206+
SessionID: "",
207+
})
208+
if err != nil {
209+
t.Fatalf("buildDaemonEncodedWakeURL() error = %v", err)
210+
}
211+
212+
parsed, err := url.Parse(urlText)
213+
if err != nil {
214+
t.Fatalf("url.Parse() error = %v", err)
215+
}
216+
if parsed.Scheme != "http" {
217+
t.Fatalf("scheme = %q, want http", parsed.Scheme)
218+
}
219+
if parsed.Host != "neocode:18921" {
220+
t.Fatalf("host = %q, want %q", parsed.Host, "neocode:18921")
221+
}
222+
if parsed.Path != "/run" {
223+
t.Fatalf("path = %q, want %q", parsed.Path, "/run")
224+
}
225+
if got := parsed.Query().Get("prompt"); got != "解释RESTful API" {
226+
t.Fatalf("prompt = %q, want %q", got, "解释RESTful API")
227+
}
228+
if got := parsed.Query().Get("workdir"); got != `C:\project` {
229+
t.Fatalf("workdir = %q, want %q", got, `C:\project`)
230+
}
231+
}
232+
233+
func TestBuildDaemonEncodedWakeURLRunAllowsSessionOnly(t *testing.T) {
234+
urlText, err := buildDaemonEncodedWakeURL(daemonEncodeCommandOptions{
235+
Action: "run",
236+
SessionID: "session-42",
237+
})
238+
if err != nil {
239+
t.Fatalf("buildDaemonEncodedWakeURL() error = %v", err)
240+
}
241+
parsed, err := url.Parse(urlText)
242+
if err != nil {
243+
t.Fatalf("url.Parse() error = %v", err)
244+
}
245+
if got := parsed.Query().Get("session_id"); got != "session-42" {
246+
t.Fatalf("session_id = %q, want %q", got, "session-42")
247+
}
248+
if got := parsed.Query().Get("prompt"); got != "" {
249+
t.Fatalf("prompt = %q, want empty", got)
250+
}
251+
}
252+
253+
func TestBuildDaemonEncodedWakeURLReviewRequiresWorkdirWithoutSession(t *testing.T) {
254+
_, err := buildDaemonEncodedWakeURL(daemonEncodeCommandOptions{
255+
Action: "review",
256+
Path: "internal/gateway/bootstrap.go",
257+
})
258+
if err == nil {
259+
t.Fatal("expected missing workdir error")
260+
}
261+
if !strings.Contains(err.Error(), "--workdir") {
262+
t.Fatalf("error = %v, want contains --workdir", err)
263+
}
264+
}

internal/gateway/adapters/urlscheme/daemon.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,17 @@ func buildHTTPDaemonWakeIntent(request *http.Request) (protocol.WakeIntent, erro
240240
workdir := popWakeQueryParam(params, "workdir")
241241
switch action {
242242
case protocol.WakeActionRun:
243-
if strings.TrimSpace(params["prompt"]) == "" {
243+
if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(params["prompt"]) == "" {
244244
return protocol.WakeIntent{}, errors.New("missing required query: prompt")
245245
}
246246
case protocol.WakeActionReview:
247-
if strings.TrimSpace(params["path"]) == "" {
248-
return protocol.WakeIntent{}, errors.New("missing required query: path")
249-
}
250-
if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(workdir) == "" {
251-
return protocol.WakeIntent{}, errors.New("missing required query: workdir or session_id")
247+
if strings.TrimSpace(sessionID) == "" {
248+
if strings.TrimSpace(params["path"]) == "" {
249+
return protocol.WakeIntent{}, errors.New("missing required query: path")
250+
}
251+
if strings.TrimSpace(workdir) == "" {
252+
return protocol.WakeIntent{}, errors.New("missing required query: workdir or session_id")
253+
}
252254
}
253255
}
254256
if len(params) == 0 {

internal/gateway/adapters/urlscheme/daemon_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ func TestBuildHTTPDaemonWakeIntentReviewAllowsSessionIDWithoutWorkdir(t *testing
5959
}
6060
}
6161

62+
func TestBuildHTTPDaemonWakeIntentRunAllowsSessionIDWithoutPrompt(t *testing.T) {
63+
request := httptest.NewRequest(
64+
http.MethodGet,
65+
"http://neocode:18921/run?session_id=s-7",
66+
http.NoBody,
67+
)
68+
intent, err := buildHTTPDaemonWakeIntent(request)
69+
if err != nil {
70+
t.Fatalf("buildHTTPDaemonWakeIntent() error = %v", err)
71+
}
72+
if intent.Action != protocol.WakeActionRun {
73+
t.Fatalf("action = %q, want %q", intent.Action, protocol.WakeActionRun)
74+
}
75+
if intent.SessionID != "s-7" {
76+
t.Fatalf("session_id = %q, want %q", intent.SessionID, "s-7")
77+
}
78+
if intent.Params["prompt"] != "" {
79+
t.Fatalf("prompt = %q, want empty", intent.Params["prompt"])
80+
}
81+
}
82+
6283
func TestBuildHTTPDaemonWakeIntentRejectsMissingPathForReview(t *testing.T) {
6384
request := httptest.NewRequest(http.MethodGet, "http://neocode:18921/review", http.NoBody)
6485
_, err := buildHTTPDaemonWakeIntent(request)

0 commit comments

Comments
 (0)