cru-teams 1.0.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 ADDED
@@ -0,0 +1,84 @@
1
+ <p align="center"><img src="assets/logo.svg" width="128"/></p>
2
+
3
+ # ◫ cru
4
+
5
+ Layout for [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), fixed.
6
+
7
+ Agent teams let multiple Claude Code instances work together in parallel — cru handles the terminal layout so you don't have to.
8
+
9
+ ```
10
+ ╭──────────────────┬────────────────┬────────────────╮
11
+ │ │ │ │
12
+ │ │ ⚡ worker-1 │ ⚡ worker-2 │
13
+ │ │ │ │
14
+ │ lead ├────────────────┼────────────────┤
15
+ │ │ │ │
16
+ │ │ ⚡ worker-3 │ ⚡ worker-4 │
17
+ │ │ │ │
18
+ ╰──────────────────┴────────────────┴────────────────╯
19
+ ```
20
+
21
+ ![demo](assets/demo.gif)
22
+
23
+ ## Terminal setup
24
+
25
+ - **Already using tmux?** You're good.
26
+ - **iTerm2?** Use [`tmux -CC`](https://iterm2.com/documentation-tmux-integration.html) for native pane integration.
27
+ - **Ghostty?** Workers still run in tmux (that's how Claude Code spawns agents), but cru mirrors them into native Ghostty splits via [AppleScript](https://ghostty.org/docs/features/applescript) — so you get Ghostty's UI instead of working inside tmux yourself. Requires Ghostty v1.3.0+.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install -g cru-teams
33
+ ```
34
+ ```bash
35
+ pnpm add -g cru-teams
36
+ ```
37
+ ```bash
38
+ bun add -g cru-teams
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ 1. Set up cru in your project:
44
+
45
+ ```bash
46
+ cru init
47
+ ```
48
+
49
+ 2. In Claude Code, spawn a team:
50
+
51
+ ```
52
+ /cru split the auth module into subtasks and parallelize across workers
53
+ ```
54
+
55
+ That's it — cru creates the panes, applies the grid layout, and your workers are ready to go.
56
+
57
+ ## Use cases
58
+
59
+ **[Parallel feature work](https://code.claude.com/docs/en/agent-teams#when-to-use-agent-teams)** — split subtasks across workers, merge when done
60
+
61
+ ```
62
+ /cru break down the checkout flow into vertical slices, one worker per slice
63
+ ```
64
+ **[Review crew](https://code.claude.com/docs/en/agent-teams#run-a-parallel-code-review)** — workers build, one reviews
65
+
66
+ ```
67
+ /cru 3 review PR #142 — one on security, one on performance, one on test coverage
68
+ ```
69
+ **[Competing hypotheses](https://code.claude.com/docs/en/agent-teams#investigate-with-competing-hypotheses)** — debug faster with multiple theories at once
70
+
71
+ ```
72
+ /cru users report the app crashes on login — each worker investigates a different theory
73
+ ```
74
+
75
+ **[Research spike](https://code.claude.com/docs/en/agent-teams#start-with-research-and-review)** — explore different approaches simultaneously
76
+ ```
77
+ /cru 3 evaluate auth libraries — one on passport, one on lucia, one on arctic
78
+ ```
79
+
80
+ ## Documentation
81
+
82
+ - [CLI reference](CLI.md) — all commands, flags, and examples
83
+ - [Configuration](CONFIG.md) — layout options, config files, resolution order
84
+ - [Testing](TESTING.md) — test structure and how to run tests
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "cru-teams",
3
+ "version": "1.0.0",
4
+ "description": "◫ Manage terminal layouts for Claude Code agent teams",
5
+ "type": "module",
6
+ "bin": {
7
+ "cru": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "skills/"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun src/cli.ts",
15
+ "test": "bun test tests/unit/",
16
+ "test:e2e": "bun test tests/e2e/ --timeout 120000",
17
+ "publish": "npm publish --access public"
18
+ },
19
+ "keywords": [
20
+ "claude-code",
21
+ "cru",
22
+ "tmux",
23
+ "layout"
24
+ ],
25
+ "license": "ISC",
26
+ "dependencies": {
27
+ "incur": "^0.2.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "^1.3.10",
31
+ "@types/node": "^25.3.5"
32
+ }
33
+ }
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: cru
3
+ description: Spawn an agent team with workers arranged in a grid layout. Use when the user wants to create a team of agents.
4
+ argument-hint: "<task description>"
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Spawn Agent Team
9
+
10
+ ## Arguments
11
+
12
+ Parse `$ARGUMENTS` as a single string:
13
+
14
+ - If the **first word is a number**, use it as the worker count and the rest as the task description.
15
+ - If the **first word is NOT a number**, use the entire string as the task description and decide the worker count yourself based on the task (typically 2–5).
16
+
17
+ ## Steps
18
+
19
+ 1. **Determine worker count and task** from `$ARGUMENTS` using the rules above.
20
+
21
+ 2. **Create the team** using TeamCreate.
22
+
23
+ 3. **Spawn workers** using the Agent tool. For each worker (worker-1 through worker-N):
24
+ - Set `team_name` to the team name from step 2
25
+ - Set `name` to "worker-1", "worker-2", etc.
26
+ - Set `subagent_type` to "general-purpose"
27
+ - Set `run_in_background` to true
28
+ - Give each worker its specific task slice in the `prompt`, plus:
29
+ - Context about what other workers are doing
30
+ - An instruction to message teammates to share findings and discuss
31
+
32
+ **IMPORTANT:** Spawn all workers AND apply the grid layout in a **single message** — include all Agent calls AND the Bash call for `cru panes grid` together. This ensures the grid command starts polling immediately while workers are still launching.
33
+
34
+ 4. **Apply grid layout** (in the same message as step 3):
35
+ ```bash
36
+ cru panes grid <team-name> --expect <worker-count>
37
+ ```
38
+ This polls for worker panes (up to 30s) and arranges them in a grid. In Ghostty, it automatically mirrors tmux panes into native splits.
39
+
40
+ If the grid command fails (e.g., not in a tmux session), that's OK — workers still run as background agents with the team bar visible. Tell the user they can start a tmux session for the grid layout.
41
+
42
+ 5. **Report** the team is ready. Tell the user:
43
+ - What each worker is focused on
44
+ - `cru panes close <team-name>` to shut down when done
45
+
46
+ ## Shutdown
47
+
48
+ 1. **Send shutdown requests first** — use `SendMessage` with `type: "shutdown_request"` to each worker. Wait for approvals (or idle notifications).
49
+ 2. **Then close panes** — `cru panes close <team-name>`
50
+ 3. **Then delete team** — `TeamDelete`
51
+
52
+ Closing panes before agents approve kills them mid-process, which causes `TeamDelete` to see "active" members and refuse cleanup.
53
+
54
+ Team data (logs, messages) is preserved after close — reviewable via `cru logs <team>`. Use `cru clean` to remove old teams.
package/src/cli.ts ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bun
2
+ import { Cli } from 'incur'
3
+ import { preflight } from '@/lib/preflight'
4
+
5
+ // Entity commands
6
+ import { teams } from '@/commands/teams'
7
+ import { panes } from '@/commands/panes'
8
+ import { tasks } from '@/commands/tasks'
9
+
10
+ // Layout
11
+ import { config } from '@/commands/config'
12
+
13
+ // Meta
14
+ import { init } from '@/commands/init'
15
+ import { doctor } from '@/commands/doctor'
16
+ import { logs } from '@/commands/logs'
17
+ import { clean } from '@/commands/clean'
18
+
19
+ // Commands that require specific preflight checks
20
+ const PREFLIGHT: Record<string, string[]> = {
21
+ panes: ['pane-session'],
22
+ }
23
+
24
+ Cli.create('cru', {
25
+ description: '◫ Manage pane layouts for Claude Code agent teams',
26
+ version: '1.0.0',
27
+ })
28
+ .use(async (c, next) => {
29
+ const checks = PREFLIGHT[c.command]
30
+ if (checks) {
31
+ const result = preflight(...checks)
32
+ if (!result.ok) {
33
+ const e = result.errors[0]
34
+ return c.error({ code: e.check.toUpperCase(), message: e.message })
35
+ }
36
+ }
37
+ await next()
38
+ })
39
+ // Entity commands
40
+ .command('teams', teams)
41
+ .command('panes', panes)
42
+ .command('tasks', tasks)
43
+ // Config & meta
44
+ .command('config', config)
45
+ .command('init', init)
46
+ .command('doctor', doctor)
47
+ .command('logs', logs)
48
+ .command('clean', clean)
49
+ .serve()
@@ -0,0 +1,78 @@
1
+ import { readdirSync, rmSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { z } from 'incur'
5
+ import { isTeamAlive } from '@/lib/panes'
6
+ import { teamsDir } from '@/lib/paths'
7
+
8
+ export const clean = {
9
+ description: 'Remove old team data from ~/.claude/teams',
10
+ options: z.object({
11
+ days: z.coerce.number().default(7).describe('Remove teams older than N days'),
12
+ all: z.boolean().default(false).describe('Remove all teams'),
13
+ 'dry-run': z.boolean().default(false).describe('Show what would be removed without deleting'),
14
+ }),
15
+ run(c) {
16
+ const dir = teamsDir()
17
+ let teams: string[]
18
+ try {
19
+ teams = readdirSync(dir).filter((d) => {
20
+ try {
21
+ statSync(join(dir, d, 'config.json'))
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ })
27
+ } catch {
28
+ return { removed: 0, teams: [] }
29
+ }
30
+
31
+ const now = Date.now()
32
+ const maxAge = c.options.days * 24 * 60 * 60 * 1000
33
+ const toRemove: Array<{ name: string; age: string }> = []
34
+
35
+ for (const name of teams) {
36
+ const configPath = join(dir, name, 'config.json')
37
+ try {
38
+ const stat = statSync(configPath)
39
+ const age = now - stat.mtimeMs
40
+ const dead = !isTeamAlive(name)
41
+
42
+ if (c.options.all || age > maxAge || dead) {
43
+ const days = Math.floor(age / (24 * 60 * 60 * 1000))
44
+ const reason = dead ? 'dead' : days === 0 ? 'today' : `${days}d ago`
45
+ toRemove.push({ name, age: reason })
46
+ }
47
+ } catch {}
48
+ }
49
+
50
+ if (toRemove.length === 0) {
51
+ if (!c.agent) console.log('Nothing to clean')
52
+ return { removed: 0, teams: [] }
53
+ }
54
+
55
+ if (c.options['dry-run']) {
56
+ if (!c.agent) {
57
+ console.log(`Would remove ${toRemove.length} teams:\n`)
58
+ for (const t of toRemove) console.log(` ${t.name} ${t.age}`)
59
+ }
60
+ return { 'dry-run': true, would_remove: toRemove.length, teams: toRemove }
61
+ }
62
+
63
+ const tasksBaseDir = join(homedir(), '.claude', 'tasks')
64
+ for (const t of toRemove) {
65
+ rmSync(join(dir, t.name), { recursive: true, force: true })
66
+ // Also clean up tasks for this team
67
+ try {
68
+ rmSync(join(tasksBaseDir, t.name), { recursive: true, force: true })
69
+ } catch {}
70
+ }
71
+
72
+ if (!c.agent) {
73
+ console.log(`Removed ${toRemove.length} teams`)
74
+ }
75
+
76
+ return { removed: toRemove.length, teams: toRemove }
77
+ },
78
+ }
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { configPaths, loadConfig } from '@/lib/config'
3
+
4
+ export const config = {
5
+ description: 'Show resolved config (merged defaults + overrides)',
6
+ run() {
7
+ const conf = loadConfig()
8
+ const loaded = configPaths().find((p) => existsSync(p)) || 'defaults'
9
+ return { source: loaded, config: conf }
10
+ },
11
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from 'incur'
2
+ import { hasBinary, getVersion, inTmux, inGhostty, detectTerminal } from '@/lib/env'
3
+
4
+ export const doctor = {
5
+ description: 'Check environment requirements for cru',
6
+ args: z.object({}),
7
+ options: z.object({
8
+ json: z.boolean().default(false).describe('Output as JSON'),
9
+ }),
10
+ run(c) {
11
+ const terminal = detectTerminal()
12
+ const tmuxCmd = terminal === 'iterm2' ? 'tmux -CC' : 'tmux'
13
+
14
+ const checks: Array<{
15
+ name: string
16
+ status: 'ok' | 'fail'
17
+ detail: string
18
+ fix?: string
19
+ }> = []
20
+
21
+ const tmuxPath = hasBinary('tmux')
22
+ if (!tmuxPath) {
23
+ checks.push({
24
+ name: 'tmux',
25
+ status: 'fail',
26
+ detail: 'not installed',
27
+ fix: process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux',
28
+ })
29
+ } else {
30
+ checks.push({ name: 'tmux', status: 'ok', detail: getVersion('tmux -V') || 'installed' })
31
+ }
32
+
33
+ if (tmuxPath && !inTmux() && !inGhostty()) {
34
+ checks.push({ name: 'tmux-session', status: 'fail', detail: 'not inside a tmux session', fix: tmuxCmd })
35
+ } else if (tmuxPath && inTmux()) {
36
+ checks.push({ name: 'tmux-session', status: 'ok', detail: 'active' })
37
+ }
38
+
39
+ const claudePath = hasBinary('claude')
40
+ if (!claudePath) {
41
+ checks.push({ name: 'claude', status: 'fail', detail: 'not installed', fix: 'npm install -g @anthropic-ai/claude-code' })
42
+ } else {
43
+ checks.push({ name: 'claude', status: 'ok', detail: getVersion('claude --version') || 'installed' })
44
+ }
45
+
46
+ // Ghostty native pane support (AppleScript)
47
+ if (terminal === 'ghostty') {
48
+ if (process.platform !== 'darwin') {
49
+ checks.push({ name: 'ghostty', status: 'fail', detail: 'AppleScript only available on macOS' })
50
+ } else {
51
+ try {
52
+ const { ghosttyVersion, isGhosttyScriptable } = require('@/lib/ghostty')
53
+ if (isGhosttyScriptable()) {
54
+ const ver = ghosttyVersion() || 'unknown'
55
+ checks.push({ name: 'ghostty', status: 'ok', detail: `v${ver} (AppleScript enabled)` })
56
+ } else {
57
+ checks.push({ name: 'ghostty', status: 'fail', detail: 'AppleScript not responding', fix: 'Set macos-applescript = true in Ghostty config' })
58
+ }
59
+ } catch {
60
+ checks.push({ name: 'ghostty', status: 'fail', detail: 'cannot connect via AppleScript' })
61
+ }
62
+ }
63
+
64
+ if (inGhostty()) {
65
+ checks.push({ name: 'pane-backend', status: 'ok', detail: 'ghostty (native AppleScript)' })
66
+ } else if (inTmux()) {
67
+ checks.push({ name: 'pane-backend', status: 'ok', detail: 'tmux (inside Ghostty)' })
68
+ }
69
+ }
70
+
71
+ const bunPath = hasBinary('bun')
72
+ if (!bunPath) {
73
+ checks.push({ name: 'bun', status: 'fail', detail: 'not installed', fix: 'curl -fsSL https://bun.sh/install | bash' })
74
+ } else {
75
+ checks.push({ name: 'bun', status: 'ok', detail: `v${getVersion('bun --version')}` })
76
+ }
77
+
78
+ const ok = checks.every((ch) => ch.status === 'ok')
79
+
80
+ return { ok, terminal, tmuxCmd, checks }
81
+ },
82
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'incur'
2
+ import { existsSync, mkdirSync, cpSync } from 'node:fs'
3
+ import { join, dirname } from 'node:path'
4
+ import { CONFIG_NAME, DEFAULTS, writeConfig } from '@/lib/config'
5
+
6
+ export const init = {
7
+ description: 'Set up cru in the current project',
8
+ options: z.object({
9
+ force: z.boolean().default(false).describe('Overwrite existing files'),
10
+ }),
11
+ run(c) {
12
+ const cwd = process.cwd()
13
+ const files: Record<string, string> = {}
14
+
15
+ // 1. Config file
16
+ const configPath = join(cwd, CONFIG_NAME)
17
+ if (!existsSync(configPath) || c.options.force) {
18
+ writeConfig(configPath, DEFAULTS)
19
+ files[CONFIG_NAME] = 'created'
20
+ } else {
21
+ files[CONFIG_NAME] = 'exists'
22
+ }
23
+
24
+ // 2. Install skill to .claude/skills/
25
+ const pkgRoot = dirname(dirname(import.meta.dirname))
26
+ const skillSrc = join(pkgRoot, 'skills', 'cru')
27
+ const skillDest = join(cwd, '.claude', 'skills', 'cru')
28
+
29
+ mkdirSync(join(cwd, '.claude', 'skills'), { recursive: true })
30
+ cpSync(skillSrc, skillDest, { recursive: true })
31
+ files['.claude/skills/cru/'] = existsSync(join(skillDest, 'SKILL.md')) ? 'updated' : 'installed'
32
+
33
+ return { files, tip: 'Use /cru in Claude Code to spawn a team.' }
34
+ },
35
+ }