Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Feishu:
更多端侧截图与交互说明:
- [Web UI 使用指南](https://neocode-docs.pages.dev/guide/web-ui)
- [飞书远程接入配置](https://neocode-docs.pages.dev/guide/feishu-remote-setup)
- [Hooks CLI 指南](docs/guides/hooks-cli.md)

---

Expand Down
22 changes: 19 additions & 3 deletions cmd/neocode-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,31 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"os"

"neo-code/internal/cli"
)

var executeGatewayServer = cli.ExecuteGatewayServer
var exitGatewayProcess = os.Exit

func main() {
if err := cli.ExecuteGatewayServer(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err)
os.Exit(1)
exitGatewayProcess(runMain(context.Background(), os.Args[1:], os.Stderr))
}

// runMain 执行 gateway-only 主流程并返回进程退出码,方便测试覆盖错误分支。
func runMain(ctx context.Context, args []string, stderr io.Writer) int {
if err := executeGatewayServer(ctx, args); err != nil {
_, _ = fmt.Fprintf(stderr, "neocode-gateway: %v\n", err)
exitCode := 1
var exitCoder interface{ ExitCode() int }
if errors.As(err, &exitCoder) {
exitCode = exitCoder.ExitCode()
}
return exitCode
}
return 0
}
64 changes: 64 additions & 0 deletions cmd/neocode-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"context"
"errors"
"os"
"os/exec"
Expand All @@ -11,12 +12,21 @@ import (

func TestMainHelpPathDoesNotExit(t *testing.T) {
originalArgs := os.Args
originalExit := exitGatewayProcess
defer func() {
os.Args = originalArgs
exitGatewayProcess = originalExit
}()

var gotExitCode int
exitGatewayProcess = func(code int) {
gotExitCode = code
}
os.Args = []string{"neocode-gateway", "--help"}
main()
if gotExitCode != 0 {
t.Fatalf("exit code = %d, want 0", gotExitCode)
}
}

func TestMainReturnsExitCodeOneOnCommandError(t *testing.T) {
Expand Down Expand Up @@ -46,3 +56,57 @@ func TestMainReturnsExitCodeOneOnCommandError(t *testing.T) {
t.Fatalf("stderr = %q, want contains %q", stderr.String(), "neocode-gateway:")
}
}

func TestRunMainReturnsExitCoderValue(t *testing.T) {
originalExecute := executeGatewayServer
t.Cleanup(func() { executeGatewayServer = originalExecute })

executeGatewayServer = func(context.Context, []string) error {
return testGatewayExitError{message: "denied", code: 5}
}

var stderr strings.Builder
exitCode := runMain(context.Background(), []string{"gateway"}, &stderr)
if exitCode != 5 {
t.Fatalf("exit code = %d, want 5", exitCode)
}
if !strings.Contains(stderr.String(), "neocode-gateway: denied") {
t.Fatalf("stderr = %q, want gateway error", stderr.String())
}
}

func TestRunMainReturnsZeroOnSuccess(t *testing.T) {
originalExecute := executeGatewayServer
t.Cleanup(func() { executeGatewayServer = originalExecute })

var capturedArgs []string
executeGatewayServer = func(_ context.Context, args []string) error {
capturedArgs = append([]string(nil), args...)
return nil
}

var stderr strings.Builder
exitCode := runMain(context.Background(), []string{"--help"}, &stderr)
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0", exitCode)
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
if len(capturedArgs) != 1 || capturedArgs[0] != "--help" {
t.Fatalf("captured args = %#v, want --help", capturedArgs)
}
}

type testGatewayExitError struct {
message string
code int
}

func (e testGatewayExitError) Error() string {
return e.message
}

func (e testGatewayExitError) ExitCode() int {
return e.code
}
27 changes: 22 additions & 5 deletions cmd/neocode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,35 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"os"

"neo-code/internal/cli"
)

var executeCLI = cli.Execute
var consumeCLIUpdateNotice = cli.ConsumeUpdateNotice
var exitProcess = os.Exit

func main() {
if err := cli.Execute(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "neocode: %v\n", err)
os.Exit(1)
exitProcess(runMain(context.Background(), os.Stdout, os.Stderr))
}

// runMain 执行 CLI 主流程并返回最终进程退出码,便于主入口与测试共享同一套分支逻辑。
func runMain(ctx context.Context, stdout io.Writer, stderr io.Writer) int {
if err := executeCLI(ctx); err != nil {
_, _ = fmt.Fprintf(stderr, "neocode: %v\n", err)
exitCode := 1
var exitCoder interface{ ExitCode() int }
if errors.As(err, &exitCoder) {
exitCode = exitCoder.ExitCode()
}
return exitCode
}
if notice := cli.ConsumeUpdateNotice(); notice != "" {
fmt.Fprintln(os.Stdout, notice)
if notice := consumeCLIUpdateNotice(); notice != "" {
_, _ = fmt.Fprintln(stdout, notice)
}
return 0
}
119 changes: 119 additions & 0 deletions cmd/neocode/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

import (
"context"
"errors"
"strings"
"testing"
)

type testExitError struct {
message string
code int
}

func (e testExitError) Error() string {
return e.message
}

func (e testExitError) ExitCode() int {
return e.code
}

func TestRunMainReturnsExitCodeFromCLI(t *testing.T) {
originalExecute := executeCLI
originalConsume := consumeCLIUpdateNotice
t.Cleanup(func() {
executeCLI = originalExecute
consumeCLIUpdateNotice = originalConsume
})

executeCLI = func(context.Context) error {
return testExitError{message: "boom", code: 7}
}
consumeCLIUpdateNotice = func() string { return "" }

var stdout strings.Builder
var stderr strings.Builder
exitCode := runMain(context.Background(), &stdout, &stderr)
if exitCode != 7 {
t.Fatalf("exitCode = %d, want 7", exitCode)
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "neocode: boom") {
t.Fatalf("stderr = %q, want error output", stderr.String())
}
}

func TestMainInvokesExitProcessWithRunMainResult(t *testing.T) {
originalExecute := executeCLI
originalConsume := consumeCLIUpdateNotice
originalExit := exitProcess
t.Cleanup(func() {
executeCLI = originalExecute
consumeCLIUpdateNotice = originalConsume
exitProcess = originalExit
})

executeCLI = func(context.Context) error {
return testExitError{message: "boom", code: 6}
}
consumeCLIUpdateNotice = func() string { return "" }
var gotExitCode int
exitProcess = func(code int) {
gotExitCode = code
}

main()
if gotExitCode != 6 {
t.Fatalf("exit code = %d, want 6", gotExitCode)
}
}

func TestRunMainPrintsUpdateNoticeOnSuccess(t *testing.T) {
originalExecute := executeCLI
originalConsume := consumeCLIUpdateNotice
t.Cleanup(func() {
executeCLI = originalExecute
consumeCLIUpdateNotice = originalConsume
})

executeCLI = func(context.Context) error { return nil }
consumeCLIUpdateNotice = func() string { return "update available" }

var stdout strings.Builder
var stderr strings.Builder
exitCode := runMain(context.Background(), &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exitCode = %d, want 0", exitCode)
}
if !strings.Contains(stdout.String(), "update available") {
t.Fatalf("stdout = %q, want update notice", stdout.String())
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}

func TestRunMainFallsBackToExitCodeOne(t *testing.T) {
originalExecute := executeCLI
originalConsume := consumeCLIUpdateNotice
t.Cleanup(func() {
executeCLI = originalExecute
consumeCLIUpdateNotice = originalConsume
})

executeCLI = func(context.Context) error { return errors.New("plain failure") }
consumeCLIUpdateNotice = func() string { return "" }

var stderr strings.Builder
exitCode := runMain(context.Background(), &strings.Builder{}, &stderr)
if exitCode != 1 {
t.Fatalf("exitCode = %d, want 1", exitCode)
}
if !strings.Contains(stderr.String(), "plain failure") {
t.Fatalf("stderr = %q, want plain failure", stderr.String())
}
}
107 changes: 107 additions & 0 deletions docs/guides/hooks-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Hooks CLI

`neocode hook` 提供面向 Runtime Hooks 的本地调试入口:

- `neocode hook lint [path]`
- `neocode hook dry-run [path] --hook <id> --fixture <path>`
- `neocode hook trace --run-id <id>`

## lint

默认扫描两处:

- `~/.neocode/config.yaml` 中的 `runtime.hooks.items`
- `<workspace>/.neocode/hooks.yaml` 中的 `hooks.items`

可显式传入单个文件路径:

```bash
neocode hook lint
neocode hook lint .neocode/hooks.yaml
```

退出码:

- `0`:无问题
- `1`:存在 lint findings
- `2`:读取、解析或内部错误

## dry-run

使用 fixture 驱动单个 hook:

```bash
neocode hook dry-run --hook warn-bash --fixture fixture.yaml
neocode hook dry-run --hook repo-guard --fixture fixture.json --repo
neocode hook dry-run .neocode/hooks.yaml --hook repo-guard --fixture fixture.json
```

fixture 支持 YAML / JSON,字段以 hook payload schema 为准,最小示例:

```yaml
payload_version: "1"
point: before_tool_call
run_id: run-1
session_id: session-1
metadata:
tool_name: bash
tool_call_id: call-1
tool_arguments_preview: echo hello
workdir: /workspace
```

查找 hook 时的默认行为:

- 默认同时扫描 user hooks 与 repo hooks。
- 同名 hook 同时存在时,优先选择 user hook。
- 若要强制执行 repo hook,可使用 `--repo`,或直接把 repo hooks 文件路径作为 `[path]` 传入。
- fixture 的 `point` 必须与目标 hook 的 `point` 完全一致,否则 `dry-run` 直接失败。

输出中会固定打印:

- `status: pass|block|failed`
- `block: true|false`
- `duration_ms: <n>`
- 命中的 `message` / `annotations`

退出码:

- `0`:结果为 `pass`
- `3`:结果为 `block`
- `4`:结果为 `failed`
- `2`:fixture / hook 解析失败

## trace

在真实 runtime 路径上打开 `--trace-hooks` 后,hook 相关 runtime 事件会持久化到当前 workspace:

```bash
neocode gateway --trace-hooks
neocode hook trace --run-id run_123
```

trace 文件位置:

```text
~/.neocode/projects/<workspace-hash>/hook-traces/<run-id>.jsonl
```

`hook trace` 会按时间顺序回放:

- `hook_started`
- `hook_finished`
- `hook_failed`
- `hook_blocked`

并在末尾输出按 `hook_id` 聚合的简单耗时统计。

补充说明:

- `hook trace --run-id` 只读取当前 workspace 对应目录下的 trace 文件,不做跨项目全局搜索。
- workspace 优先取 `--workdir`,未传时回退到当前进程工作目录。

退出码:

- `0`:成功输出 trace
- `1`:未找到 trace
- `2`:trace 文件损坏或读取失败
Loading
Loading