diff --git a/Makefile b/Makefile index a4dd7da..56ade1b 100644 --- a/Makefile +++ b/Makefile @@ -28,8 +28,8 @@ host: ## Build host binary -> host/tokenpulse host-test: ## Run host tests cd host && go test ./... -host-install: ## go install the host tool onto $GOBIN/$GOPATH/bin - cd host && go install . +host-install: ## Install host binary -> $(go env GOPATH)/bin/tokenpulse + cd host && go build -o "$$(go env GOPATH)/bin/tokenpulse" . ## ---------------- firmware (ESP-IDF) ---------------- diff --git a/README.md b/README.md index 67a221d..1ee1176 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,18 @@ go build -o tokenpulse . install tokenpulse /usr/local/bin/ ``` -Register it as the Claude Code status-line command in `~/.claude/settings.json`: +### Register the status line + +Easiest — let the tool do it (detects the serial port, wraps your existing +status line as passthrough, backs up `settings.json` first): + +```bash +tokenpulse setup # prompts before writing; --yes to skip, --serial to override +``` + +`setup` is idempotent (re-running it when already configured does nothing). + +Or register it manually in `~/.claude/settings.json`: ```json { "statusLine": { "type": "command", "command": "tokenpulse statusline" } } @@ -47,7 +58,7 @@ point it at whatever you ran before via `TOKENPULSE_INNER`. | Variable | Default | Purpose | |---|---|---| | `TOKENPULSE_INNER` | `bash ~/.claude/statusline-command.sh` | Your existing status-line command; its stdout is passed through verbatim. | -| `TOKENPULSE_SERIAL` | autodetect `/dev/cu.usbmodem*` | Serial device of the board. | +| `TOKENPULSE_SERIAL` | autodetect (prefers the TokenPulse board, else first `/dev/cu.usbmodem*`) | Serial device of the board. | | `TOKENPULSE_BAUD` | `115200` | Baud (nominal; irrelevant for USB-CDC). | The tool is fail-safe by design: if the board is unplugged, the serial write diff --git a/host/main.go b/host/main.go index 19f724a..f1dff22 100644 --- a/host/main.go +++ b/host/main.go @@ -28,6 +28,9 @@ func main() { fmt.Println("tokenpulse " + version) os.Exit(0) } + if len(os.Args) >= 2 && os.Args[1] == "setup" { + os.Exit(runSetup(os.Args[2:], os.Stdin)) + } if len(os.Args) < 2 || os.Args[1] != "statusline" { // 未知子命令:静默退出,绝不污染 stdout os.Exit(0) diff --git a/host/serial.go b/host/serial.go index 5dd98f1..652b0a9 100644 --- a/host/serial.go +++ b/host/serial.go @@ -2,15 +2,27 @@ package main import ( "path/filepath" + "strings" "go.bug.st/serial" ) +// tokenPulseSerialHint matches the macOS device node of our board, which is +// named cu.usbmodem where the firmware's USB serial string is "TP_…". +const tokenPulseSerialHint = "TP_" + func autodetectSerial(glob func(string) ([]string, error)) string { matches, err := glob("/dev/cu.usbmodem*") if err != nil || len(matches) == 0 { return "" } + // Prefer the TokenPulse board if present, so a second usbmodem device + // (another board, or the ROM USB-JTAG port in download mode) isn't grabbed. + for _, m := range matches { + if strings.Contains(strings.ToUpper(m), tokenPulseSerialHint) { + return m + } + } return matches[0] } diff --git a/host/setup.go b/host/setup.go new file mode 100644 index 0000000..ef76432 --- /dev/null +++ b/host/setup.go @@ -0,0 +1,182 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// defaultSettingsPath returns ~/.claude/settings.json. +func defaultSettingsPath() string { + home, err := os.UserHomeDir() + if err != nil { + return ".claude/settings.json" + } + return filepath.Join(home, ".claude", "settings.json") +} + +// shellSingleQuote wraps s in single quotes, safely escaping embedded quotes. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// buildStatusLineCommand assembles the statusLine command string that registers +// TokenPulse: it sets the serial port + wraps the user's existing status-line +// command as the passthrough inner command, then runs `tokenpulse statusline`. +// serialPath == "" omits the env var (host autodetects at runtime). +// innerCmd == "" omits TOKENPULSE_INNER (host uses its built-in default). +func buildStatusLineCommand(selfPath, serialPath, innerCmd string) string { + var b strings.Builder + if serialPath != "" { + b.WriteString("TOKENPULSE_SERIAL=") + b.WriteString(serialPath) + b.WriteString(" ") + } + if innerCmd != "" { + b.WriteString("TOKENPULSE_INNER=") + b.WriteString(shellSingleQuote(innerCmd)) + b.WriteString(" ") + } + if strings.ContainsAny(selfPath, " \t'\"") { + b.WriteString(shellSingleQuote(selfPath)) + } else { + b.WriteString(selfPath) + } + b.WriteString(" statusline") + return b.String() +} + +// extractStatusLineCommand returns the current statusLine.command from a +// settings.json blob ("" if there is no statusLine). +func extractStatusLineCommand(settings []byte) (string, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(settings, &top); err != nil { + return "", err + } + raw, ok := top["statusLine"] + if !ok { + return "", nil + } + var sl struct { + Command string `json:"command"` + } + if err := json.Unmarshal(raw, &sl); err != nil { + return "", err + } + return sl.Command, nil +} + +// setStatusLineCommand returns settings.json with statusLine replaced by a +// command-type entry running newCmd. Other keys are preserved (semantically); +// the file is re-emitted pretty-printed with top-level keys sorted, so exact +// formatting/key-order changes but the configuration is identical. +func setStatusLineCommand(settings []byte, newCmd string) ([]byte, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(settings, &top); err != nil { + return nil, err + } + sl, err := json.Marshal(map[string]string{"type": "command", "command": newCmd}) + if err != nil { + return nil, err + } + top["statusLine"] = sl + out, err := json.MarshalIndent(top, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +// isTokenPulseCommand reports whether a statusLine command already runs us. +func isTokenPulseCommand(cmd string) bool { + return strings.Contains(cmd, "tokenpulse statusline") +} + +// runSetup implements `tokenpulse setup`: register into ~/.claude/settings.json. +func runSetup(args []string, stdin *os.File) int { + fs := flag.NewFlagSet("setup", flag.ContinueOnError) + yes := fs.Bool("yes", false, "apply without confirmation") + serial := fs.String("serial", "", "serial device (default: autodetect /dev/cu.usbmodem*)") + settingsPath := fs.String("settings", defaultSettingsPath(), "path to Claude Code settings.json") + if err := fs.Parse(args); err != nil { + return 2 + } + + self, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "setup: cannot resolve own path:", err) + return 1 + } + + data, err := os.ReadFile(*settingsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "setup: cannot read %s: %v\n", *settingsPath, err) + fmt.Fprintln(os.Stderr, "Run Claude Code once so it creates the file, or pass --settings.") + return 1 + } + + prev, err := extractStatusLineCommand(data) + if err != nil { + fmt.Fprintf(os.Stderr, "setup: %s is not valid JSON: %v\n", *settingsPath, err) + return 1 + } + if isTokenPulseCommand(prev) { + fmt.Println("Already configured — statusLine already runs tokenpulse. Nothing to do.") + return 0 + } + + port := *serial + if port == "" { + port = autodetectSerial(globFiles) + } + if port == "" { + fmt.Fprintln(os.Stderr, "Note: no TokenPulse serial port found now; the tool will autodetect at runtime once the board is plugged in.") + } + + newCmd := buildStatusLineCommand(self, port, prev) + + fmt.Println("Will update statusLine in", *settingsPath) + if prev == "" { + fmt.Println(" current: (none)") + } else { + fmt.Println(" current:", prev) + } + fmt.Println(" new: ", newCmd) + if prev != "" { + fmt.Println("(your existing status line is preserved as the passthrough inner command)") + } + + if !*yes { + fmt.Print("Apply this change? [y/N] ") + r := bufio.NewReader(stdin) + line, _ := r.ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + default: + fmt.Println("Aborted.") + return 1 + } + } + + if err := os.WriteFile(*settingsPath+".bak", data, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "setup: failed to write backup:", err) + return 1 + } + out, err := setStatusLineCommand(data, newCmd) + if err != nil { + fmt.Fprintln(os.Stderr, "setup:", err) + return 1 + } + if err := os.WriteFile(*settingsPath, out, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "setup: failed to write settings:", err) + return 1 + } + + fmt.Printf("Done. Backup saved to %s.bak (top-level keys were re-sorted).\n", *settingsPath) + fmt.Println("Claude Code applies it on the next status-line refresh (or restart).") + return 0 +} diff --git a/host/setup_test.go b/host/setup_test.go new file mode 100644 index 0000000..00ca03e --- /dev/null +++ b/host/setup_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuildStatusLineCommand_Full(t *testing.T) { + got := buildStatusLineCommand("/Users/x/go/bin/tokenpulse", "/dev/cu.usbmodemTP_00011", "bash /Users/x/.claude/statusline-command.sh") + want := "TOKENPULSE_SERIAL=/dev/cu.usbmodemTP_00011 TOKENPULSE_INNER='bash /Users/x/.claude/statusline-command.sh' /Users/x/go/bin/tokenpulse statusline" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestBuildStatusLineCommand_NoSerialNoInner(t *testing.T) { + got := buildStatusLineCommand("/usr/local/bin/tokenpulse", "", "") + want := "/usr/local/bin/tokenpulse statusline" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestShellSingleQuote_EscapesQuote(t *testing.T) { + got := buildStatusLineCommand("/bin/tp", "", `echo 'hi'`) + // inner with single quotes must be safely escaped + if !strings.Contains(got, `TOKENPULSE_INNER='echo '\''hi'\'''`) { + t.Fatalf("quote not escaped: %q", got) + } +} + +func TestBuildStatusLineCommand_QuotesSelfPathWithSpaces(t *testing.T) { + got := buildStatusLineCommand("/Users/My Apps/tokenpulse", "", "") + want := "'/Users/My Apps/tokenpulse' statusline" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestExtractStatusLineCommand(t *testing.T) { + s := []byte(`{"foo":1,"statusLine":{"type":"command","command":"bash x.sh"}}`) + got, err := extractStatusLineCommand(s) + if err != nil { + t.Fatal(err) + } + if got != "bash x.sh" { + t.Fatalf("got %q", got) + } +} + +func TestExtractStatusLineCommand_None(t *testing.T) { + got, err := extractStatusLineCommand([]byte(`{"foo":1}`)) + if err != nil || got != "" { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestSetStatusLineCommand_PreservesOtherKeys(t *testing.T) { + in := []byte(`{"permissions":{"allow":["Read"]},"statusLine":{"type":"command","command":"old"}}`) + out, err := setStatusLineCommand(in, "NEW statusline") + if err != nil { + t.Fatal(err) + } + // statusLine updated + cmd, _ := extractStatusLineCommand(out) + if cmd != "NEW statusline" { + t.Fatalf("command not updated: %q", cmd) + } + // other keys preserved (semantically; re-indentation is expected) + var top map[string]json.RawMessage + if err := json.Unmarshal(out, &top); err != nil { + t.Fatal(err) + } + if _, ok := top["permissions"]; !ok { + t.Fatal("permissions key lost") + } + var perms struct { + Allow []string `json:"allow"` + } + if err := json.Unmarshal(top["permissions"], &perms); err != nil { + t.Fatal(err) + } + if len(perms.Allow) != 1 || perms.Allow[0] != "Read" { + t.Fatalf("permissions value changed: %v", perms.Allow) + } +} + +func TestSetStatusLineCommand_AddsWhenMissing(t *testing.T) { + out, err := setStatusLineCommand([]byte(`{"foo":1}`), "C statusline") + if err != nil { + t.Fatal(err) + } + cmd, _ := extractStatusLineCommand(out) + if cmd != "C statusline" { + t.Fatalf("got %q", cmd) + } +} + +func TestIsTokenPulseCommand(t *testing.T) { + if !isTokenPulseCommand("TOKENPULSE_SERIAL=x /go/bin/tokenpulse statusline") { + t.Fatal("should detect existing tokenpulse command") + } + if isTokenPulseCommand("bash statusline-command.sh") { + t.Fatal("false positive") + } +} + +func TestAutodetectSerial_PrefersTokenPulse(t *testing.T) { + glob := func(string) ([]string, error) { + return []string{"/dev/cu.usbmodem1101", "/dev/cu.usbmodemTP_00011"}, nil + } + if got := autodetectSerial(glob); got != "/dev/cu.usbmodemTP_00011" { + t.Fatalf("got %q, want TokenPulse device", got) + } +} + +func TestAutodetectSerial_FallbackFirst(t *testing.T) { + glob := func(string) ([]string, error) { + return []string{"/dev/cu.usbmodemABC", "/dev/cu.usbmodemXYZ"}, nil + } + if got := autodetectSerial(glob); got != "/dev/cu.usbmodemABC" { + t.Fatalf("got %q, want first", got) + } +} + +func TestAutodetectSerial_Empty(t *testing.T) { + glob := func(string) ([]string, error) { return nil, nil } + if got := autodetectSerial(glob); got != "" { + t.Fatalf("got %q, want empty", got) + } +}