@npow/oh-my-claude 0.1.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 +317 -0
- package/bin/omc.js +403 -0
- package/docs/architecture.md +198 -0
- package/docs/segment-contract.md +186 -0
- package/docs/theme-format.md +156 -0
- package/package.json +35 -0
- package/src/cache.js +102 -0
- package/src/color.js +105 -0
- package/src/compositor.js +163 -0
- package/src/config.js +146 -0
- package/src/plugins.js +72 -0
- package/src/runner.js +160 -0
- package/src/segments/achievement.js +68 -0
- package/src/segments/api-timer.js +55 -0
- package/src/segments/battle-log.js +55 -0
- package/src/segments/cat.js +89 -0
- package/src/segments/coffee-cup.js +81 -0
- package/src/segments/commit-msg.js +95 -0
- package/src/segments/context-bar.js +50 -0
- package/src/segments/context-percent.js +40 -0
- package/src/segments/context-tokens.js +52 -0
- package/src/segments/cost-budget.js +43 -0
- package/src/segments/coworker.js +137 -0
- package/src/segments/custom-text.js +25 -0
- package/src/segments/directory.js +75 -0
- package/src/segments/emoji-story.js +99 -0
- package/src/segments/flex-space.js +25 -0
- package/src/segments/fortune-cookie.js +131 -0
- package/src/segments/garden.js +57 -0
- package/src/segments/git-branch.js +36 -0
- package/src/segments/git-status.js +56 -0
- package/src/segments/horoscope.js +134 -0
- package/src/segments/index.js +65 -0
- package/src/segments/lines-changed.js +29 -0
- package/src/segments/model-name.js +28 -0
- package/src/segments/narrator.js +129 -0
- package/src/segments/output-style.js +25 -0
- package/src/segments/rpg-stats.js +119 -0
- package/src/segments/separator-arrow.js +22 -0
- package/src/segments/separator-pipe.js +22 -0
- package/src/segments/separator-space.js +22 -0
- package/src/segments/session-cost.js +72 -0
- package/src/segments/session-timer.js +53 -0
- package/src/segments/smart-nudge.js +97 -0
- package/src/segments/soundtrack.js +133 -0
- package/src/segments/speedrun.js +94 -0
- package/src/segments/stock-ticker.js +71 -0
- package/src/segments/streak.js +131 -0
- package/src/segments/tamagotchi.js +95 -0
- package/src/segments/token-sparkline.js +73 -0
- package/src/segments/version.js +27 -0
- package/src/segments/vibe-check.js +109 -0
- package/src/segments/vim-mode.js +29 -0
- package/src/segments/weather-report.js +88 -0
- package/themes/default.json +59 -0
- package/themes/minimal.json +37 -0
- package/themes/powerline.json +73 -0
package/bin/omc.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/omc.js — oh-my-claude CLI
|
|
4
|
+
// Usage: npx oh-my-claude [command]
|
|
5
|
+
// Commands: install, themes, uninstall, list, validate, create
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PACKAGE_ROOT = join(__dirname, '..');
|
|
15
|
+
const OMC_DIR = join(homedir(), '.claude', 'oh-my-claude');
|
|
16
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
17
|
+
const CONFIG_PATH = join(OMC_DIR, 'config.json');
|
|
18
|
+
const PLUGINS_DIR = join(OMC_DIR, 'plugins');
|
|
19
|
+
|
|
20
|
+
// ─── Colors ──────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const C = {
|
|
23
|
+
reset: '\x1b[0m',
|
|
24
|
+
bold: '\x1b[1m',
|
|
25
|
+
dim: '\x1b[2m',
|
|
26
|
+
cyan: '\x1b[36m',
|
|
27
|
+
green: '\x1b[32m',
|
|
28
|
+
yellow: '\x1b[33m',
|
|
29
|
+
red: '\x1b[31m',
|
|
30
|
+
magenta: '\x1b[35m',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function log(msg = '') { console.log(msg); }
|
|
34
|
+
function info(msg) { log(`${C.cyan}${msg}${C.reset}`); }
|
|
35
|
+
function success(msg) { log(`${C.green}✓${C.reset} ${msg}`); }
|
|
36
|
+
function warn(msg) { log(`${C.yellow}!${C.reset} ${msg}`); }
|
|
37
|
+
|
|
38
|
+
// ─── Prompts ─────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function ask(question, options) {
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
if (options) {
|
|
44
|
+
log(`\n${C.bold}${question}${C.reset}`);
|
|
45
|
+
options.forEach((opt, i) => log(` ${C.cyan}${i + 1}${C.reset}) ${opt}`));
|
|
46
|
+
rl.question(`\nChoose [1-${options.length}]: `, (answer) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
const idx = parseInt(answer, 10) - 1;
|
|
49
|
+
resolve(idx >= 0 && idx < options.length ? options[idx] : options[0]);
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
rl.question(`${question} `, (answer) => {
|
|
53
|
+
rl.close();
|
|
54
|
+
resolve(answer.trim());
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Install ─────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function install() {
|
|
63
|
+
log(`\n${C.bold}${C.cyan} oh-my-claude${C.reset} installer\n`);
|
|
64
|
+
log(`${C.dim}The framework for Claude Code statuslines.${C.reset}\n`);
|
|
65
|
+
|
|
66
|
+
// 1. Choose theme
|
|
67
|
+
const themes = readdirSync(join(PACKAGE_ROOT, 'themes'))
|
|
68
|
+
.filter(f => f.endsWith('.json'))
|
|
69
|
+
.map(f => f.replace('.json', ''));
|
|
70
|
+
|
|
71
|
+
const theme = await ask('Which theme?', themes);
|
|
72
|
+
log(`${C.dim} → ${theme}${C.reset}`);
|
|
73
|
+
|
|
74
|
+
// 2. Budget
|
|
75
|
+
const budgetStr = await ask(`\n${C.bold}Monthly cost budget in USD?${C.reset} (default: 10) `);
|
|
76
|
+
const budget = parseFloat(budgetStr) || 10;
|
|
77
|
+
log(`${C.dim} → $${budget}${C.reset}`);
|
|
78
|
+
|
|
79
|
+
// 3. Create directories
|
|
80
|
+
mkdirSync(OMC_DIR, { recursive: true });
|
|
81
|
+
mkdirSync(join(OMC_DIR, 'plugins'), { recursive: true });
|
|
82
|
+
|
|
83
|
+
// 4. Copy framework files
|
|
84
|
+
const srcDest = join(OMC_DIR, 'src');
|
|
85
|
+
const themesDest = join(OMC_DIR, 'themes');
|
|
86
|
+
cpSync(join(PACKAGE_ROOT, 'src'), srcDest, { recursive: true });
|
|
87
|
+
cpSync(join(PACKAGE_ROOT, 'themes'), themesDest, { recursive: true });
|
|
88
|
+
cpSync(join(PACKAGE_ROOT, 'package.json'), join(OMC_DIR, 'package.json'));
|
|
89
|
+
|
|
90
|
+
// 5. Write user config
|
|
91
|
+
const config = {
|
|
92
|
+
theme,
|
|
93
|
+
segments: {
|
|
94
|
+
'cost-budget': { budget },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
98
|
+
success('Config written to ' + CONFIG_PATH);
|
|
99
|
+
|
|
100
|
+
// 6. Update Claude Code settings.json
|
|
101
|
+
let settings = {};
|
|
102
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
103
|
+
try {
|
|
104
|
+
settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
105
|
+
} catch {
|
|
106
|
+
settings = {};
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
settings.statusLine = {
|
|
113
|
+
type: 'command',
|
|
114
|
+
command: `node ${join(OMC_DIR, 'src', 'runner.js')}`,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
118
|
+
success('Updated ' + SETTINGS_PATH);
|
|
119
|
+
|
|
120
|
+
// 7. Show preview
|
|
121
|
+
log(`\n${C.bold}Preview:${C.reset}\n`);
|
|
122
|
+
|
|
123
|
+
const mockData = JSON.stringify({
|
|
124
|
+
model: { display_name: 'Opus', id: 'claude-opus-4-6' },
|
|
125
|
+
context_window: { used_percentage: 35, context_window_size: 200000, total_input_tokens: 70000, total_output_tokens: 10000 },
|
|
126
|
+
cost: { total_cost_usd: 2.45, total_duration_ms: 900000, total_api_duration_ms: 220000, total_lines_added: 83, total_lines_removed: 21 },
|
|
127
|
+
workspace: { current_dir: process.cwd(), project_dir: process.cwd() },
|
|
128
|
+
session_id: 'preview', version: '2.1.34',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const { execSync } = await import('node:child_process');
|
|
132
|
+
try {
|
|
133
|
+
const preview = execSync(`echo '${mockData}' | node ${join(OMC_DIR, 'src', 'runner.js')}`, { encoding: 'utf8' });
|
|
134
|
+
log(preview);
|
|
135
|
+
} catch {
|
|
136
|
+
log(`${C.dim}(preview unavailable)${C.reset}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log(`\n${C.green}${C.bold}Done!${C.reset} Restart Claude Code to see your new statusline.`);
|
|
140
|
+
log(`${C.dim}Config: ${CONFIG_PATH}`);
|
|
141
|
+
log(`Themes: omc themes`);
|
|
142
|
+
log(`Uninstall: omc uninstall${C.reset}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Themes ──────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function listThemes() {
|
|
148
|
+
log(`\n${C.bold}Available themes:${C.reset}\n`);
|
|
149
|
+
const themesDir = existsSync(join(OMC_DIR, 'themes')) ? join(OMC_DIR, 'themes') : join(PACKAGE_ROOT, 'themes');
|
|
150
|
+
const themes = readdirSync(themesDir).filter(f => f.endsWith('.json'));
|
|
151
|
+
|
|
152
|
+
for (const file of themes) {
|
|
153
|
+
try {
|
|
154
|
+
const theme = JSON.parse(readFileSync(join(themesDir, file), 'utf8'));
|
|
155
|
+
const name = file.replace('.json', '');
|
|
156
|
+
|
|
157
|
+
// Check if active
|
|
158
|
+
let active = false;
|
|
159
|
+
if (existsSync(CONFIG_PATH)) {
|
|
160
|
+
try {
|
|
161
|
+
const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
162
|
+
active = cfg.theme === name;
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const marker = active ? `${C.green} ●${C.reset}` : ' ';
|
|
167
|
+
log(`${marker} ${C.bold}${name}${C.reset} — ${C.dim}${theme.description || ''}${C.reset}`);
|
|
168
|
+
log(` ${C.dim}Lines: ${theme.lines?.length || 0} | Segments: ${[...(theme.lines || [])].flatMap(l => [...(l.left || []), ...(l.right || [])]).length}${C.reset}`);
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
log('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── List Segments ───────────────────────────────
|
|
175
|
+
|
|
176
|
+
async function listSegments() {
|
|
177
|
+
log(`\n${C.bold}Built-in segments:${C.reset}\n`);
|
|
178
|
+
const segDir = existsSync(join(OMC_DIR, 'src', 'segments'))
|
|
179
|
+
? join(OMC_DIR, 'src', 'segments')
|
|
180
|
+
: join(PACKAGE_ROOT, 'src', 'segments');
|
|
181
|
+
|
|
182
|
+
const files = readdirSync(segDir).filter(f => f.endsWith('.js') && f !== 'index.js').sort();
|
|
183
|
+
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
try {
|
|
186
|
+
const mod = await import(join(segDir, file));
|
|
187
|
+
const name = mod.meta?.name || file.replace('.js', '');
|
|
188
|
+
const desc = mod.meta?.description || '';
|
|
189
|
+
const requires = mod.meta?.requires?.length ? ` ${C.yellow}[${mod.meta.requires.join(', ')}]${C.reset}` : '';
|
|
190
|
+
log(` ${C.cyan}${name}${C.reset} — ${desc}${requires}`);
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
log(`\n${C.dim}${files.length} segments available${C.reset}`);
|
|
194
|
+
|
|
195
|
+
// List plugins
|
|
196
|
+
let pluginCount = 0;
|
|
197
|
+
if (existsSync(PLUGINS_DIR)) {
|
|
198
|
+
try {
|
|
199
|
+
const pluginEntries = readdirSync(PLUGINS_DIR).sort();
|
|
200
|
+
const validPlugins = [];
|
|
201
|
+
|
|
202
|
+
for (const entry of pluginEntries) {
|
|
203
|
+
const entryPath = join(PLUGINS_DIR, entry);
|
|
204
|
+
const segmentPath = join(entryPath, 'segment.js');
|
|
205
|
+
try {
|
|
206
|
+
const { statSync: statSyncFs } = await import('node:fs');
|
|
207
|
+
const stat = statSyncFs(entryPath);
|
|
208
|
+
if (!stat.isDirectory()) continue;
|
|
209
|
+
if (!existsSync(segmentPath)) continue;
|
|
210
|
+
|
|
211
|
+
const { pathToFileURL } = await import('node:url');
|
|
212
|
+
const mod = await import(pathToFileURL(segmentPath).href);
|
|
213
|
+
if (!mod.meta || typeof mod.meta.name !== 'string' || typeof mod.render !== 'function') {
|
|
214
|
+
validPlugins.push({
|
|
215
|
+
name: entry,
|
|
216
|
+
desc: `${C.red}(invalid: missing meta.name or render)${C.reset}`,
|
|
217
|
+
path: segmentPath,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
validPlugins.push({
|
|
221
|
+
name: mod.meta.name,
|
|
222
|
+
desc: mod.meta.description || '',
|
|
223
|
+
path: segmentPath,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
validPlugins.push({
|
|
228
|
+
name: entry,
|
|
229
|
+
desc: `${C.red}(error: ${err.message})${C.reset}`,
|
|
230
|
+
path: segmentPath,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (validPlugins.length > 0) {
|
|
236
|
+
log(`\n${C.bold}Plugins:${C.reset}\n`);
|
|
237
|
+
for (const p of validPlugins) {
|
|
238
|
+
log(` ${C.magenta}${p.name}${C.reset} — ${p.desc}`);
|
|
239
|
+
log(` ${C.dim}${p.path}${C.reset}`);
|
|
240
|
+
}
|
|
241
|
+
pluginCount = validPlugins.length;
|
|
242
|
+
log(`\n${C.dim}${pluginCount} plugin(s) found${C.reset}`);
|
|
243
|
+
}
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (pluginCount === 0) {
|
|
248
|
+
log(`\n${C.dim}No plugins installed. Run ${C.reset}${C.cyan}omc create <name>${C.reset}${C.dim} to create one.${C.reset}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
log('');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Create Plugin ───────────────────────────────
|
|
255
|
+
|
|
256
|
+
function createPlugin(name) {
|
|
257
|
+
if (!name || typeof name !== 'string') {
|
|
258
|
+
warn('Usage: omc create <segment-name>');
|
|
259
|
+
log(`\n${C.dim}Example: omc create my-segment${C.reset}\n`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate segment name: lowercase letters, numbers, hyphens only
|
|
264
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
265
|
+
warn(`Invalid segment name: "${name}"`);
|
|
266
|
+
log(`\n${C.dim}Names must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.${C.reset}`);
|
|
267
|
+
log(`${C.dim}Example: my-segment, cpu-usage, weather-v2${C.reset}\n`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const pluginDir = join(PLUGINS_DIR, name);
|
|
272
|
+
const segmentPath = join(pluginDir, 'segment.js');
|
|
273
|
+
|
|
274
|
+
// Check if plugin already exists
|
|
275
|
+
if (existsSync(segmentPath)) {
|
|
276
|
+
warn(`Plugin "${name}" already exists at:`);
|
|
277
|
+
log(` ${C.dim}${segmentPath}${C.reset}\n`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Create plugin directory and segment file
|
|
282
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
283
|
+
|
|
284
|
+
const template = `// ${segmentPath}
|
|
285
|
+
// Custom segment for oh-my-claude.
|
|
286
|
+
// Add "${name}" to your theme's lines array to use it.
|
|
287
|
+
|
|
288
|
+
export const meta = {
|
|
289
|
+
name: '${name}',
|
|
290
|
+
description: 'My custom segment',
|
|
291
|
+
requires: [],
|
|
292
|
+
defaultConfig: {},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Render this segment.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} data - JSON from Claude Code (use optional chaining: data?.model?.display_name)
|
|
299
|
+
* @param {object} config - Per-segment config from your theme/config.json
|
|
300
|
+
* @returns {{ text: string, style: string } | null} Return { text, style } or null to hide
|
|
301
|
+
*/
|
|
302
|
+
export function render(data, config) {
|
|
303
|
+
// Example: show a greeting. Replace this with your own logic.
|
|
304
|
+
// Available data fields:
|
|
305
|
+
// data?.model?.display_name - "Opus", "Sonnet", etc.
|
|
306
|
+
// data?.model?.id - "claude-opus-4-6", etc.
|
|
307
|
+
// data?.context_window?.used_percentage
|
|
308
|
+
// data?.cost?.total_cost_usd
|
|
309
|
+
// data?.workspace?.current_dir
|
|
310
|
+
// data?.session_id
|
|
311
|
+
// data?.version
|
|
312
|
+
//
|
|
313
|
+
// Return null to hide the segment when data is unavailable.
|
|
314
|
+
// Never throw — return null instead.
|
|
315
|
+
|
|
316
|
+
return { text: 'Hello from ${name}!', style: 'cyan' };
|
|
317
|
+
}
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
writeFileSync(segmentPath, template);
|
|
321
|
+
|
|
322
|
+
log(`\n${C.green}${C.bold}Plugin created!${C.reset}\n`);
|
|
323
|
+
success(`File: ${segmentPath}`);
|
|
324
|
+
log(`\n${C.bold}To use it:${C.reset}\n`);
|
|
325
|
+
log(` 1. Edit the segment file to customize it:`);
|
|
326
|
+
log(` ${C.dim}${segmentPath}${C.reset}\n`);
|
|
327
|
+
log(` 2. Add ${C.cyan}"${name}"${C.reset} to a theme's lines array in your config:`);
|
|
328
|
+
log(` ${C.dim}${CONFIG_PATH}${C.reset}\n`);
|
|
329
|
+
log(` Example config.json snippet:`);
|
|
330
|
+
log(` ${C.dim}{`);
|
|
331
|
+
log(` "lines": [`);
|
|
332
|
+
log(` { "left": ["model-name", "${name}"], "right": ["session-cost"] }`);
|
|
333
|
+
log(` ]`);
|
|
334
|
+
log(` }${C.reset}\n`);
|
|
335
|
+
log(` 3. Restart Claude Code to see your segment.\n`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Uninstall ───────────────────────────────────
|
|
339
|
+
|
|
340
|
+
function uninstall() {
|
|
341
|
+
log(`\n${C.bold}Uninstalling oh-my-claude...${C.reset}\n`);
|
|
342
|
+
|
|
343
|
+
// Remove statusLine from settings
|
|
344
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
345
|
+
try {
|
|
346
|
+
const settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
347
|
+
delete settings.statusLine;
|
|
348
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
349
|
+
success('Removed statusLine from ' + SETTINGS_PATH);
|
|
350
|
+
} catch {
|
|
351
|
+
warn('Could not update settings.json');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
log(`\n${C.dim}To fully remove, delete: ${OMC_DIR}${C.reset}`);
|
|
356
|
+
log(`${C.green}Done.${C.reset}\n`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Main ────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
const command = process.argv[2] || 'install';
|
|
362
|
+
|
|
363
|
+
switch (command) {
|
|
364
|
+
case 'install':
|
|
365
|
+
install().catch(err => { console.error(err); process.exit(1); });
|
|
366
|
+
break;
|
|
367
|
+
case 'themes':
|
|
368
|
+
case 'theme':
|
|
369
|
+
listThemes();
|
|
370
|
+
break;
|
|
371
|
+
case 'list':
|
|
372
|
+
case 'segments':
|
|
373
|
+
listSegments().catch(err => { console.error(err); process.exit(1); });
|
|
374
|
+
break;
|
|
375
|
+
case 'create':
|
|
376
|
+
createPlugin(process.argv[3]);
|
|
377
|
+
break;
|
|
378
|
+
case 'uninstall':
|
|
379
|
+
case 'remove':
|
|
380
|
+
uninstall();
|
|
381
|
+
break;
|
|
382
|
+
case 'validate':
|
|
383
|
+
import(join(PACKAGE_ROOT, 'scripts', 'validate.js'));
|
|
384
|
+
break;
|
|
385
|
+
case '--help':
|
|
386
|
+
case '-h':
|
|
387
|
+
case 'help':
|
|
388
|
+
log(`\n${C.bold}oh-my-claude${C.reset} — The framework for Claude Code statuslines\n`);
|
|
389
|
+
log(`${C.bold}Usage:${C.reset} omc <command>\n`);
|
|
390
|
+
log(`${C.bold}Commands:${C.reset}`);
|
|
391
|
+
log(` ${C.cyan}install${C.reset} Install oh-my-claude (interactive wizard)`);
|
|
392
|
+
log(` ${C.cyan}themes${C.reset} List available themes`);
|
|
393
|
+
log(` ${C.cyan}list${C.reset} List all available segments`);
|
|
394
|
+
log(` ${C.cyan}create${C.reset} Create a new plugin segment (omc create <name>)`);
|
|
395
|
+
log(` ${C.cyan}validate${C.reset} Run the segment contract validator`);
|
|
396
|
+
log(` ${C.cyan}uninstall${C.reset} Remove oh-my-claude from Claude Code`);
|
|
397
|
+
log(` ${C.cyan}help${C.reset} Show this help message\n`);
|
|
398
|
+
break;
|
|
399
|
+
default:
|
|
400
|
+
warn(`Unknown command: ${command}`);
|
|
401
|
+
log(`Run ${C.cyan}omc help${C.reset} for available commands.`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How oh-my-claude transforms Claude Code's JSON status data into a styled, multi-line statusline.
|
|
4
|
+
|
|
5
|
+
## Pipeline
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Claude Code (stdin JSON)
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
runner.js Parse stdin, orchestrate pipeline
|
|
12
|
+
|
|
|
13
|
+
v
|
|
14
|
+
config.js Load theme + user overrides, resolve per-segment config
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
segments/*.js Each segment's render(data, config) called
|
|
18
|
+
|
|
|
19
|
+
v
|
|
20
|
+
compositor.js Arrange segments into lines with left/right alignment
|
|
21
|
+
|
|
|
22
|
+
v
|
|
23
|
+
color.js Apply ANSI escape codes from style strings
|
|
24
|
+
|
|
|
25
|
+
v
|
|
26
|
+
stdout Final styled string printed to terminal
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## runner.js
|
|
30
|
+
|
|
31
|
+
The entry point. Responsibilities:
|
|
32
|
+
|
|
33
|
+
1. Read stdin to completion (Claude Code pipes a single JSON blob).
|
|
34
|
+
2. Parse JSON. If parsing fails, output an empty line and exit.
|
|
35
|
+
3. Call `config.js` to resolve the active theme and user overrides.
|
|
36
|
+
4. Discover all segment modules in `src/segments/`.
|
|
37
|
+
5. Check each segment's `meta.requires` against available system commands. Skip segments with missing dependencies.
|
|
38
|
+
6. For each line defined in the theme, call each segment's `render(data, config)`.
|
|
39
|
+
7. Pass rendered segments to `compositor.js` to produce final output.
|
|
40
|
+
8. Write to stdout.
|
|
41
|
+
|
|
42
|
+
All segment render calls are synchronous. The statusline must produce output quickly and exit.
|
|
43
|
+
|
|
44
|
+
## config.js
|
|
45
|
+
|
|
46
|
+
Resolves configuration through a three-layer stack:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Layer 1 (lowest priority): themes/default.json
|
|
50
|
+
Layer 2: themes/<active-theme>.json
|
|
51
|
+
Layer 3 (highest priority): ~/.claude/oh-my-claude/config.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The user config file at `~/.claude/oh-my-claude/config.json` can specify:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"theme": "minimal",
|
|
59
|
+
"segments": {
|
|
60
|
+
"cost": { "precision": 4, "warnAt": 10.0 }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Config resolution for a segment:
|
|
66
|
+
|
|
67
|
+
1. Start with the segment's `meta.defaultConfig`.
|
|
68
|
+
2. Merge the theme's `segments["name"]` on top.
|
|
69
|
+
3. Merge the user's `segments["name"]` on top.
|
|
70
|
+
4. Pass the result as `config` to `render()`.
|
|
71
|
+
|
|
72
|
+
Merging is shallow `Object.assign` -- not deep merge. Keep segment configs flat.
|
|
73
|
+
|
|
74
|
+
## compositor.js
|
|
75
|
+
|
|
76
|
+
Takes an array of line definitions (each with rendered left and right segments) and produces the final output string.
|
|
77
|
+
|
|
78
|
+
### Alignment
|
|
79
|
+
|
|
80
|
+
Each line has a `left` array and a `right` array of rendered segments. The compositor:
|
|
81
|
+
|
|
82
|
+
1. Joins left segments with the theme's separator (default: `" "`).
|
|
83
|
+
2. Joins right segments with the separator.
|
|
84
|
+
3. Detects terminal width from `process.stdout.columns` (falls back to 80).
|
|
85
|
+
4. Pads the space between left and right groups to fill the terminal width.
|
|
86
|
+
5. If the combined width exceeds the terminal, truncates the right group first, then the left group. Truncation adds `...` as an indicator.
|
|
87
|
+
|
|
88
|
+
### Multi-line Output
|
|
89
|
+
|
|
90
|
+
Themes can define multiple lines. The compositor outputs each line separated by `\n`. Claude Code's statusline feature supports multi-line output.
|
|
91
|
+
|
|
92
|
+
### Separator
|
|
93
|
+
|
|
94
|
+
The theme's `separator` field controls the string placed between adjacent segments on the same side. Default is a single space. Themes can use Unicode characters like `|`, `//`, or powerline glyphs.
|
|
95
|
+
|
|
96
|
+
## color.js
|
|
97
|
+
|
|
98
|
+
Parses style strings into ANSI escape sequences.
|
|
99
|
+
|
|
100
|
+
Input: A space-separated string like `"bold cyan"` or `"bg:red white"`.
|
|
101
|
+
|
|
102
|
+
Output: An object with `open` and `close` ANSI strings that wrap the segment text.
|
|
103
|
+
|
|
104
|
+
### Parsing Rules
|
|
105
|
+
|
|
106
|
+
1. Split style string on spaces.
|
|
107
|
+
2. For each token:
|
|
108
|
+
- Modifiers (`bold`, `dim`, `italic`, `underline`, `inverse`, `hidden`, `strikethrough`): map to ANSI SGR codes.
|
|
109
|
+
- Foreground colors (`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`): map to ANSI 30-37.
|
|
110
|
+
- Background colors (`bg:black`, `bg:red`, etc.): map to ANSI 40-47.
|
|
111
|
+
3. Combine all codes into a single `\x1b[...m` open sequence.
|
|
112
|
+
4. Close sequence is always `\x1b[0m` (reset).
|
|
113
|
+
|
|
114
|
+
No 256-color or truecolor support in v1. Standard 8 colors cover the common case.
|
|
115
|
+
|
|
116
|
+
## cache.js
|
|
117
|
+
|
|
118
|
+
Provides a TTL-based cache for shell command output. Segments that run external commands (git, system utilities) MUST use this to avoid re-executing commands on every statusline update.
|
|
119
|
+
|
|
120
|
+
### API
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { cachedExec } from "../cache.js";
|
|
124
|
+
|
|
125
|
+
// Returns stdout as a string, or null if the command fails
|
|
126
|
+
const result = cachedExec(key, command, ttlMs);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Parameter | Type | Description |
|
|
130
|
+
|-----------|------|-------------|
|
|
131
|
+
| `key` | `string` | Cache key. Use a descriptive name like `"git-branch"`. |
|
|
132
|
+
| `command` | `string` | Shell command to execute. |
|
|
133
|
+
| `ttlMs` | `number` | Time-to-live in milliseconds. Cached result is reused within this window. |
|
|
134
|
+
|
|
135
|
+
### Implementation Details
|
|
136
|
+
|
|
137
|
+
- Uses `child_process.execSync` with a timeout (default: 2000ms).
|
|
138
|
+
- Cache storage: files in `/tmp/omc-cache/`. Each key maps to a file containing `{ timestamp, stdout }` as JSON.
|
|
139
|
+
- On cache hit (file exists and age < TTL): returns cached stdout without executing.
|
|
140
|
+
- On cache miss or expired: executes command, writes result, returns stdout.
|
|
141
|
+
- On command failure (non-zero exit, timeout): returns `null`. Does not cache failures.
|
|
142
|
+
- File-based cache survives across statusline invocations within the same shell session.
|
|
143
|
+
|
|
144
|
+
### Why File-Based
|
|
145
|
+
|
|
146
|
+
The statusline runs as a short-lived process on every update. In-memory caches die with the process. File-based caching in `/tmp` persists across invocations and is automatically cleaned by the OS.
|
|
147
|
+
|
|
148
|
+
## External Plugins
|
|
149
|
+
|
|
150
|
+
Users can add custom segments without modifying the framework.
|
|
151
|
+
|
|
152
|
+
### Location
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
~/.claude/oh-my-claude/plugins/<name>/segment.sh
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Contract
|
|
159
|
+
|
|
160
|
+
- The script receives the full stdin JSON on stdin.
|
|
161
|
+
- It must output exactly one line to stdout: the segment text.
|
|
162
|
+
- Exit code 0: segment is shown. Non-zero: segment is hidden.
|
|
163
|
+
- The theme references external plugins by name (e.g., `"my-plugin"`).
|
|
164
|
+
- Styling for external plugins is configured in the theme's `segments` block:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"segments": {
|
|
169
|
+
"my-plugin": { "style": "bold cyan" }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
External plugins do not support the `meta` object. The framework assigns reasonable defaults (no requirements, no default config).
|
|
175
|
+
|
|
176
|
+
### Performance Note
|
|
177
|
+
|
|
178
|
+
External plugins fork a child process on every invocation. They are inherently slower than built-in JS segments. The framework enforces a 2-second timeout on plugin execution.
|
|
179
|
+
|
|
180
|
+
## Dependency Checking
|
|
181
|
+
|
|
182
|
+
When a segment declares `meta.requires = ["git"]`, the framework checks at startup:
|
|
183
|
+
|
|
184
|
+
1. Run `which <dep>` for each dependency.
|
|
185
|
+
2. Cache the result for the lifetime of the process.
|
|
186
|
+
3. If any dependency is missing, skip the segment entirely (treat as null).
|
|
187
|
+
|
|
188
|
+
This prevents error spam from segments that depend on tools not installed on the user's system.
|
|
189
|
+
|
|
190
|
+
## Error Isolation
|
|
191
|
+
|
|
192
|
+
Every segment render call is wrapped in a try/catch. If a segment throws:
|
|
193
|
+
|
|
194
|
+
1. The exception is caught.
|
|
195
|
+
2. The segment is treated as returning `null` (hidden).
|
|
196
|
+
3. No error output contaminates stdout. The statusline is a display-only surface.
|
|
197
|
+
|
|
198
|
+
This guarantee means a single broken segment can never take down the entire statusline.
|