workstreams-cli 0.5.0__py3-none-any.whl

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.
@@ -0,0 +1,71 @@
1
+ """Workstreams - visually dispatch coding-agent work to subagents in real
2
+ terminal windows and monitor it in one dashboard.
3
+
4
+ Agent-agnostic: works with Claude Code, Codex, OpenCode, Qwen Code,
5
+ MiMoCode, Hermes, Kilo Code, Cline, and any other coding agent you can
6
+ run from a shell.
7
+
8
+ Quick start:
9
+ pip install workstreams-cli
10
+ workstreams init --project myproj --workstreams 3
11
+ workstreams start --cmd "claude"
12
+ workstreams dispatch --workstream 1 --subagent claude-code --issue 42 --prompt "Fix auth"
13
+ workstreams monitor
14
+ """
15
+
16
+ from .models import (
17
+ WorkstreamConfig,
18
+ WorkstreamsConfig,
19
+ WorkstreamStatus,
20
+ SubagentEvent,
21
+ )
22
+ from .config import load_config, save_config, config_path_in, find_config_file
23
+ from .manager import WorkstreamsManager
24
+ from .event_log import EventLog, get_event_log, default_log_dir
25
+ from .notifier import Notifier
26
+ from .subagent_client import (
27
+ subagent_report,
28
+ subagent_started,
29
+ subagent_progress,
30
+ subagent_completed,
31
+ subagent_failed,
32
+ subagent_error,
33
+ subagent_done,
34
+ )
35
+ from .multiplexer import MultiplexerBase, TmuxMultiplexer, ZellijMultiplexer, get_multiplexer
36
+
37
+ __version__ = "0.5.0"
38
+
39
+ __all__ = [
40
+ # models
41
+ "WorkstreamConfig",
42
+ "WorkstreamsConfig",
43
+ "WorkstreamStatus",
44
+ "SubagentEvent",
45
+ # config
46
+ "load_config",
47
+ "save_config",
48
+ "config_path_in",
49
+ "find_config_file",
50
+ # manager
51
+ "WorkstreamsManager",
52
+ # events
53
+ "EventLog",
54
+ "get_event_log",
55
+ "default_log_dir",
56
+ # notifications
57
+ "Notifier",
58
+ # subagent client
59
+ "subagent_report",
60
+ "subagent_started",
61
+ "subagent_progress",
62
+ "subagent_completed",
63
+ "subagent_failed",
64
+ "subagent_error",
65
+ "subagent_done",
66
+ # multiplexers
67
+ "MultiplexerBase",
68
+ "TmuxMultiplexer",
69
+ "ZellijMultiplexer",
70
+ "get_multiplexer",
71
+ ]
workstreams/cli.py ADDED
@@ -0,0 +1,499 @@
1
+ """CLI entry point for workstreams.
2
+
3
+ Usage:
4
+ workstreams <command> [options]
5
+
6
+ Every command accepts --project to pick which project config to use.
7
+ Most commands also accept --json for machine-readable output (useful for
8
+ coding agents that want to parse status).
9
+
10
+ Exit codes:
11
+ 0 success
12
+ 1 general error
13
+ 2 invalid arguments
14
+ 3 git / gh error
15
+ 4 multiplexer unavailable
16
+ 5 workstream not found
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, UTC, timedelta
26
+ from pathlib import Path
27
+ from typing import List, Optional
28
+
29
+ from .config import load_config, save_config, find_config_file
30
+ from .manager import WorkstreamsManager
31
+ from .event_log import get_event_log
32
+ from .subagent_client import subagent_report
33
+ from . import __version__
34
+
35
+
36
+ def _load_manager(args) -> WorkstreamsManager:
37
+ """Build a manager from parsed args."""
38
+ base_path = Path.cwd()
39
+ if getattr(args, "project", None):
40
+ # Look up .workstreams.yaml in cwd first, then fall back to cwd
41
+ config = load_config(base_path, project=args.project)
42
+ # If the user passed --project explicitly, trust it over the file's value
43
+ config.project = args.project
44
+ else:
45
+ config = load_config(base_path)
46
+
47
+ if getattr(args, "multiplexer", None):
48
+ config.multiplexer = args.multiplexer
49
+ if getattr(args, "layout", None):
50
+ config.layout = args.layout
51
+ if getattr(args, "base_branch", None):
52
+ config.base_branch = args.base_branch
53
+
54
+ manager = WorkstreamsManager(config)
55
+
56
+ # Auto-init if no workstreams defined and command needs them
57
+ if not config.workstreams and args.command in ("start", "dispatch", "work", "run", "logs", "sync", "assign"):
58
+ manager.config.workstreams = _default_workstreams(manager, getattr(args, "workstream", 1))
59
+ save_config(manager.config, manager.base_path)
60
+ return manager
61
+
62
+
63
+ def _default_workstreams(manager: WorkstreamsManager, first_id: int = 1):
64
+ from .models import WorkstreamConfig
65
+ return [
66
+ WorkstreamConfig(
67
+ id=first_id,
68
+ name=f"ws{first_id}",
69
+ path=f"worktrees/ws{first_id}",
70
+ branch=f"ws/{first_id}",
71
+ command="",
72
+ env={},
73
+ )
74
+ ]
75
+
76
+
77
+ # =============================================================================
78
+ # Subcommand builders
79
+ # =============================================================================
80
+
81
+ def build_parser() -> argparse.ArgumentParser:
82
+ parser = argparse.ArgumentParser(
83
+ prog="workstreams",
84
+ description="Visually dispatch coding-agent work to subagents in real terminal windows and monitor it in one dashboard.",
85
+ epilog="See `workstreams <command> --help` for per-command options.",
86
+ )
87
+ parser.add_argument("--version", action="version", version=f"workstreams {__version__}")
88
+
89
+ sub = parser.add_subparsers(dest="command")
90
+
91
+ # ---- shared parent for most commands ----
92
+ def common_parent(name: str) -> argparse.ArgumentParser:
93
+ p = sub.add_parser(name, help=f"{name} - see --help")
94
+ p.add_argument("--project", help="Project name (default: from .workstreams.yaml or cwd)")
95
+ p.add_argument("--json", action="store_true", help="Emit JSON output where supported")
96
+ return p
97
+
98
+ # init
99
+ init = common_parent("init")
100
+ init.add_argument("--workstreams", type=int, default=4, help="Number of workstreams to create (default: 4)")
101
+ init.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None, help="Multiplexer type")
102
+ init.add_argument("--layout", choices=["even-horizontal", "even-vertical", "main-horizontal", "tiled"], default=None)
103
+ init.add_argument("--base-branch", default=None, help="Base branch (default: main)")
104
+ init.add_argument("--mode", choices=["worktree", "branch"], default=None, help="worktree (isolated) or branch (shared dir)")
105
+ init.add_argument("--agent", choices=["auto", "claude", "codex", "opencode", "qwen", "generic"], default=None)
106
+ init.add_argument("--force", action="store_true", help="Overwrite existing config")
107
+ init.add_argument("--no-worktrees", action="store_true", help="Skip creating git worktrees (config only)")
108
+
109
+ # start
110
+ start = common_parent("start")
111
+ start.add_argument("--workstream", type=int, help="Start a single workstream by ID")
112
+ start.add_argument("--cmd", help="Override the command sent to each pane")
113
+ start.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
114
+ start.add_argument("--layout", choices=["even-horizontal", "even-vertical", "main-horizontal", "tiled"], default=None)
115
+
116
+ # attach
117
+ attach = common_parent("attach")
118
+ attach.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
119
+ attach.add_argument("--session", help="Override session name")
120
+
121
+ # status
122
+ status = common_parent("status")
123
+ status.add_argument("--workstream", type=int, help="Show one workstream")
124
+ status.add_argument("--live", action="store_true", help="Watch mode (same as monitor)")
125
+ status.add_argument("--once", action="store_true", help="Single frame, exit")
126
+
127
+ # monitor
128
+ monitor = common_parent("monitor")
129
+ monitor.add_argument("--refresh", type=int, default=2, help="Seconds between frames")
130
+ monitor.add_argument("--once", action="store_true", help="Render one frame and exit")
131
+
132
+ # workstream add / remove / cleanup
133
+ ws_sub = sub.add_parser("workstream", help="Manage workstreams (add, remove, cleanup)")
134
+ ws_sub_sub = ws_sub.add_subparsers(dest="ws_command")
135
+
136
+ ws_add = ws_sub_sub.add_parser("add", help="Add a workstream")
137
+ ws_add.add_argument("--project", help="Project name")
138
+ ws_add.add_argument("--name", required=True)
139
+ ws_add.add_argument("--branch", required=True)
140
+ ws_add.add_argument("--path", help="Worktree path (default: worktrees/<name>)")
141
+ ws_add.add_argument("--command", default="", help="Default command for this pane")
142
+ ws_add.add_argument("--json", action="store_true")
143
+
144
+ ws_remove = ws_sub_sub.add_parser("remove", help="Remove a workstream")
145
+ ws_remove.add_argument("--project", help="Project name")
146
+ ws_remove.add_argument("--workstream", type=int, required=True)
147
+ ws_remove.add_argument("--force", action="store_true", help="Delete directory + worktree")
148
+ ws_remove.add_argument("--json", action="store_true")
149
+
150
+ ws_cleanup = ws_sub_sub.add_parser("cleanup", help="Clean up completed workstreams")
151
+ ws_cleanup.add_argument("--project", help="Project name")
152
+ ws_cleanup.add_argument("--workstream", type=int)
153
+ ws_cleanup.add_argument("--all", dest="all_done", action="store_true", help="Clean all workstreams")
154
+ ws_cleanup.add_argument("--force", action="store_true")
155
+ ws_cleanup.add_argument("--json", action="store_true")
156
+
157
+ # dispatch
158
+ dispatch = common_parent("dispatch")
159
+ dispatch.add_argument("--workstream", type=int, required=True)
160
+ dispatch.add_argument("--subagent", required=True, help="Subagent identifier (claude-code, codex, opencode, qwen-code, mimocode, hermes, kilo-code, cline, ... free-form)")
161
+ dispatch.add_argument("--issue", type=int, default=0)
162
+ dispatch.add_argument("--prompt", help="Prompt / task description sent to the pane")
163
+ dispatch.add_argument("--agent", help="Agent binary to invoke (e.g. 'claude', 'codex', 'opencode run'). If omitted, sends --prompt verbatim to the pane.")
164
+ dispatch.add_argument("--wait", action="store_true", help="Block until subagent reports done/failed")
165
+ dispatch.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
166
+
167
+ # work (run agent command directly)
168
+ work = common_parent("work")
169
+ work.add_argument("--workstream", type=int, required=True)
170
+ work.add_argument("--agent", required=True, help="Agent command (claude, codex, opencode, qwen, cline, ...)")
171
+ work.add_argument("--task", required=True, help="Task / prompt for the agent")
172
+ work.add_argument("--subagent", default="agent", help="Identifier used in event log")
173
+ work.add_argument("--issue", type=int, default=0)
174
+ work.add_argument("--wait", action="store_true", help="Block until terminal event")
175
+ work.add_argument("--multiplexer", choices=["tmux", "zellij", "nami", "lmux", "wmux", "herdr"], default=None)
176
+
177
+ # run
178
+ run = common_parent("run")
179
+ run.add_argument("--workstream", type=int, required=True)
180
+ run.add_argument("--cmd", required=True, dest="run_command")
181
+
182
+ # logs
183
+ logs = common_parent("logs")
184
+ logs.add_argument("--workstream", type=int, required=True)
185
+ logs.add_argument("--follow", action="store_true")
186
+ logs.add_argument("--lines", type=int, default=100)
187
+
188
+ # tail
189
+ tail = common_parent("tail")
190
+ tail.add_argument("--workstream", type=int)
191
+ tail.add_argument("--lines", type=int, default=20)
192
+
193
+ # events
194
+ events = common_parent("events")
195
+ events.add_argument("--workstream", type=int)
196
+ events.add_argument("--since", type=int, default=10, help="Minutes back (default: 10)")
197
+ events.add_argument("--type", choices=["started", "progress", "completed", "failed", "error", "done"], help="Filter")
198
+ events.add_argument("--subagent", help="Filter by subagent name")
199
+ events.add_argument("--limit", type=int, default=50)
200
+ events.add_argument("--clear", action="store_true", help="Clear the event log")
201
+
202
+ # event (emit an event from any process - the key agent-integration command)
203
+ event = sub.add_parser("event", help="Emit a subagent event (for subagents / scripts to report)")
204
+ event.add_argument("event_type", choices=["started", "progress", "completed", "failed", "error", "done"])
205
+ event.add_argument("--project", required=True)
206
+ event.add_argument("--workstream", type=int, default=0)
207
+ event.add_argument("--subagent", required=True, help="Subagent identifier (free-form)")
208
+ event.add_argument("--issue", type=int, default=0)
209
+ event.add_argument("--message", default="")
210
+ event.add_argument("--data", help="JSON object of extra data (e.g. '{\"files\": 3}')")
211
+ event.add_argument("--json", action="store_true")
212
+
213
+ # notify
214
+ notify = common_parent("notify")
215
+ notify.add_argument("--title", required=True)
216
+ notify.add_argument("--message", required=True)
217
+ notify.add_argument("--urgency", choices=["low", "normal", "critical"], default="normal")
218
+
219
+ # assign
220
+ assign = common_parent("assign")
221
+ assign.add_argument("--workstream", type=int, required=True)
222
+ assign.add_argument("--issue", type=int, action="append", required=True)
223
+
224
+ # sync
225
+ sync = common_parent("sync")
226
+ sync.add_argument("--workstream", type=int)
227
+ sync.add_argument("--rebase", action="store_true")
228
+
229
+ # pr
230
+ pr = common_parent("pr")
231
+ pr.add_argument("--workstream", type=int, required=True)
232
+ pr.add_argument("--title", required=True)
233
+ pr.add_argument("--body", default="")
234
+ pr.add_argument("--base", help="Target branch (default: config's base_branch)")
235
+ pr.add_argument("--draft", action="store_true")
236
+
237
+ # merge
238
+ merge = common_parent("merge")
239
+ merge.add_argument("--workstream", type=int, required=True)
240
+ merge.add_argument("--method", choices=["merge", "squash", "rebase"], default="squash")
241
+ merge.add_argument("--delete-branch", action="store_true")
242
+ merge.add_argument("--auto", action="store_true")
243
+
244
+ # status-json helper
245
+ _ = parser
246
+ return parser
247
+
248
+
249
+ # =============================================================================
250
+ # Command handlers
251
+ # =============================================================================
252
+
253
+ def _cmd_init(args) -> int:
254
+ manager = _load_manager(args)
255
+ if args.mode:
256
+ manager.config.mode = args.mode
257
+ if args.agent:
258
+ manager.config.agent = args.agent
259
+ if args.no_worktrees:
260
+ # Skip git worktree creation; just write config
261
+ from .models import WorkstreamConfig
262
+ for i in range(1, args.workstreams + 1):
263
+ manager.config.workstreams.append(WorkstreamConfig(
264
+ id=i, name=f"ws{i}", path=f"worktrees/ws{i}",
265
+ branch=f"ws/{i}", command="", env={},
266
+ ))
267
+ save_config(manager.config, manager.base_path)
268
+ print(f"Initialized config only (no worktrees) for '{manager.config.project}'")
269
+ return 0
270
+ ok = manager.init_project(force=args.force, num_workstreams=args.workstreams, agent=args.agent)
271
+ return 0 if ok else 1
272
+
273
+
274
+ def _cmd_start(args) -> int:
275
+ manager = _load_manager(args)
276
+ manager.start(workstream_id=args.workstream, command=args.cmd)
277
+ return 0
278
+
279
+
280
+ def _cmd_attach(args) -> int:
281
+ manager = _load_manager(args)
282
+ manager.attach(multiplexer=args.multiplexer, session=args.session)
283
+ return 0
284
+
285
+
286
+ def _cmd_status(args) -> int:
287
+ manager = _load_manager(args)
288
+ if args.live:
289
+ manager.monitor(refresh=2, once=False)
290
+ return 0
291
+ manager.status(workstream_id=args.workstream, live=False, as_json=args.json or args.once)
292
+ return 0
293
+
294
+
295
+ def _cmd_monitor(args) -> int:
296
+ manager = _load_manager(args)
297
+ manager.monitor(refresh=args.refresh, once=args.once)
298
+ return 0
299
+
300
+
301
+ def _cmd_workstream(args) -> int:
302
+ manager = _load_manager(args)
303
+ if args.ws_command == "add":
304
+ ws = manager.add_workstream(args.name, args.branch, args.path, args.command)
305
+ if args.json:
306
+ print(json.dumps(ws.to_dict(), indent=2))
307
+ return 0
308
+ if args.ws_command == "remove":
309
+ return manager.remove_workstream(args.workstream, force=args.force)
310
+ if args.ws_command == "cleanup":
311
+ manager.cleanup(workstream_id=args.workstream, force=args.force, all_done=args.all_done)
312
+ return 0
313
+ print("Usage: workstreams workstream {add|remove|cleanup} --help")
314
+ return 2
315
+
316
+
317
+ def _cmd_dispatch(args) -> int:
318
+ manager = _load_manager(args)
319
+ # Build the pane command
320
+ prompt = args.prompt or f"Work on issue #{args.issue} as {args.subagent}" if args.issue else f"as {args.subagent}"
321
+ if args.agent:
322
+ pane_cmd = f"{args.agent} {prompt}"
323
+ if args.issue:
324
+ pane_cmd = f"{args.agent} {prompt}"
325
+ else:
326
+ pane_cmd = prompt
327
+
328
+ # Re-use dispatch for logging/notify, then send the actual agent command
329
+ ws = manager.config.workstream(args.workstream)
330
+ if not ws:
331
+ print(f"Workstream {args.workstream} not found", file=sys.stderr)
332
+ return 5
333
+
334
+ from .subagent_client import subagent_started, subagent_report as _report
335
+ subagent_started(manager.config.project, args.workstream, args.subagent, args.issue, prompt)
336
+ manager._append_workstream_log(ws, f"DISPATCH [{args.subagent}] {pane_cmd}")
337
+ manager.notifier.send(f"Workstream {ws.name}", f"{args.subagent} started" + (f" on #{args.issue}" if args.issue else ""))
338
+ print(f"Dispatched {args.subagent} -> workstream {ws.name}" + (f" (issue #{args.issue})" if args.issue else ""))
339
+
340
+ mux = manager._get_multiplexer()
341
+ if mux is not None:
342
+ try:
343
+ mux.send_command(args.workstream, pane_cmd)
344
+ except Exception as e:
345
+ print(f" (warning) pane send failed: {e}", file=sys.stderr)
346
+
347
+ if args.wait:
348
+ return manager._wait_for_done(args.workstream, args.subagent)
349
+ return 0
350
+
351
+
352
+ def _cmd_work(args) -> int:
353
+ manager = _load_manager(args)
354
+ return manager.work(
355
+ args.workstream,
356
+ agent_cmd=args.agent,
357
+ task=args.task,
358
+ subagent=args.subagent,
359
+ issue=args.issue,
360
+ wait=args.wait,
361
+ )
362
+
363
+
364
+ def _cmd_run(args) -> int:
365
+ manager = _load_manager(args)
366
+ ok = manager.run(args.workstream, args.run_command)
367
+ return 0 if ok else 1
368
+
369
+
370
+ def _cmd_logs(args) -> int:
371
+ manager = _load_manager(args)
372
+ manager.logs(args.workstream, follow=args.follow, lines=args.lines)
373
+ return 0
374
+
375
+
376
+ def _cmd_tail(args) -> int:
377
+ manager = _load_manager(args)
378
+ manager.tail_logs(workstream_id=args.workstream, follow=False)
379
+ return 0
380
+
381
+
382
+ def _cmd_events(args) -> int:
383
+ manager = _load_manager(args)
384
+ if args.clear:
385
+ manager.event_log.clear()
386
+ print("Event log cleared.")
387
+ return 0
388
+ events = manager.get_events(
389
+ workstream_id=args.workstream,
390
+ since_minutes=args.since,
391
+ event_type=args.type,
392
+ subagent=args.subagent,
393
+ limit=args.limit,
394
+ )
395
+ if args.json:
396
+ print(json.dumps(events, indent=2, default=str))
397
+ return 0
398
+ for e in events:
399
+ ts = e.get("timestamp", "")[11:19]
400
+ print(f"[{ts}] [{e['subagent']}] ws{e['workstream_id']} #{e['issue']} - {e['event_type']}: {e['message']}")
401
+ if not events:
402
+ print(f"(no events in last {args.since} min)")
403
+ return 0
404
+
405
+
406
+ def _cmd_event(args) -> int:
407
+ """Emit a single event from the calling process (subagent -> main terminal)."""
408
+ data = {}
409
+ if args.data:
410
+ try:
411
+ data = json.loads(args.data)
412
+ except json.JSONDecodeError:
413
+ print(f"Invalid --data JSON: {args.data}", file=sys.stderr)
414
+ return 2
415
+ ok = subagent_report(
416
+ project=args.project,
417
+ workstream_id=args.workstream,
418
+ subagent=args.subagent,
419
+ issue=args.issue,
420
+ event_type=args.event_type,
421
+ message=args.message,
422
+ data=data,
423
+ )
424
+ if args.json:
425
+ print(json.dumps({"ok": ok, "event_type": args.event_type, "subagent": args.subagent}))
426
+ else:
427
+ if ok:
428
+ print(f"event [{args.event_type}] {args.subagent} ws{args.workstream} #{args.issue} -> {args.project}")
429
+ else:
430
+ print("failed to write event", file=sys.stderr)
431
+ return 0 if ok else 1
432
+
433
+
434
+ def _cmd_notify(args) -> int:
435
+ manager = _load_manager(args)
436
+ manager.notify(args.title, args.message, args.urgency)
437
+ return 0
438
+
439
+
440
+ def _cmd_assign(args) -> int:
441
+ manager = _load_manager(args)
442
+ manager.assign(args.workstream, args.issue)
443
+ return 0
444
+
445
+
446
+ def _cmd_sync(args) -> int:
447
+ manager = _load_manager(args)
448
+ return manager.sync(workstream_id=args.workstream, rebase=args.rebase)
449
+
450
+
451
+ def _cmd_pr(args) -> int:
452
+ manager = _load_manager(args)
453
+ return manager.pr(args.workstream, args.title, args.body, args.base, args.draft)
454
+
455
+
456
+ def _cmd_merge(args) -> int:
457
+ manager = _load_manager(args)
458
+ return manager.merge(args.workstream, args.method, args.delete_branch, args.auto)
459
+
460
+
461
+ HANDLERS = {
462
+ "init": _cmd_init,
463
+ "start": _cmd_start,
464
+ "attach": _cmd_attach,
465
+ "status": _cmd_status,
466
+ "monitor": _cmd_monitor,
467
+ "workstream": _cmd_workstream,
468
+ "dispatch": _cmd_dispatch,
469
+ "work": _cmd_work,
470
+ "run": _cmd_run,
471
+ "logs": _cmd_logs,
472
+ "tail": _cmd_tail,
473
+ "events": _cmd_events,
474
+ "event": _cmd_event,
475
+ "notify": _cmd_notify,
476
+ "assign": _cmd_assign,
477
+ "sync": _cmd_sync,
478
+ "pr": _cmd_pr,
479
+ "merge": _cmd_merge,
480
+ }
481
+
482
+
483
+ def main(argv: Optional[List[str]] = None) -> int:
484
+ parser = build_parser()
485
+ args = parser.parse_args(argv)
486
+
487
+ if not args.command:
488
+ parser.print_help()
489
+ return 2
490
+
491
+ handler = HANDLERS.get(args.command)
492
+ if handler is None:
493
+ parser.print_help()
494
+ return 2
495
+ return handler(args)
496
+
497
+
498
+ if __name__ == "__main__":
499
+ sys.exit(main())