totopo 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -189,6 +189,16 @@ Agents are self-aware — sandbox constraints, git remote block, and any active
189
189
 
190
190
  totopo keeps all three CLIs on their latest published versions, checking for updates automatically.
191
191
 
192
+ #### Claude status line
193
+
194
+ For convenience, every Claude session opens with a status line at the bottom of the terminal:
195
+
196
+ ```
197
+ Opus 4.7 high · 174k / 1M (17%) · ▓░░░░░░░░░ (13% used, resets in 3 hr 35 min)
198
+ ```
199
+
200
+ Model and reasoning effort, current context usage with token count colored by absolute size (green below 100k, yellow up to 500k, red beyond), and a 10-block gauge of the 5-hour rate-limit window with current usage and time-to-reset (subscriber accounts only). Ask Claude `/totopo-statusline` to customize or restore the default.
201
+
192
202
  ### Persistent Agent Memory
193
203
 
194
204
  Agent session data (conversation history, settings) is stored per workspace and survives container restarts and rebuilds.
@@ -8,10 +8,9 @@
8
8
  // Claude Code: https://docs.anthropic.com/en/docs/claude-code
9
9
  // OpenCode: https://github.com/opencode-ai/opencode
10
10
  // Codex: https://github.com/openai/codex
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import { AGENTS_DIR, CONTAINER_HOME, GIT_MODE } from "./constants.js";
13
+ import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_HOME, GIT_MODE, PACKAGE_ROOT } from "./constants.js";
15
14
  export const AGENT_MOUNTS = [
16
15
  {
17
16
  agent: "claude",
@@ -75,13 +74,16 @@ export function buildAgentMountArgs(workspaceDir) {
75
74
  ];
76
75
  }
77
76
  // --- Template helpers --------------------------------------------------------------------------------------------------------------------
78
- const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
79
77
  function loadTemplate(name) {
80
- return readFileSync(join(packageRoot, "templates", "context", `${name}.md`), "utf8").trimEnd();
78
+ return readFileSync(join(PACKAGE_ROOT, "templates", "context", `${name}.md`), "utf8").trimEnd();
81
79
  }
82
80
  function renderTemplate(template, vars) {
83
81
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
84
82
  }
83
+ function writeFileEnsuringDir(filePath, content) {
84
+ mkdirSync(dirname(filePath), { recursive: true });
85
+ writeFileSync(filePath, content);
86
+ }
85
87
  // --- Build agent context documents -------------------------------------------------------------------------------------------------------
86
88
  /**
87
89
  * Assembles the agent context markdown injected into each supported agent's config dir at session start.
@@ -111,6 +113,48 @@ export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE
111
113
  codex: build("~/.codex/AGENTS.md"),
112
114
  };
113
115
  }
116
+ // --- Claude settings.json bootstrap ------------------------------------------------------------------------------------------------------
117
+ function readSettingsObject(path) {
118
+ try {
119
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
120
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
121
+ return parsed;
122
+ }
123
+ }
124
+ catch {
125
+ // Missing file or malformed JSON: caller proceeds with an empty object.
126
+ }
127
+ return {};
128
+ }
129
+ // Sets ~/.claude/settings.json -> statusLine to the totopo default if no value is set.
130
+ // Non-destructive: preserves other fields and never overwrites a user-set statusLine.
131
+ export function ensureClaudeStatusLine(workspaceDir) {
132
+ const settingsPath = join(workspaceDir, AGENTS_DIR, "claude", "settings.json");
133
+ const settings = readSettingsObject(settingsPath);
134
+ if (settings.statusLine === undefined) {
135
+ settings.statusLine = { type: "command", command: CLAUDE_STATUSLINE_PATH };
136
+ writeFileEnsuringDir(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
137
+ }
138
+ }
139
+ // --- Claude skills injection -------------------------------------------------------------------------------------------------------------
140
+ // Renders every templates/skills/<name>/SKILL.md into agents/claude/skills/<name>/SKILL.md. Drop a
141
+ // new directory under templates/skills/ to ship a new skill - no other code changes needed.
142
+ export function injectClaudeSkills(workspaceDir) {
143
+ const templatesSkillsDir = join(PACKAGE_ROOT, "templates", "skills");
144
+ if (!existsSync(templatesSkillsDir))
145
+ return;
146
+ const targetSkillsDir = join(workspaceDir, AGENTS_DIR, "claude", "skills");
147
+ const vars = { statusline_path: CLAUDE_STATUSLINE_PATH };
148
+ for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
149
+ if (!entry.isDirectory())
150
+ continue;
151
+ const sourcePath = join(templatesSkillsDir, entry.name, "SKILL.md");
152
+ if (!existsSync(sourcePath))
153
+ continue;
154
+ const rendered = renderTemplate(readFileSync(sourcePath, "utf8"), vars);
155
+ writeFileEnsuringDir(join(targetSkillsDir, entry.name, "SKILL.md"), rendered);
156
+ }
157
+ }
114
158
  // --- Inject agent context ----------------------------------------------------------------------------------------------------------------
115
159
  /**
116
160
  * Writes agent context markdown files into the workspace's agents/ directory.
@@ -123,7 +167,8 @@ export function injectAgentContext(workspaceDir, docs) {
123
167
  { path: join(a, "codex", "AGENTS.md"), content: docs.codex },
124
168
  ];
125
169
  for (const { path: filePath, content } of files) {
126
- mkdirSync(dirname(filePath), { recursive: true });
127
- writeFileSync(filePath, content);
170
+ writeFileEnsuringDir(filePath, content);
128
171
  }
172
+ ensureClaudeStatusLine(workspaceDir);
173
+ injectClaudeSkills(workspaceDir);
129
174
  }
@@ -1,6 +1,11 @@
1
1
  // =========================================================================================================================================
2
2
  // src/lib/constants.ts - Canonical constants used across the totopo codebase
3
3
  // =========================================================================================================================================
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ // Resolve the package root (repo root in dev, npm package root when installed).
7
+ // All compiled lib modules sit at dist/lib/*.js, so dirname x3 from this file lands at the package root.
8
+ export const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
4
9
  // ~/.totopo/ structure
5
10
  export const TOTOPO_DIR = ".totopo";
6
11
  export const WORKSPACES_DIR = "workspaces";
@@ -22,6 +27,8 @@ export const CONTAINER_USER = "devuser";
22
27
  export const CONTAINER_HOME = `/home/${CONTAINER_USER}`;
23
28
  export const CONTAINER_WORKSPACE = "/workspace";
24
29
  export const CONTAINER_STARTUP = `${CONTAINER_HOME}/startup.mjs`;
30
+ // Claude Code default status line script - baked into the image, referenced from ~/.claude/settings.json
31
+ export const CLAUDE_STATUSLINE_PATH = "/usr/local/share/totopo/claude-statusline.sh";
25
32
  // Docker container/image naming
26
33
  export const CONTAINER_NAME_PREFIX = "totopo-";
27
34
  // Docker label keys
@@ -43,7 +50,11 @@ export const PROFILE = {
43
50
  import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "../../templates/runtime-constants.mjs";
44
51
  export { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE };
45
52
  export const GIT_MODES = Object.values(GIT_MODE);
46
- // Runtime env vars injected into every container via docker run -e
53
+ // Runtime env vars injected into every container via docker run -e.
54
+ // DISABLE_AUTOUPDATER suppresses Claude Code's in-process auto-updater, which always fails inside
55
+ // the container (devuser can't write to the root-owned global npm prefix). totopo's startup script
56
+ // already keeps Claude on the latest version - see templates/startup.mjs.
47
57
  export const RUNTIME_ENV = {
48
58
  CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1",
59
+ DISABLE_AUTOUPDATER: "1",
49
60
  };
@@ -22,12 +22,13 @@
22
22
  // All migrations are idempotent - each checks if needed and skips if not.
23
23
  // =========================================================================================================================================
24
24
  import { spawnSync } from "node:child_process";
25
+ import { createHash } from "node:crypto";
25
26
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
26
27
  import { homedir } from "node:os";
27
28
  import { join } from "node:path";
28
29
  import { confirm, isCancel, log, note } from "@clack/prompts";
29
30
  import { load as loadYaml } from "js-yaml";
30
- import { AGENTS_DIR, CONTAINER_STARTUP, GIT_MODE, GIT_WRAPPER_SOURCE, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
31
+ import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_STARTUP, GIT_MODE, GIT_WRAPPER_SOURCE, LOCK_FILE, PACKAGE_ROOT, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
31
32
  import { safeRmSync } from "./safe-rm.js";
32
33
  import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
33
34
  import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
@@ -532,5 +533,20 @@ export function isImageStale(containerName) {
532
533
  });
533
534
  if (constantsCheck.status !== 0)
534
535
  return true;
536
+ // SHA-256 of the host claude-statusline.sh vs the file inside the container - any refinement to
537
+ // the shipped script triggers an automatic rebuild prompt on next session.
538
+ const hostScriptPath = join(PACKAGE_ROOT, "templates", "claude-statusline.sh");
539
+ if (existsSync(hostScriptPath)) {
540
+ const expectedHash = createHash("sha256").update(readFileSync(hostScriptPath)).digest("hex");
541
+ const sumResult = spawnSync("docker", ["exec", containerName, "sha256sum", CLAUDE_STATUSLINE_PATH], {
542
+ encoding: "utf8",
543
+ stdio: "pipe",
544
+ });
545
+ if (sumResult.status !== 0)
546
+ return true;
547
+ const actualHash = sumResult.stdout.trim().split(/\s+/)[0] ?? "";
548
+ if (actualHash !== expectedHash)
549
+ return true;
550
+ }
535
551
  return false;
536
552
  }
@@ -2,11 +2,10 @@
2
2
  // src/lib/totopo-yaml.ts - Read, write, and validate totopo.yaml
3
3
  // =========================================================================================================================================
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
- import { basename, dirname, join } from "node:path";
6
- import { fileURLToPath } from "node:url";
5
+ import { basename, join } from "node:path";
7
6
  import AjvModule from "ajv";
8
7
  import { dump as dumpYaml, load as loadYaml } from "js-yaml";
9
- import { DEFAULT_SHADOW_PATHS, TOTOPO_YAML, WORKSPACE_ID_MAX, WORKSPACE_ID_MIN } from "./constants.js";
8
+ import { DEFAULT_SHADOW_PATHS, PACKAGE_ROOT, TOTOPO_YAML, WORKSPACE_ID_MAX, WORKSPACE_ID_MIN } from "./constants.js";
10
9
  // --- Validation --------------------------------------------------------------------------------------------------------------------------
11
10
  // Must match the pattern, minLength, and maxLength in schema/totopo.schema.json
12
11
  const WORKSPACE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
@@ -29,8 +28,7 @@ export function slugifyForWorkspaceId(name) {
29
28
  .slice(0, WORKSPACE_ID_MAX) || "my-workspace");
30
29
  }
31
30
  // --- Schema validation -------------------------------------------------------------------------------------------------------------------
32
- const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
33
- const schemaPath = join(packageRoot, "schema", "totopo.schema.json");
31
+ const schemaPath = join(PACKAGE_ROOT, "schema", "totopo.schema.json");
34
32
  // ajv is a CJS module - under nodenext resolution the class is nested under .default
35
33
  const Ajv = AjvModule.default ?? AjvModule;
36
34
  let _validate = null;
@@ -77,7 +75,7 @@ export function readTotopoYaml(dir) {
77
75
  // --- Write -------------------------------------------------------------------------------------------------------------------------------
78
76
  // Every published version (rc or release) has a corresponding git tag created by pnpm rc / pnpm rc:promote.
79
77
  // We rely on that tag existing so these URLs resolve correctly for every installed version.
80
- const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
78
+ const { version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
81
79
  export const GITHUB_README_URL = `https://github.com/asafratzon/totopo/blob/v${version}/README.md`;
82
80
  // Inline comments injected before specific YAML keys (preceded by a blank line)
83
81
  const YAML_COMMENTS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -105,6 +105,13 @@ RUN mkdir -p /usr/local/share/totopo
105
105
  COPY git-readonly-wrapper.mjs /usr/local/share/totopo/git-readonly
106
106
  RUN chmod +x /usr/local/share/totopo/git-readonly
107
107
 
108
+ # ---------------------------------------------------------------------------
109
+ # Layer 11 — Bake Claude default status line script
110
+ # Referenced from ~/.claude/settings.json (statusLine.command) at session start.
111
+ # ---------------------------------------------------------------------------
112
+ COPY claude-statusline.sh /usr/local/share/totopo/claude-statusline.sh
113
+ RUN chmod +x /usr/local/share/totopo/claude-statusline.sh
114
+
108
115
  WORKDIR /workspace
109
116
 
110
117
  # ---------------------------------------------------------------------------
@@ -0,0 +1,172 @@
1
+ #!/bin/sh
2
+ # =============================================================================
3
+ # Default Claude Code status line shipped by totopo.
4
+ # Baked into the container image at /usr/local/share/totopo/claude-statusline.sh
5
+ # Referenced from ~/.claude/settings.json -> statusLine.command
6
+ #
7
+ # Render pattern:
8
+ # <model> <effort> · <tokens>k / <ctx> (<pct>%) · <bar> (<rate>% used, resets in <hr> hr <min> min)
9
+ #
10
+ # Designed to degrade gracefully: every field uses jq's // fallback, and any field that goes
11
+ # missing in a future Claude Code release is silently skipped rather than failing the script.
12
+ #
13
+ # To customize or revert, ask Claude: /totopo-statusline
14
+ # =============================================================================
15
+
16
+ # Single jq invocation for all fields, separated by newlines. Every path uses // fallbacks so a
17
+ # missing or renamed field becomes an empty string, which downstream branches handle as "skip".
18
+ # jq itself returns no output (empty parsed) on malformed input, which still produces a valid line.
19
+ # Tokens floor-rounded; percentages rounded to integers so downstream shell uses them as-is.
20
+ parsed=$(jq -r '
21
+ .model.display_name // "",
22
+ ((.context_window.used_percentage // 0) | round),
23
+ (((.context_window.current_usage.input_tokens // 0)
24
+ + (.context_window.current_usage.cache_creation_input_tokens // 0)
25
+ + (.context_window.current_usage.cache_read_input_tokens // 0)) | floor),
26
+ .effort.level // "",
27
+ (.rate_limits.five_hour.used_percentage | if . == null then "" else round end),
28
+ (.rate_limits.five_hour.resets_at // "")
29
+ ' 2>/dev/null)
30
+
31
+ {
32
+ IFS= read -r model_full
33
+ IFS= read -r used_pct
34
+ IFS= read -r used_tokens
35
+ IFS= read -r effort
36
+ IFS= read -r rate_pct
37
+ IFS= read -r rate_resets_at
38
+ } <<EOF
39
+ $parsed
40
+ EOF
41
+
42
+ # Split "Opus 4.7 (1M context)" into model "Opus 4.7" + ctx_size "1M" via POSIX parameter expansion.
43
+ ctx_size=""
44
+ model_display="$model_full"
45
+ case "$model_full" in
46
+ *"("*")"*)
47
+ inside=${model_full#*\(}
48
+ inside=${inside%%\)*}
49
+ ctx_size=${inside% context}
50
+ model_display=${model_full%% \(*}
51
+ ;;
52
+ esac
53
+
54
+ # Colors
55
+ GREEN='\033[32m'
56
+ YELLOW='\033[33m'
57
+ RED='\033[31m'
58
+ BLUE='\033[34m'
59
+ GREY='\033[90m'
60
+ RESET='\033[0m'
61
+
62
+ # Normalize tokens & pct (may arrive empty when current_usage is null).
63
+ [ -z "$used_tokens" ] && used_tokens=0
64
+ [ -z "$used_pct" ] && used_pct=0
65
+
66
+ # Token color by absolute count: <100k green, 100k-500k yellow, >500k red.
67
+ if [ "$used_tokens" -lt 100000 ]; then
68
+ tokens_color="$GREEN"
69
+ elif [ "$used_tokens" -le 500000 ]; then
70
+ tokens_color="$YELLOW"
71
+ else
72
+ tokens_color="$RED"
73
+ fi
74
+
75
+ # Format tokens label + 10-char quota bar in a single awk pass. Each bar block = 10% of the window.
76
+ # rate_pct arrives pre-rounded from jq (or empty string when the field is absent).
77
+ awk_out=$(awk -v t="$used_tokens" -v r="$rate_pct" 'BEGIN {
78
+ if (t == 0) printf "0k\n";
79
+ else if (t >= 100000) printf "%dk\n", int(t/1000 + 0.5);
80
+ else printf "%.1fk\n", t/1000;
81
+
82
+ if (r != "") {
83
+ if (r > 100) r = 100;
84
+ if (r < 0) r = 0;
85
+ filled = int(r / 10);
86
+ bar = "";
87
+ for (i = 0; i < filled; i++) bar = bar "▓";
88
+ for (i = filled; i < 10; i++) bar = bar "░";
89
+ printf "%s\n", bar;
90
+ } else {
91
+ printf "\n";
92
+ }
93
+ }')
94
+
95
+ {
96
+ IFS= read -r tokens_label
97
+ IFS= read -r bar
98
+ } <<EOF
99
+ $awk_out
100
+ EOF
101
+
102
+ # Bar color by rate-limit usage: <50% green, <80% yellow, >=80% red.
103
+ bar_color="$GREEN"
104
+ if [ -n "$rate_pct" ]; then
105
+ if [ "$rate_pct" -lt 50 ]; then
106
+ bar_color="$GREEN"
107
+ elif [ "$rate_pct" -lt 80 ]; then
108
+ bar_color="$YELLOW"
109
+ else
110
+ bar_color="$RED"
111
+ fi
112
+ fi
113
+
114
+ # Time until the rate-limit window resets, formatted as "X hr Y min" / "Y min" / "X day Y hr".
115
+ # Skipped silently when resets_at is missing or non-numeric (forward compat with future schemas).
116
+ reset_label=""
117
+ case "$rate_resets_at" in
118
+ '' | *[!0-9]*) ;;
119
+ *)
120
+ now=$(date +%s)
121
+ secs=$((rate_resets_at - now))
122
+ [ "$secs" -lt 60 ] && secs=60
123
+ total_min=$(((secs + 30) / 60))
124
+ if [ "$total_min" -lt 60 ]; then
125
+ reset_label="${total_min} min"
126
+ elif [ "$total_min" -lt 1440 ]; then
127
+ hr=$((total_min / 60))
128
+ min=$((total_min % 60))
129
+ if [ "$min" -eq 0 ]; then
130
+ reset_label="${hr} hr"
131
+ else
132
+ reset_label="${hr} hr ${min} min"
133
+ fi
134
+ else
135
+ day=$((total_min / 1440))
136
+ hr=$(((total_min % 1440) / 60))
137
+ if [ "$hr" -eq 0 ]; then
138
+ reset_label="${day} day"
139
+ else
140
+ reset_label="${day} day ${hr} hr"
141
+ fi
142
+ fi
143
+ ;;
144
+ esac
145
+
146
+ # Mid-dot separator between segments.
147
+ SEP=" ${GREY}·${RESET} "
148
+
149
+ # Model + effort
150
+ out=""
151
+ if [ -n "$model_display" ]; then
152
+ out="${BLUE}${model_display}${RESET}"
153
+ [ -n "$effort" ] && out="${out} ${GREY}${effort}${RESET}"
154
+ fi
155
+
156
+ # <tokens> [/ <ctx>] (<pct>%)
157
+ ctx_seg="${tokens_color}${tokens_label}${RESET}"
158
+ [ -n "$ctx_size" ] && ctx_seg="${ctx_seg} ${GREY}/ ${ctx_size}${RESET}"
159
+ ctx_seg="${ctx_seg} ${GREY}(${used_pct}%)${RESET}"
160
+ if [ -n "$out" ]; then
161
+ out="${out}${SEP}${ctx_seg}"
162
+ else
163
+ out="${ctx_seg}"
164
+ fi
165
+
166
+ # <bar> (<rate>% used, resets in <hr> hr <min> min) -- only when rate-limit data is present
167
+ if [ -n "$bar" ] && [ -n "$reset_label" ]; then
168
+ quota_seg="${bar_color}${bar}${RESET} ${GREY}(${rate_pct}% used, resets in ${reset_label})${RESET}"
169
+ out="${out}${SEP}${quota_seg}"
170
+ fi
171
+
172
+ printf '%b\n' "$out"
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: totopo-statusline
3
+ description: View, customize, or revert the Claude status line in a totopo container. Use when the user mentions the status line, asks what their token count or model display means, wants to change colors or thresholds, or wants to restore the totopo default.
4
+ ---
5
+
6
+ # totopo-statusline: Manage the Claude status line
7
+
8
+ This skill helps the user inspect, customize, or revert the Claude Code status line shipped with totopo.
9
+
10
+ **Important constants:**
11
+ - The totopo default script is baked into the image at `{{statusline_path}}` (read-only, root-owned).
12
+ - Claude reads its config from `~/.claude/settings.json` (`statusLine.command`).
13
+ - After any change, the user must **restart Claude** for the new status line to take effect.
14
+
15
+ ## Step 1 - Inspect current state
16
+
17
+ Read `~/.claude/settings.json` (treat a missing file or unparseable JSON as `{}`). Look at `.statusLine.command` and classify:
18
+
19
+ - **default-by-omit** - `statusLine` field is absent. totopo will inject the default on the next session start, but it is not active in the running session yet.
20
+ - **totopo-default** - `statusLine.command` equals `{{statusline_path}}`.
21
+ - **custom** - `statusLine.command` is any other value.
22
+
23
+ Tell the user which state they are in, and the exact command path if custom.
24
+
25
+ ## Step 2 - Explain the totopo default render pattern
26
+
27
+ Three segments separated by a mid-dot. Example:
28
+
29
+ ```
30
+ Opus 4.7 high · 174k / 1M (17%) · ▓░░░░░░░░░ (13% used, resets in 3 hr 35 min)
31
+ ```
32
+
33
+ - Model name (blue) + reasoning effort (grey, hidden if unsupported).
34
+ - Tokens used / context window size / percentage. Token count is colored: green below 100k, yellow up to 500k, red beyond.
35
+ - 10-block gauge of the 5-hour rate-limit window (each block = 10%; green below 50%, yellow below 80%, red at or above 80%) followed by current usage and time until reset. Whole segment is hidden for free accounts and before the first API response.
36
+
37
+ ## Step 3 - Ask the user what they want
38
+
39
+ Based on the current state:
40
+
41
+ **If default-by-omit:**
42
+ "Your status line will be the totopo default once the session restarts. Want to install it explicitly now, or customize before next session?"
43
+
44
+ **If totopo-default:**
45
+ "You are using the totopo default. Want to (1) keep it, (2) fork-and-edit a copy to tweak something, or (3) write a new one from scratch?"
46
+
47
+ **If custom:**
48
+ "You have a custom status line at `<command>`. Want to (1) keep it, (2) revert to the totopo default, or (3) modify further?"
49
+
50
+ ## Step 4 - Apply the chosen action
51
+
52
+ ### Install the default explicitly
53
+
54
+ Edit `~/.claude/settings.json` so it contains:
55
+
56
+ ```json
57
+ {
58
+ "statusLine": {
59
+ "type": "command",
60
+ "command": "{{statusline_path}}"
61
+ }
62
+ }
63
+ ```
64
+
65
+ Preserve any other top-level fields the file already has. Tell the user to restart Claude.
66
+
67
+ ### Revert from custom to totopo default
68
+
69
+ Same as install: set `statusLine.command` to `{{statusline_path}}`. Do not delete the user's custom script (it may be at a path like `~/.claude/statusline.sh`); just stop pointing at it. Mention to the user that the old script file is still on disk if they want to keep it for later.
70
+
71
+ ### Fork and edit (customize from the totopo default)
72
+
73
+ Never edit `{{statusline_path}}` directly - it is root-owned and read-only inside the container.
74
+
75
+ 1. Copy the script to a writable location:
76
+ ```bash
77
+ cp {{statusline_path}} ~/.claude/statusline.sh
78
+ chmod +x ~/.claude/statusline.sh
79
+ ```
80
+ 2. Ask the user what they want to change (colors, thresholds, segment order, what to show, what to hide).
81
+ 3. Edit `~/.claude/statusline.sh` per their request.
82
+ 4. Update `~/.claude/settings.json` so `statusLine.command` points to `~/.claude/statusline.sh`.
83
+ 5. Tell the user to restart Claude.
84
+
85
+ ### Write from scratch
86
+
87
+ If the user wants something completely different:
88
+
89
+ 1. Confirm the spec with them - what segments, what colors, what data sources from the input JSON Claude pipes to the script.
90
+ 2. Write a new script to `~/.claude/statusline-<descriptive-name>.sh`, `chmod +x` it.
91
+ 3. Update `~/.claude/settings.json` to point at it.
92
+ 4. Tell the user to restart Claude.
93
+
94
+ ## Notes
95
+
96
+ - Always preserve other fields in `settings.json` when editing. The file may contain unrelated user settings.
97
+ - If `settings.json` does not exist or is unparseable, create it fresh as `{ "statusLine": { ... } }`.
98
+ - The format Claude Code passes via stdin includes `workspace.current_dir`, `model.display_name`, `context_window.used_percentage`, and `context_window.current_usage.{input_tokens, cache_creation_input_tokens, cache_read_input_tokens}`. Use these as the data sources for any custom script.