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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ in this file. Populated and maintained via the `/improve` skill.
| hammerspoon | Window management |
| tools | Devbox, Claude Code, Codex |
| osx | macOS defaults |
| agents | Agent skills, custom agents, hooks, and status line (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents, agents/AGENTS.md → ~/.claude/CLAUDE.md, registers hooks and status line in ~/.claude/settings.json) |
| agents | Agent skills, custom agents, hooks, and status lines (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents, agents/AGENTS.md → ~/.claude/CLAUDE.md, registers Claude hooks and status line in ~/.claude/settings.json, and configures Codex's native status line with model, context remaining, 5-hour usage, and weekly usage in $CODEX_HOME/config.toml or ~/.codex/config.toml) |
| pi | pi.dev coding agent CLI + config (installs pi via curl pi.dev/install.sh, symlinks pi/agent/models.json → ~/.pi/agent/models.json, and seeds defaultProvider/defaultModel for Ollama qwen3:32b in ~/.pi/agent/settings.json; auth.json and sessions/ stay local) |

## Writing Skills / Slash Commands
Expand Down Expand Up @@ -302,4 +302,4 @@ If `go install`/`go test`/`go vet` fails with `failed to initialize build cache
- Installation is destructive (no backups)
- Requires macOS, Homebrew, Go 1.15+
- Uses map-based dispatch for dynamic component installation
- CI runs on GitHub Actions (macOS, Go 1.24.10)
- CI runs on GitHub Actions (macOS, Go 1.24.10)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dots docker stop-all # Stop all Docker containers

| Component | What it installs |
|-----------|------------------|
| `agents` | Agent skills, custom agents, hooks, and status line (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers SessionStart/PostToolUse hooks and status line in `~/.claude/settings.json`; one SessionStart hook prepends dev-tool bin dirs — `go/bin`, cargo, asdf shims — onto the Bash tool's `PATH` so binaries like `tts` resolve by bare name) |
| `agents` | Agent skills, custom agents, hooks, and status lines (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers Claude hooks and status line in `~/.claude/settings.json`, and configures Codex's native status line with model, context remaining, 5-hour usage, and weekly usage in `$CODEX_HOME/config.toml` or `~/.codex/config.toml`; one SessionStart hook prepends dev-tool bin dirs — `go/bin`, cargo, asdf shims — onto the Bash tool's `PATH` so binaries like `tts` resolve by bare name) |
| `bin` | Custom shell scripts and Go utilities to `~/bin` |
| `git` | `.gitconfig`, `.gitignore_global`, git extensions |
| `home` | Dotfiles symlinked to `~/` (`.zshrc`, `.vimrc`, `.tmux.conf`, `.gitconfig`, etc.) |
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/install/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ func Agents() {
// Register status line
registerStatusLine()

// Configure Codex's native status line to show model, context, and limits
registerCodexStatusLine()

// Manage local session transcript retention
registerCleanupPeriod()
}
Expand Down
352 changes: 352 additions & 0 deletions cli/commands/install/codex_statusline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
package install

import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/drn/dots/pkg/log"
"github.com/drn/dots/pkg/path"
)

var codexStatusLineItems = []string{
"model-with-reasoning",
"context-remaining",
"five-hour-limit",
"weekly-limit",
}

func registerCodexStatusLine() {
configPath := codexConfigPath()
data, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
log.Warning("Failed to read Codex config: %s", err.Error())
return
}

updated, changed, err := ensureCodexStatusLine(string(data))
if err != nil {
log.Warning("Failed to update Codex status line: %s", err.Error())
return
}
if !changed {
return
}

mode := os.FileMode(0600)
if info, statErr := os.Stat(configPath); statErr == nil {
mode = info.Mode().Perm()
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
log.Warning("Failed to create Codex config directory: %s", err.Error())
return
}
if err := writeFileAtomic(configPath, []byte(updated), mode); err != nil {
log.Warning("Failed to write Codex config: %s", err.Error())
return
}
log.Success("Registered Codex status line (model, context, and limits)")
}

func codexConfigPath() string {
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
codexHome = path.FromHome(".codex")
}
return filepath.Join(codexHome, "config.toml")
}

func ensureCodexStatusLine(config string) (string, bool, error) {
location := findCodexStatusLine(config)
if location.targetStart >= 0 {
return updateCodexStatusLine(config, location.targetStart)
}

var missing []string
appendMissingStatusLineItems(&missing)
value := "status_line = " + formatTomlStringArray(missing) + "\n"
if location.tuiStart >= 0 {
return config[:location.tuiEnd] + value + config[location.tuiEnd:], true, nil
}

// Put a dotted key before any table so it remains in the root TOML scope.
if location.firstTable < 0 {
if config != "" && !strings.HasSuffix(config, "\n") {
config += "\n"
}
return config + "[tui]\n" + value, true, nil
}
return config[:location.firstTable] + "tui.status_line = " + formatTomlStringArray(missing) + "\n\n" + config[location.firstTable:], true, nil
}

type codexStatusLineLocation struct {
tuiStart int
tuiEnd int
targetStart int
firstTable int
}

func findCodexStatusLine(config string) codexStatusLineLocation {
lines := strings.SplitAfter(config, "\n")
section := ""
location := codexStatusLineLocation{tuiStart: -1, tuiEnd: -1, targetStart: -1, firstTable: -1}
offset := 0
for _, line := range lines {
trimmed := strings.TrimSpace(stripTomlComment(line))
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
if location.firstTable < 0 {
location.firstTable = offset
}
section = strings.TrimSpace(trimmed[1 : len(trimmed)-1])
if section == "tui" {
location.tuiStart = offset
location.tuiEnd = offset + len(line)
}
} else if section == "tui" && location.tuiStart >= 0 {
location.tuiEnd = offset + len(line)
}

key, value, hasValue := strings.Cut(trimmed, "=")
if hasValue && (section == "tui" && strings.TrimSpace(key) == "status_line" || section == "" && strings.TrimSpace(key) == "tui.status_line") {
value = strings.TrimSpace(value)
location.targetStart = offset + strings.Index(line, value)
}
offset += len(line)
}
return location
}

func updateCodexStatusLine(config string, start int) (string, bool, error) {
for start < len(config) && isSpace(config[start]) {
start++
}
if isTomlNull(config, start) {
var items []string
appendMissingStatusLineItems(&items)
return config[:start] + formatTomlStringArray(items) + config[start+4:], true, nil
}
items, end, err := readCodexStatusLineArray(config, start)
if err != nil {
return config, false, err
}
if !appendMissingStatusLineItems(&items) {
return config, false, nil
}
updatedArray := formatTomlStringArray(items)
for i, comment := range tomlArrayComments(config[start:end]) {
if i == 0 {
updatedArray += " " + comment
} else {
updatedArray += "\n" + comment
}
}
return config[:start] + updatedArray + config[end:], true, nil
}

func readCodexStatusLineArray(config string, start int) ([]string, int, error) {
if start >= len(config) || config[start] != '[' {
return nil, start, fmt.Errorf("tui.status_line must be an array of strings")
}
end, err := tomlArrayEnd(config, start, len(config))
if err != nil {
return nil, start, err
}
items, err := parseTomlStringArray(config[start:end])
if err != nil {
return nil, start, err
}
return items, end, nil
}

func isTomlNull(config string, start int) bool {
if start+4 > len(config) || config[start:start+4] != "null" {
return false
}
if start+4 == len(config) {
return true
}
next := config[start+4]
return isSpace(next) || next == '#' || next == '\n' || next == '\r'
}

func stripTomlComment(line string) string {
var quote byte
escaped := false
for i := 0; i < len(line); i++ {
char := line[i]
if quote != 0 {
if quote == '"' && escaped {
escaped = false
} else if quote == '"' && char == '\\' {
escaped = true
} else if char == quote {
quote = 0
}
continue
}
if char == '"' || char == '\'' {
quote = char
} else if char == '#' {
return line[:i]
}
}
return line
}

func tomlArrayComments(value string) []string {
var comments []string
for _, line := range strings.Split(value, "\n") {
withoutComment := stripTomlComment(line)
if len(withoutComment) < len(line) {
comments = append(comments, strings.TrimSpace(line[len(withoutComment):]))
}
}
return comments
}

func appendMissingStatusLineItems(items *[]string) bool {
changed := false
for _, wanted := range codexStatusLineItems {
found := false
for _, item := range *items {
if item == wanted {
found = true
break
}
}
if !found {
*items = append(*items, wanted)
changed = true
}
}
return changed
}

func formatTomlStringArray(items []string) string {
quoted := make([]string, 0, len(items))
for _, item := range items {
quoted = append(quoted, strconv.Quote(item))
}
return "[" + strings.Join(quoted, ", ") + "]"
}

func parseTomlStringArray(value string) ([]string, error) {
if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' {
return nil, fmt.Errorf("tui.status_line must be an array of strings")
}
body := value[1 : len(value)-1]
var items []string
for i := 0; i < len(body); {
i = skipTomlArrayWhitespace(body, i)
if i == len(body) {
break
}
item, next, err := parseTomlString(body, i)
if err != nil {
return nil, err
}
items = append(items, item)
i = next
i = skipTomlArrayWhitespace(body, i)
if i < len(body) {
if body[i] != ',' {
return nil, fmt.Errorf("expected comma in tui.status_line")
}
i++
}
}
return items, nil
}

func skipTomlArrayWhitespace(body string, i int) int {
for i < len(body) {
if isSpace(body[i]) {
i++
continue
}
if body[i] == '#' {
for i < len(body) && body[i] != '\n' {
i++
}
continue
}
break
}
return i
}

func parseTomlString(body string, start int) (string, int, error) {
quote := body[start]
if quote != '"' && quote != '\'' {
return "", start, fmt.Errorf("tui.status_line contains a non-string value")
}
end := start + 1
for end < len(body) {
if quote == '"' && body[end] == '\\' {
end += 2
continue
}
if body[end] == quote {
end++
break
}
end++
}
if end > len(body) || body[end-1] != quote {
return "", start, fmt.Errorf("unterminated string in tui.status_line")
}
raw := body[start:end]
if quote == '\'' {
return raw[1 : len(raw)-1], end, nil
}
item, err := strconv.Unquote(raw)
if err != nil {
return "", start, fmt.Errorf("invalid string in tui.status_line: %w", err)
}
return item, end, nil
}

func tomlArrayEnd(config string, start, limit int) (int, error) {
state := tomlArrayScanState{}
for i := start + 1; i < limit; i++ {
if state.consume(config[i]) {
return i + 1, nil
}
}
return 0, fmt.Errorf("unterminated tui.status_line array")
}

type tomlArrayScanState struct {
quote byte
escaped bool
comment bool
}

func (state *tomlArrayScanState) consume(char byte) bool {
if state.comment {
state.comment = char != '\n'
return false
}
if state.quote != 0 {
if state.quote == '"' && state.escaped {
state.escaped = false
} else if state.quote == '"' && char == '\\' {
state.escaped = true
} else if char == state.quote {
state.quote = 0
}
return false
}
if char == '#' {
state.comment = true
} else if char == '"' || char == '\'' {
state.quote = char
}
return char == ']'
}

func isSpace(value byte) bool {
return value == ' ' || value == '\t' || value == '\r' || value == '\n'
}
Loading
Loading