Pane showing STATUS.md progress, next task and git state of every project

/plugin install project-status --marketplace 66Hamidreza/claude-code-mods
Shows one row per project above the prompt: task progress, first unfinished task and phase, dirty-file count, and commits ahead of the upstream. /projects toggles the band; it starts hidden.
/projects: show or hide; showing also refreshes.CLAUDE_MODS_SYNC_PHRASE: prompt text, default sync. For another project the message is <phrase> — only project <name> (<dir>).CLAUDE_MODS_PROJECTS_DIR: project parent folder, default ~/projects.CLAUDE_MODS_PROJECTS_FILE: optional JSON registry; unset means no registry is read. Paths support leading ~/.Example registry (directories are relative to the project parent):
[{"id":"demo","name":"Demo","dir":"demo"}]
Example environment before starting Claude:
export CLAUDE_MODS_PROJECTS_DIR=~/projects
export CLAUDE_MODS_SYNC_PHRASE='Update project status'
Adds immediate child folders containing STATUS.md to registry entries. Reads checkbox items only under the exact ## Tasks section; ### headings identify phases. Refreshes at session start, after turns, every 60 seconds, and when shown. The current project appears first. Git queries have a 10-second timeout; unavailable Git/upstream data displays as zero. Discovery is not recursive. Registry entries are trusted input, expected to be an array of {id,name,dir} objects. A missing/unreadable registry is ignored. The assistant determines what the submitted phrase does.
## Tasks
### Build
- [x] Read requirements
- [ ] Implement parser
hooks/register.tsx 175 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Project } from '../types'
5import { parseTasks } from './parse'
6import { projectConfig, syncMessage } from './config'
7
8const REFRESH_MS = 60_000
9const VISIBLE_KEY = 'visible'
10const projects = atom({ plugin: 'project-status', key: 'projects' } as const, [])
11const updatedAt = atom({ plugin: 'project-status', key: 'updatedAt' } as const, 0)
12
13type ProjectEntry = { id: string; name: string; dir: string }
14
15const git = async ($: EngineInterface, dir: string, args: string[]) => {
16 try {
17 const run = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 10_000 })
18 return run.exitCode === 0 ? run.stdout : undefined
19 } catch {
20 return undefined
21 }
22}
23
24const loadProject = async (
25 $: EngineInterface,
26 root: string,
27 cwd: string,
28 entry: ProjectEntry,
29): Promise<Project> => {
30 const dir = `${root}/${entry.dir}`
31 let tasks = parseTasks('')
32 let hasStatus = false
33 try {
34 tasks = parseTasks(await $.fs.read(`${dir}/STATUS.md`))
35 hasStatus = true
36 } catch {}
37 const porcelain = await git($, dir, ['status', '--porcelain'])
38 const ahead = await git($, dir, ['rev-list', '--count', '@{u}..HEAD'])
39
40 return {
41 id: entry.id,
42 name: entry.name,
43 dir,
44 ...tasks,
45 dirty: porcelain ? porcelain.split('\n').filter(Boolean).length : 0,
46 ahead: Number(ahead?.trim() ?? 0) || 0,
47 hasStatus,
48 isCurrent: cwd === dir || cwd.startsWith(`${dir}/`),
49 }
50}
51
52const refresh = async ($: EngineInterface) => {
53 const home = (await $.env.get('HOME')) ?? ''
54 const { root, projectsFile } = projectConfig(
55 home,
56 await $.env.get('CLAUDE_MODS_PROJECTS_DIR'),
57 await $.env.get('CLAUDE_MODS_PROJECTS_FILE'),
58 )
59 const cwd = await $.session.cwd()
60 let entries: ProjectEntry[] = []
61 if (projectsFile) {
62 try {
63 entries = JSON.parse(await $.fs.read(projectsFile))
64 } catch {}
65 }
66 // Projects with a STATUS.md that the optional registry does not list yet.
67 const known = new Set(entries.map(entry => entry.dir))
68 for (const item of await $.fs.list(root).catch(() => [])) {
69 if (item.kind !== 'dir' || known.has(item.name)) continue
70 if (await $.fs.exists(`${root}/${item.name}/STATUS.md`)) {
71 entries.push({ id: item.name, name: item.name, dir: item.name })
72 }
73 }
74 const list = await Promise.all(entries.map(entry => loadProject($, root, cwd, entry)))
75 // The project this session runs in goes first.
76 list.sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent))
77 await update($, projects, () => list)
78 await update($, updatedAt, () => Date.now())
79 const unsynced = list.filter(project => project.dirty > 0 || project.ahead > 0).length
80 $.ui.status(`projects ${list.length}${unsynced ? ` · ${unsynced} unsynced` : ''}`)
81}
82
83const bar = (done: number, total: number, width = 10) => {
84 const filled = total ? Math.round((done / total) * width) : 0
85 return '█'.repeat(filled) + '░'.repeat(width - filled)
86}
87
88const syncProject = async ($: EngineInterface, project: Project) =>
89 $.prompt.submit({
90 text: syncMessage(await $.env.get('CLAUDE_MODS_SYNC_PHRASE'), project),
91 asUser: true,
92 })
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'projects',
98 description: 'Show or hide the status band of every project',
99 })
100 void refresh($)
101 $.clock.every(REFRESH_MS, () => void refresh($))
102
103 return next(e)
104 })
105
106 on('command.run', { command: 'projects' }, async $ => {
107 const isVisible = !((await $.store.get(VISIBLE_KEY)) ?? false)
108 await $.store.set(VISIBLE_KEY, isVisible)
109 if (isVisible) await refresh($)
110 $.ui.invalidate('ui.render')
111
112 return { text: isVisible ? 'Projects band shown.' : 'Projects band hidden.' }
113 })
114
115 on('turn.complete', async ($, e, next) => {
116 const ran = await next(e)
117 void refresh($)
118
119 return ran
120 })
121
122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123 if (e.props.hasSurvey || !(await $.store.get(VISIBLE_KEY))) return next(e)
124
125 const { Box, Button, Text } = $.ui.resolve(e)
126 const list = await read($, projects)
127 const at = await read($, updatedAt)
128 const nameWidth = Math.max(8, ...list.map(project => project.name.length))
129
130 return (
131 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
132 {list.length === 0 && <Text dimColor>Reading projects…</Text>}
133 {list.map((project, index) => {
134 const complete = project.total > 0 && project.done === project.total
135 const unsynced = project.dirty > 0 || project.ahead > 0
136 const sync = [
137 project.dirty ? `${project.dirty} changed` : '',
138 project.ahead ? `${project.ahead} unpushed` : '',
139 ].filter(Boolean).join(' · ')
140 const nextLine = project.nextTask
141 ? `${project.phase ? `[${project.phase}] ` : ''}${project.nextTask}`
142 : ''
143
144 return (
145 <Box key={project.id} flexDirection="row" gap={1}>
146 <Text color={project.isCurrent ? 'magenta' : undefined} bold>
147 {`${project.isCurrent ? '▸' : ' '} ${project.name.padEnd(nameWidth)}`}
148 </Text>
149 <Text color={complete ? 'green' : 'cyan'}>{bar(project.done, project.total)}</Text>
150 <Text>{(project.hasStatus ? `${project.done}/${project.total}` : '—').padEnd(7)}</Text>
151 <Text color="yellow">{sync.padEnd(22)}</Text>
152 <Box flexGrow={1} flexShrink={1}>
153 <Text dimColor wrap="truncate-end">{nextLine}</Text>
154 </Box>
155 <Button
156 key={`sync-${project.id}`}
157 label={`${index + 1} sync`}
158 hotkey={index < 9 ? String(index + 1) : undefined}
159 variant={unsynced ? 'primary' : 'secondary'}
160 dimColor={!unsynced}
161 onPress={() => void syncProject($, project)}
162 />
163 </Box>
164 )
165 })}
166 {at > 0 && (
167 <Text dimColor>
168 {`updated ${new Date(at).toLocaleTimeString()} · ▸ this session · ctrl+x tab then 1-9 = sync · /projects hides`}
169 </Text>
170 )}
171 </Box>
172 )
173 })
174}
175hooks/parse.ts 30 lines1export type TaskSummary = { done: number; total: number; phase?: string; nextTask?: string }
2
3// Reads the `## Tasks` section of a STATUS.md: checkbox counts and the first open task with its `###` heading.
4export const parseTasks = (markdown: string): TaskSummary => {
5 const summary: TaskSummary = { done: 0, total: 0 }
6 let inTasks = false
7 let heading: string | undefined
8 for (const line of markdown.split('\n')) {
9 if (/^## /.test(line)) {
10 inTasks = /^## Tasks\b/.test(line)
11 continue
12 }
13 if (!inTasks) continue
14 const sub = line.match(/^###\s+(.*)/)
15 if (sub) {
16 heading = (sub[1] ?? '').trim()
17 continue
18 }
19 const box = line.match(/^\s*[-*] \[([ xX])\]\s+(.*)/)
20 if (!box) continue
21 summary.total++
22 if (box[1] !== ' ') summary.done++
23 else if (summary.nextTask === undefined) {
24 summary.nextTask = (box[2] ?? '').trim()
25 summary.phase = heading
26 }
27 }
28 return summary
29}
30hooks/config.ts 14 lines1// Resolve environment values at use time, including ~ in configured paths.
2export const projectConfig = (home: string, root?: string, file?: string) => {
3 const expand = (path: string) => path === '~' ? home : path.replace(/^~\//, `${home}/`)
4 return { root: expand(root || `${home}/projects`), projectsFile: file ? expand(file) : undefined }
5}
6
7export const syncMessage = (
8 phrase: string | undefined,
9 project: { isCurrent: boolean; name: string; dir: string },
10) => {
11 const text = phrase || 'sync'
12 return project.isCurrent ? text : `${text} — only project ${project.name} (${project.dir})`
13}
14types/index.d.ts 20 lines1export type Project = {
2 id: string
3 name: string
4 dir: string
5 done: number
6 total: number
7 phase?: string
8 nextTask?: string
9 dirty: number
10 ahead: number
11 hasStatus: boolean
12 isCurrent: boolean
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'project-status': { projects: Project[]; updatedAt: number }
18 }
19}
20