Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------------

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions host/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions host/serial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<serial> 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]
}

Expand Down
182 changes: 182 additions & 0 deletions host/setup.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions host/setup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading