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 +10 -0
- package/dist/lib/agent-context.js +52 -7
- package/dist/lib/constants.js +12 -1
- package/dist/lib/migrate-to-latest.js +17 -1
- package/dist/lib/totopo-yaml.js +4 -6
- package/package.json +1 -1
- package/templates/Dockerfile +7 -0
- package/templates/claude-statusline.sh +172 -0
- package/templates/skills/totopo-statusline/SKILL.md +98 -0
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 {
|
|
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(
|
|
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
|
-
|
|
127
|
-
writeFileSync(filePath, content);
|
|
170
|
+
writeFileEnsuringDir(filePath, content);
|
|
128
171
|
}
|
|
172
|
+
ensureClaudeStatusLine(workspaceDir);
|
|
173
|
+
injectClaudeSkills(workspaceDir);
|
|
129
174
|
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/totopo-yaml.js
CHANGED
|
@@ -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,
|
|
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
|
|
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(
|
|
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
package/templates/Dockerfile
CHANGED
|
@@ -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.
|