SLOPSHOPPER

project-status

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

newbandcommandstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /projects ⎿ project-status: Projects band shown. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Reading projects… │ │ updated 5:15:19 PM · ▸ this session · ctrl+x tab then 1-9 = sync · /projects hides │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ project-status: projects 0

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Reading projects… │ │ updated 5:15:19 PM · ▸ this session · ctrl+x tab then 1-9 = sync · /projects hides │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

project-status

/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.

Commands and configuration

  • /projects: show or hide; showing also refreshes.
  • The sync button (or the displayed 1–9 hotkey after focusing the band) submits a user prompt. It does not implement a sync workflow itself.
  • 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'

How it works and limits

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

Installation and development · راهنمای فارسی

Source 4 files
hooks/register.tsx 175 lines
1import { 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}
175
hooks/parse.ts 30 lines
1export 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}
30
hooks/config.ts 14 lines
1// 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}
14
types/index.d.ts 20 lines
1export 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