open-wadah 1.0.6 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # open-wadah
2
2
 
3
- Open Wadah CLI for managing tasks in a shared workspace.
3
+ Open Wadah CLI — shared task board for humans and agents. Use it from the terminal or from Cursor, Claude Code, and Kimi.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,22 +8,13 @@ Open Wadah CLI for managing tasks in a shared workspace.
8
8
  npm install -g open-wadah
9
9
  ```
10
10
 
11
- Commands are available as `wadah`, `tm`, and `ow`.
11
+ Commands: `wadah`, `tm`, `ow` (same CLI).
12
12
 
13
- ## Quick Start
13
+ ## Quick start
14
14
 
15
- 1. Make sure the API is reachable (default: `https://api.openwadah.com`).
16
- 2. Sign up or log in:
17
-
18
- ```bash
19
- wadah signup
20
- wadah login
21
- ```
22
-
23
- `wadah login` uses browser sign-in by default.
24
- Use `wadah login --password` for terminal email/password.
25
-
26
- 3. Use the CLI:
15
+ 1. API must be reachable (default: `https://api.openwadah.com`). Override: `TASK_MANAGER_API_URL` or `--api <url>`.
16
+ 2. Sign in: `wadah login` (browser) or `wadah login --password` (terminal).
17
+ 3. Use the board:
27
18
 
28
19
  ```bash
29
20
  wadah open
@@ -31,55 +22,69 @@ wadah add "Fix login issue"
31
22
  wadah complete <task-id>
32
23
  ```
33
24
 
34
- ## Useful Commands
25
+ ## Commands
26
+
27
+ | Command | Description |
28
+ |--------|-------------|
29
+ | **Auth** | `login`, `signup`, `logout`, `whoami` |
30
+ | **Tasks** | `open`, `list`, `search`, `requested`, `add`, `complete`, `reopen`, `view`, `update`, `move`, `assign`, `comment`, `delete` |
31
+ | **Relationships** | `add --blocks <id>`, `add --blocked-by <id>`, `update --blocks <id>` |
32
+ | **Board** | `boards --json`, `buckets --json`, `assignees --json`; `board create/delete`, `bucket create/update/delete`, `assignee create/update/delete` |
33
+ | **Files** | `folders`, `files`, `folder create` / `mkdir`, `upload` |
34
+ | **Calendar** | `calendar` (list; `--assignee`, `--task`, `--from`, `--to`), `calendar add`, `calendar update <id>`, `calendar delete <id>` |
35
+ | **Docs** | `docs` (list), `doc create [title]`, `doc show <id>`, `doc update <id>`, `doc delete <id>` |
36
+ | **Agent tokens** | `agent-tokens` (list), `agent-token create [name]`, `agent-token delete <id>` |
37
+ | **Workspace** | `state`, `members`, `invite`, `config` |
38
+ | **Other** | `do "<sentence>"` (natural language, needs `OPENAI_API_KEY`), `doctor`, `completion [bash\|zsh]` |
35
39
 
36
- - `wadah --help`
37
- - `wadah whoami`
38
- - `wadah doctor`
39
- - `wadah list --open`
40
- - `wadah move <task-id> <bucket>`
41
- - `wadah update <task-id> --title "New title"`
42
- - `wadah state`
40
+ Run `wadah --help` for full list and options.
43
41
 
44
- ## Global Flags
42
+ ## Shell completion
45
43
 
46
- - `--profile <name>`: use a named profile (defaults to `default`)
47
- - `--token <token>`: use a token for this command only
48
- - `--json`: force machine-readable JSON output
49
- - `--quiet`: silence non-error output (also machine-friendly)
44
+ **Bash:**
50
45
 
51
- ## Stable Error Codes
46
+ ```bash
47
+ wadah completion bash >> ~/.bashrc
48
+ source ~/.bashrc
49
+ ```
52
50
 
53
- When `--json` is enabled (or output is non-interactive), errors return:
51
+ **Zsh:**
54
52
 
55
- ```json
56
- {"error":{"code":"auth_error","message":"Not authenticated"}}
53
+ ```bash
54
+ wadah completion zsh >> ~/.zshrc
55
+ source ~/.zshrc
57
56
  ```
58
57
 
59
- Common codes:
58
+ ## Agent / AI use
60
59
 
61
- - `auth_error`
62
- - `validation_error`
63
- - `network_error`
64
- - `api_error`
65
- - `server_error`
66
- - `authorization_pending`
67
- - `invalid_device_code`
68
- - `token_expired`
69
- - `login_timeout`
60
+ - Set `TASK_MANAGER_TOKEN` (create a token: `wadah agent-token create "My Agent"`).
61
+ - Use `--json` for machine-readable output: `wadah open --json`, `wadah list --json`, `wadah buckets --json`.
62
+ - Natural language: `wadah do "add a task to fix the bug"` (requires `OPENAI_API_KEY`).
63
+ - In Cursor/Claude/Kimi: see **WADAH_CLI.md** in the repo root for a short reference.
64
+ - For autonomous 24/7 agents with GitHub Actions: see **AGENTS.md** in the repo root.
70
65
 
71
- ## API URL Override
66
+ ## Global flags
72
67
 
73
- Use env var:
68
+ - `--api <url>` — API base URL
69
+ - `--profile <name>` — config profile
70
+ - `--token <token>` — auth token for this run
71
+ - `--json` — JSON output
72
+ - `--quiet` — minimal output
74
73
 
75
- ```bash
76
- TASK_MANAGER_API_URL=https://api.openwadah.com wadah open
77
- ```
74
+ ## Develop & test
78
75
 
79
- Or pass per command:
76
+ From the repo root:
80
77
 
81
78
  ```bash
82
- wadah --api https://api.openwadah.com open
79
+ npm run install:all
80
+ npm run tm -- --help
81
+ cd task-manager-cli && npm test
83
82
  ```
84
83
 
85
- Use your own API URL if self-hosting (for example `https://api.yourcompany.com`).
84
+ ## Error codes (with `--json`)
85
+
86
+ - `auth_error` — not authenticated
87
+ - `validation_error` — bad input
88
+ - `network_error` — request failed
89
+ - `api_error` — API returned an error
90
+ - `config` — missing config (e.g. OPENAI_API_KEY for `do`)
package/cli.js CHANGED
@@ -6,8 +6,9 @@ import Conf from 'conf'
6
6
  import { input, password as promptPassword } from '@inquirer/prompts'
7
7
  import { webcrypto } from 'node:crypto'
8
8
  import { spawn } from 'node:child_process'
9
- import { readFileSync } from 'node:fs'
10
- import { dirname, resolve } from 'node:path'
9
+ import { readFileSync, existsSync, createReadStream } from 'node:fs'
10
+ import FormData from 'form-data'
11
+ import { dirname, resolve, basename } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
12
13
 
13
14
  const randomUUID = () => webcrypto.randomUUID()
@@ -180,8 +181,8 @@ function printTaskList(tasks, state) {
180
181
  }
181
182
 
182
183
  function handleError(err) {
183
- const msg = err.message ?? 'Unexpected error'
184
- const code = err.code ?? 'unknown_error'
184
+ const msg = err.message ?? 'Unexpected error'
185
+ const code = err.code ?? 'unknown_error'
185
186
  const authLike =
186
187
  code === 'auth_error' ||
187
188
  msg.toLowerCase().includes('missing token') ||
@@ -194,6 +195,17 @@ function handleError(err) {
194
195
  console.error(chalk.red('\n✗ Not authenticated.'))
195
196
  console.error(chalk.gray(' Humans: wadah login'))
196
197
  console.error(chalk.gray(' Agents: TASK_MANAGER_TOKEN=<token> wadah open\n'))
198
+ } else if (code === 'network_error') {
199
+ console.error(chalk.red('\n✗ Network error — could not reach the API.'))
200
+ console.error(chalk.gray(` Check your connection or API URL: ${getApiBase()}`))
201
+ console.error(chalk.gray(' Run: wadah doctor\n'))
202
+ } else if (code === 'server_error') {
203
+ console.error(chalk.red('\n✗ Server error — something went wrong on the API side.'))
204
+ console.error(chalk.gray(' Try again in a moment. If it persists, check api.openwadah.com status.\n'))
205
+ } else if (code === 'api_error' && msg.toLowerCase().includes('not found')) {
206
+ console.error(chalk.red('\n✗ Not found.'))
207
+ console.error(chalk.gray(' The resource may not exist, or this endpoint may not be available for agent tokens.'))
208
+ console.error(chalk.gray(' Run: wadah doctor\n'))
197
209
  } else {
198
210
  console.error(chalk.red(`\n✗ ${msg}\n`))
199
211
  }
@@ -203,12 +215,11 @@ function handleError(err) {
203
215
  async function resolveMyAssigneeId(state) {
204
216
  try {
205
217
  const me = await api('/api/auth/me')
218
+ if (me.assigneeId) return me.assigneeId
206
219
  const userId = me.user?.id
207
- // Match by user_id field on assignee (if backend returns it)
208
220
  const byUserId = state.assignees.find((a) => a.user_id === userId)
209
221
  if (byUserId) return byUserId.id
210
222
  } catch {}
211
- // Fallback: 'Me' assignee
212
223
  return state.assignees.find((a) => a.name === 'Me')?.id ?? null
213
224
  }
214
225
 
@@ -262,10 +273,117 @@ function isMachineOutput() {
262
273
  return runtimeJsonOutput || runtimeQuietOutput || !process.stdout.isTTY
263
274
  }
264
275
 
276
+ // ── session state cache ───────────────────────────────────────────────────────
277
+ let _cachedState = null
278
+ async function getState() {
279
+ if (!_cachedState) _cachedState = await api('/api/state')
280
+ return _cachedState
281
+ }
282
+
265
283
  function sleep(ms) {
266
284
  return new Promise((resolve) => setTimeout(resolve, ms))
267
285
  }
268
286
 
287
+ // ── natural language (do) ─────────────────────────────────────────────────────
288
+
289
+ const DO_SYSTEM_PROMPT = `You are a parser for the "wadah" CLI (task board). Given the user's natural language request, output exactly one JSON object with no markdown or extra text:
290
+ {"command": "<command>", "args": [<array of string arguments>]}
291
+
292
+ Available commands and their args (use these exact command names):
293
+ - add: args = [task title string]. Example: add a task "Fix login" -> {"command": "add", "args": ["Fix login"]}
294
+ - folder create (or mkdir): args = [folder name]. Example: create a folder called Reports -> {"command": "folder create", "args": ["Reports"]}
295
+ - upload: args = [file path]. Optional: include "--folder", "<folder-id>" in args if user says to put in a folder. Example: upload report.pdf -> {"command": "upload", "args": ["report.pdf"]}
296
+ - open: args = []. List open tasks.
297
+ - list: args = []. List all tasks.
298
+ - complete: args = [task id]. Mark task done. Example: complete task abc123 -> {"command": "complete", "args": ["abc123"]}
299
+ - requested: args = []. List tasks I requested.
300
+ - view: args = [task id]. Show task details.
301
+ - boards: args = []. List boards.
302
+ - buckets: args = []. List columns.
303
+ - assignees: args = []. List assignees.
304
+ - state: args = []. Full state JSON.
305
+ - move: args = [task id, bucket name or id]. Move task to another column.
306
+ - assign: args = [task id, assignee name]. Assign a task.
307
+ - whoami: args = []. Show current user/workspace.
308
+ - folders: args = []. List folders.
309
+ - files: args = []. List files; optionally include "--folder", "<id>" in args.
310
+ - members: args = []. List workspace members.
311
+ - calendar: args = []. List calendar events.
312
+ - calendar add: args = []; include "--title", "<t>", "--start", "<iso>", "--end", "<iso>" for new event (start/end required).
313
+ - docs: args = []. List workspace docs.
314
+ - doc create: args = [title]. Create a doc. Optional: "--parent", "<id>", "--emoji", "<char>".
315
+ - doc show: args = [doc id]. Show one doc.
316
+ - doc update: args = [doc id]. Optional: "--title", "<t>", "--content", "<json>", "--emoji", "--parent", "<id>".
317
+ - doc delete: args = [doc id]. Delete a doc.
318
+ - agent-tokens: args = []. List agent tokens.
319
+ - agent-token create: args = [name]. Create agent token; optional "--assignee-name", "<text>".
320
+ - agent-token delete: args = [token id]. Revoke token.
321
+ - board create: args = [board name]. Create board.
322
+ - board delete: args = [board id]. Delete board.
323
+ - bucket create: args = [column title]. Optional "--board", "<id>".
324
+ - bucket update: args = [bucket id]. Optional "--title", "<t>", "--order", "<n>".
325
+ - bucket delete: args = [bucket id]. Delete column.
326
+ - assignee create: args = [name]. Optional "--type", "human" or "agent".
327
+ - assignee update: args = [assignee id]. Optional "--name", "--type".
328
+ - assignee delete: args = [assignee id]. Delete assignee.
329
+
330
+ If the request is unclear or not a valid CLI action, return {"command": "", "args": []}. Output only the JSON object.`
331
+
332
+ async function parseNaturalLanguage(message) {
333
+ const apiKey = process.env.OPENAI_API_KEY || process.env.WADAH_OPENAI_API_KEY
334
+ if (!apiKey || !apiKey.trim()) {
335
+ throw new CliError('config', 'Set OPENAI_API_KEY or WADAH_OPENAI_API_KEY to use natural language (wadah do "...")')
336
+ }
337
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Content-Type': 'application/json',
341
+ Authorization: `Bearer ${apiKey.trim()}`,
342
+ },
343
+ body: JSON.stringify({
344
+ model: 'gpt-4o-mini',
345
+ messages: [
346
+ { role: 'system', content: DO_SYSTEM_PROMPT },
347
+ { role: 'user', content: String(message).trim() },
348
+ ],
349
+ max_tokens: 200,
350
+ temperature: 0,
351
+ }),
352
+ })
353
+ const data = await res.json().catch(() => ({}))
354
+ if (!res.ok) {
355
+ const err = data.error?.message || data.message || res.statusText
356
+ throw new CliError('api_error', `LLM request failed: ${err}`)
357
+ }
358
+ const text = data.choices?.[0]?.message?.content?.trim() || ''
359
+ const jsonMatch = text.match(/\{[\s\S]*\}/)
360
+ const raw = jsonMatch ? jsonMatch[0] : text
361
+ let parsed
362
+ try {
363
+ parsed = JSON.parse(raw)
364
+ } catch (_) {
365
+ throw new CliError('api_error', 'Could not parse LLM response as JSON')
366
+ }
367
+ const cmd = parsed.command && String(parsed.command).trim()
368
+ const args = Array.isArray(parsed.args) ? parsed.args.map(String) : []
369
+ return { command: cmd, args }
370
+ }
371
+
372
+ function runResolvedCommand(command, args) {
373
+ const cliPath = resolve(__dirname, 'cli.js')
374
+ const childArgs = [cliPath]
375
+ if (runtimeApiBase) childArgs.push('--api', runtimeApiBase)
376
+ if (runtimeToken) childArgs.push('--token', runtimeToken)
377
+ if (runtimeProfile) childArgs.push('--profile', runtimeProfile)
378
+ const cmdParts = String(command).split(/\s+/).filter(Boolean)
379
+ childArgs.push(...cmdParts, ...args)
380
+ return new Promise((resolve, reject) => {
381
+ const child = spawn(process.execPath, childArgs, { stdio: 'inherit', env: process.env, shell: false })
382
+ child.on('error', reject)
383
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Exit ${code}`))))
384
+ })
385
+ }
386
+
269
387
  function openBrowser(url) {
270
388
  let cmd
271
389
  let args
@@ -308,6 +426,27 @@ program.hook('preAction', (_, actionCommand) => {
308
426
  runtimeQuietOutput = Boolean(opts.quiet)
309
427
  })
310
428
 
429
+ // ── do (natural language) ──────────────────────────────────────────────────────
430
+
431
+ program
432
+ .command('do <message>')
433
+ .description('Run a command from natural language (e.g. "add a task to fix the login bug")')
434
+ .action(async (message) => {
435
+ try {
436
+ const { command, args } = await parseNaturalLanguage(message)
437
+ if (!command) {
438
+ console.error(chalk.yellow('\nCould not understand. Try a direct command, e.g:\n wadah add "Fix bug"\n wadah folder create "Reports"\n wadah open\n'))
439
+ process.exit(1)
440
+ }
441
+ if (!runtimeQuietOutput && process.stdout.isTTY) {
442
+ console.log(chalk.gray(`Running: wadah ${command} ${args.join(' ')}\n`))
443
+ }
444
+ await runResolvedCommand(command, args)
445
+ } catch (err) {
446
+ handleError(err)
447
+ }
448
+ })
449
+
311
450
  // ── tm login ──────────────────────────────────────────────────────────────────
312
451
 
313
452
  program
@@ -563,7 +702,7 @@ program
563
702
  .option('--completed', 'Show completed tasks instead')
564
703
  .action(async (opts) => {
565
704
  try {
566
- const state = await api('/api/state')
705
+ const state = await getState()
567
706
  let tasks = state.tasks.filter((t) => !!t.completed === !!opts.completed)
568
707
 
569
708
  if (!opts.all && !opts.assignee) {
@@ -602,7 +741,7 @@ program
602
741
  if (opts.open && opts.completed) {
603
742
  return fail('Use either --open or --completed, not both.')
604
743
  }
605
- const state = await api('/api/state')
744
+ const state = await getState()
606
745
  let tasks = state.tasks
607
746
  if (opts.open) tasks = tasks.filter((t) => !t.completed)
608
747
  if (opts.completed) tasks = tasks.filter((t) => !!t.completed)
@@ -627,6 +766,48 @@ program
627
766
  } catch (err) { handleError(err) }
628
767
  })
629
768
 
769
+ // ── tm search ─────────────────────────────────────────────────────────────────
770
+
771
+ program
772
+ .command('search <query>')
773
+ .description('Search tasks by title, notes or tags')
774
+ .option('--board <name>', 'Limit search to a specific board')
775
+ .option('--assignee <name>', 'Limit search to a specific assignee')
776
+ .option('--open', 'Only open tasks (default)')
777
+ .option('--all', 'Include completed tasks')
778
+ .action(async (query, opts) => {
779
+ try {
780
+ const state = await getState()
781
+ const q = query.toLowerCase()
782
+ let tasks = state.tasks
783
+
784
+ if (!opts.all) tasks = tasks.filter((t) => !t.completed)
785
+ if (opts.board) {
786
+ const b = findBoard(state.boards, opts.board)
787
+ if (!b) return fail(`Board not found: ${opts.board}`)
788
+ tasks = tasks.filter((t) => t.boardId === b.id)
789
+ }
790
+ if (opts.assignee) {
791
+ const a = findAssignee(state.assignees, opts.assignee)
792
+ if (!a) return fail(`Assignee not found: ${opts.assignee}`)
793
+ tasks = tasks.filter((t) => t.assigneeId === a.id)
794
+ }
795
+
796
+ tasks = tasks.filter((t) =>
797
+ t.title.toLowerCase().includes(q) ||
798
+ (t.notes ?? '').toLowerCase().includes(q) ||
799
+ (t.description ?? '').toLowerCase().includes(q) ||
800
+ (t.tags ?? []).some((tag) => tag.toLowerCase().includes(q))
801
+ )
802
+
803
+ if (!isMachineOutput() && tasks.length === 0) {
804
+ console.log(chalk.gray(`\n No tasks matching "${query}".\n`))
805
+ return
806
+ }
807
+ printTaskList(tasks, state)
808
+ } catch (err) { handleError(err) }
809
+ })
810
+
630
811
  // ── tm requested ──────────────────────────────────────────────────────────────
631
812
 
632
813
  program
@@ -634,7 +815,7 @@ program
634
815
  .description('List open tasks you requested (assigned to others / agents)')
635
816
  .action(async () => {
636
817
  try {
637
- const state = await api('/api/state')
818
+ const state = await getState()
638
819
  const myId = await resolveMyAssigneeId(state)
639
820
  const tasks = state.tasks.filter((t) => t.requestedById === myId && !t.completed)
640
821
  printTaskList(tasks, state)
@@ -653,9 +834,11 @@ program
653
834
  .option('--repo <owner/repo>','GitHub repo')
654
835
  .option('--url <url>', 'Issue / PR URL')
655
836
  .option('--notes <text>', 'Notes')
837
+ .option('--blocks <id>', 'This task blocks another task (provide task id)')
838
+ .option('--blocked-by <id>', 'This task is blocked by another task (provide task id)')
656
839
  .action(async (title, opts) => {
657
840
  try {
658
- const state = await api('/api/state')
841
+ const state = await getState()
659
842
  const myId = await resolveMyAssigneeId(state)
660
843
 
661
844
  let assigneeId = myId
@@ -668,14 +851,19 @@ program
668
851
  requestedById = myId
669
852
  }
670
853
 
671
- const bucket = opts.bucket
672
- ? state.buckets.find((b) => b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
673
- : state.buckets[0]
674
-
675
854
  const board = opts.board
676
855
  ? state.boards.find((b) => b.name.toLowerCase().includes(opts.board.toLowerCase()))
677
856
  : state.boards.find((b) => b.id === state.currentBoardId) ?? state.boards[0]
678
857
 
858
+ if (!board && opts.board) return fail(`Board not found: ${opts.board}`)
859
+ if (!board) console.warn(chalk.yellow(` ⚠ No board specified — defaulting to "${state.boards[0]?.name}". Set a default with: wadah config --default-board "<name>"`))
860
+
861
+ const boardBuckets = board ? state.buckets.filter((b) => b.boardId === board.id) : state.buckets
862
+ const bucket = opts.bucket
863
+ ? boardBuckets.find((b) => b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
864
+ ?? state.buckets.find((b) => b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
865
+ : boardBuckets[0] ?? state.buckets[0]
866
+
679
867
  const task = await api('/api/tasks', {
680
868
  method: 'POST',
681
869
  body: JSON.stringify({
@@ -688,9 +876,27 @@ program
688
876
  repo: opts.repo ?? null,
689
877
  contextUrl: opts.url ?? null,
690
878
  notes: opts.notes ?? '',
879
+ tags: [
880
+ ...(opts.blocks ? [`blocks:${opts.blocks.slice(0, 8)}`] : []),
881
+ ...(opts.blockedBy ? [`blocked-by:${opts.blockedBy.slice(0, 8)}`] : []),
882
+ ],
691
883
  }),
692
884
  })
693
885
 
886
+ // If this task blocks another, tag the other task back
887
+ if (opts.blocks) {
888
+ try {
889
+ const other = await api(`/api/tasks/${opts.blocks}`)
890
+ const existingTags = other.tags ?? []
891
+ if (!existingTags.some((t) => t.startsWith('blocked-by:'))) {
892
+ await api(`/api/tasks/${opts.blocks}`, {
893
+ method: 'PATCH',
894
+ body: JSON.stringify({ tags: [...existingTags, `blocked-by:${task.id.slice(0, 8)}`] }),
895
+ })
896
+ }
897
+ } catch {}
898
+ }
899
+
694
900
  const assigneeName = state.assignees.find((a) => a.id === task.assigneeId)?.name ?? 'Unassigned'
695
901
  console.log(chalk.green('\n✓ Created') + ` ${chalk.bold(task.title)}`)
696
902
  console.log(chalk.gray(` ID: ${task.id} · Assigned to: ${assigneeName}\n`))
@@ -735,7 +941,7 @@ program
735
941
  .requiredOption('--to <name>', 'Assignee name')
736
942
  .action(async (id, opts) => {
737
943
  try {
738
- const state = await api('/api/state')
944
+ const state = await getState()
739
945
  const a = findAssignee(state.assignees, opts.to)
740
946
  if (!a) return fail(`Assignee not found: ${opts.to}`)
741
947
 
@@ -753,14 +959,18 @@ program
753
959
  .description('Move a task to another column')
754
960
  .action(async (id, bucketQuery) => {
755
961
  try {
756
- const state = await api('/api/state')
962
+ const state = await getState()
757
963
  const bucket = findBucket(state.buckets, bucketQuery)
758
964
  if (!bucket) return fail(`Bucket not found: ${bucketQuery}`)
965
+ const patch = { bucketId: bucket.id }
966
+ if (bucket.boardId) patch.boardId = bucket.boardId
759
967
  const task = await api(`/api/tasks/${id}`, {
760
968
  method: 'PATCH',
761
- body: JSON.stringify({ bucketId: bucket.id }),
969
+ body: JSON.stringify(patch),
762
970
  })
763
- console.log(chalk.green('\n✓ Moved') + ` ${chalk.bold(task.title)} ${bucket.title}\n`)
971
+ const boardName = state.boards.find((b) => b.id === bucket.boardId)?.name
972
+ const dest = boardName ? `${boardName} / ${bucket.title}` : bucket.title
973
+ console.log(chalk.green('\n✓ Moved') + ` ${chalk.bold(task.title)} → ${dest}\n`)
764
974
  } catch (err) { handleError(err) }
765
975
  })
766
976
 
@@ -778,6 +988,8 @@ program
778
988
  .option('--board <name>', 'Set board')
779
989
  .option('--bucket <name>', 'Set bucket')
780
990
  .option('--completed <true|false>', 'Set completion state')
991
+ .option('--blocks <id>', 'Mark this task as blocking another task')
992
+ .option('--blocked-by <id>', 'Mark this task as blocked by another task')
781
993
  .action(async (id, opts) => {
782
994
  try {
783
995
  const patch = {}
@@ -802,7 +1014,8 @@ program
802
1014
  if (opts.clearDue) patch.dueDate = null
803
1015
 
804
1016
  const needsStateLookup = !!(opts.assignee || opts.board || opts.bucket)
805
- const state = needsStateLookup ? await api('/api/state') : null
1017
+ const state = needsStateLookup ? await getState() : null
1018
+
806
1019
 
807
1020
  if (opts.assignee) {
808
1021
  const a = findAssignee(state.assignees, opts.assignee)
@@ -815,12 +1028,32 @@ program
815
1028
  patch.boardId = b.id
816
1029
  }
817
1030
  if (opts.bucket) {
818
- const b = findBucket(state.buckets, opts.bucket)
1031
+ const targetBoardId = patch.boardId ?? null
1032
+ const scopedBuckets = targetBoardId
1033
+ ? state.buckets.filter((b) => b.boardId === targetBoardId)
1034
+ : state.buckets
1035
+ const b = scopedBuckets.find((b) => b.id === opts.bucket || b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
1036
+ ?? state.buckets.find((b) => b.id === opts.bucket || b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
819
1037
  if (!b) return fail(`Bucket not found: ${opts.bucket}`)
820
1038
  patch.bucketId = b.id
1039
+ if (!patch.boardId && b.boardId) patch.boardId = b.boardId
821
1040
  }
822
1041
  if (opts.completed !== undefined) patch.completed = parsedCompleted
823
1042
 
1043
+ // Handle relationship tags (blocks / blocked-by)
1044
+ if (opts.blocks || opts.blockedBy) {
1045
+ const currentTask = await api(`/api/tasks/${id}`)
1046
+ const currentTags = currentTask.tags ?? []
1047
+ const newTags = [...currentTags]
1048
+ if (opts.blocks && !newTags.some((t) => t.startsWith('blocks:'))) {
1049
+ newTags.push(`blocks:${opts.blocks.slice(0, 8)}`)
1050
+ }
1051
+ if (opts.blockedBy && !newTags.some((t) => t.startsWith('blocked-by:'))) {
1052
+ newTags.push(`blocked-by:${opts.blockedBy.slice(0, 8)}`)
1053
+ }
1054
+ patch.tags = newTags
1055
+ }
1056
+
824
1057
  if (Object.keys(patch).length === 0) {
825
1058
  return fail('Nothing to update. Run wadah update --help')
826
1059
  }
@@ -868,7 +1101,17 @@ program
868
1101
  if (task.dueDate) console.log(chalk.gray(` Due: ${formatDate(task.dueDate)}`))
869
1102
  if (task.repo) console.log(chalk.gray(` Repo: ${task.repo}`))
870
1103
  if (task.contextUrl) console.log(chalk.gray(` Link: ${task.contextUrl}`))
871
- if (task.tags?.length) console.log(chalk.gray(` Tags: #${task.tags.join(' #')}`))
1104
+ if (task.tags?.length) {
1105
+ const relTags = task.tags.filter((t) => t.startsWith('blocks:') || t.startsWith('blocked-by:'))
1106
+ const plainTags = task.tags.filter((t) => !t.startsWith('blocks:') && !t.startsWith('blocked-by:'))
1107
+ if (plainTags.length) console.log(chalk.gray(` Tags: #${plainTags.join(' #')}`))
1108
+ if (relTags.length) {
1109
+ const blocking = relTags.filter((t) => t.startsWith('blocks:')).map((t) => t.replace('blocks:', ''))
1110
+ const blockedBy = relTags.filter((t) => t.startsWith('blocked-by:')).map((t) => t.replace('blocked-by:', ''))
1111
+ if (blocking.length) console.log(chalk.red(` Blocks: ${blocking.join(', ')}`))
1112
+ if (blockedBy.length) console.log(chalk.yellow(` Blocked by: ${blockedBy.join(', ')}`))
1113
+ }
1114
+ }
872
1115
  if (task.description) { console.log(); console.log(` ${task.description}`) }
873
1116
  if (task.notes) { console.log(chalk.gray('\n Notes:')); console.log(` ${task.notes}`) }
874
1117
  if (task.subtasks?.length) {
@@ -911,7 +1154,17 @@ program
911
1154
  .description('List all boards')
912
1155
  .action(async () => {
913
1156
  try {
914
- const state = await api('/api/state')
1157
+ const state = await getState()
1158
+ if (isMachineOutput()) {
1159
+ const result = state.boards.map((b) => ({
1160
+ id: b.id,
1161
+ name: b.name,
1162
+ openCount: state.tasks.filter((t) => t.boardId === b.id && !t.completed).length,
1163
+ doneCount: state.tasks.filter((t) => t.boardId === b.id && t.completed).length,
1164
+ }))
1165
+ console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1166
+ return
1167
+ }
915
1168
  console.log()
916
1169
  state.boards.forEach((b) => {
917
1170
  const open = state.tasks.filter((t) => t.boardId === b.id && !t.completed).length
@@ -927,8 +1180,20 @@ program
927
1180
  .description('List all columns (buckets)')
928
1181
  .action(async () => {
929
1182
  try {
930
- const state = await api('/api/state')
1183
+ const state = await getState()
931
1184
  const boardMap = Object.fromEntries(state.boards.map((b) => [b.id, b.name]))
1185
+ if (isMachineOutput()) {
1186
+ const result = state.buckets.map((b) => ({
1187
+ id: b.id,
1188
+ title: b.title,
1189
+ boardId: b.boardId,
1190
+ boardName: boardMap[b.boardId] ?? '—',
1191
+ openCount: state.tasks.filter((t) => t.bucketId === b.id && !t.completed).length,
1192
+ doneCount: state.tasks.filter((t) => t.bucketId === b.id && t.completed).length,
1193
+ }))
1194
+ console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1195
+ return
1196
+ }
932
1197
  console.log()
933
1198
  state.buckets.forEach((b) => {
934
1199
  const open = state.tasks.filter((t) => t.bucketId === b.id && !t.completed).length
@@ -944,7 +1209,18 @@ program
944
1209
  .description('List all assignees')
945
1210
  .action(async () => {
946
1211
  try {
947
- const state = await api('/api/state')
1212
+ const state = await getState()
1213
+ if (isMachineOutput()) {
1214
+ const result = state.assignees.map((a) => ({
1215
+ id: a.id,
1216
+ name: a.name,
1217
+ type: a.type ?? 'human',
1218
+ openCount: state.tasks.filter((t) => t.assigneeId === a.id && !t.completed).length,
1219
+ doneCount: state.tasks.filter((t) => t.assigneeId === a.id && t.completed).length,
1220
+ }))
1221
+ console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1222
+ return
1223
+ }
948
1224
  console.log()
949
1225
  state.assignees.forEach((a) => {
950
1226
  const open = state.tasks.filter((t) => t.assigneeId === a.id && !t.completed).length
@@ -955,12 +1231,509 @@ program
955
1231
  } catch (err) { handleError(err) }
956
1232
  })
957
1233
 
1234
+ // ── board CRUD ────────────────────────────────────────────────────────────────
1235
+
1236
+ const boardCmd = program.command('board').description('Create or delete a board (list: wadah boards)')
1237
+
1238
+ boardCmd
1239
+ .command('create <name>')
1240
+ .description('Create a new board')
1241
+ .action(async (name) => {
1242
+ try {
1243
+ const board = await api('/api/boards', { method: 'POST', body: JSON.stringify({ name: String(name).trim() }) })
1244
+ if (isMachineOutput()) {
1245
+ console.log(JSON.stringify(board, null, runtimeJsonOutput ? 2 : 0))
1246
+ } else {
1247
+ console.log(chalk.green('\n✓ Board created') + ` ${chalk.bold(board.name)} ${chalk.gray(board.id?.slice(0, 8))}\n`)
1248
+ }
1249
+ } catch (err) { handleError(err) }
1250
+ })
1251
+
1252
+ boardCmd
1253
+ .command('delete <id>')
1254
+ .description('Delete a board (cannot delete the last one)')
1255
+ .action(async (id) => {
1256
+ try {
1257
+ await api(`/api/boards/${id}`, { method: 'DELETE' })
1258
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Board deleted\n'))
1259
+ } catch (err) { handleError(err) }
1260
+ })
1261
+
1262
+ // ── bucket CRUD ──────────────────────────────────────────────────────────────
1263
+
1264
+ const bucketCmd = program.command('bucket').description('Create, update, or delete a column/bucket (list: wadah buckets)')
1265
+
1266
+ bucketCmd
1267
+ .command('create <title>')
1268
+ .description('Create a new bucket (column)')
1269
+ .option('--board <id>', 'Board id (optional)')
1270
+ .action(async function (title) {
1271
+ try {
1272
+ const body = { title: String(title).trim() }
1273
+ if (this.opts().board) body.boardId = this.opts().board
1274
+ const bucket = await api('/api/buckets', { method: 'POST', body: JSON.stringify(body) })
1275
+ if (isMachineOutput()) {
1276
+ console.log(JSON.stringify(bucket, null, runtimeJsonOutput ? 2 : 0))
1277
+ } else {
1278
+ console.log(chalk.green('\n✓ Bucket created') + ` ${chalk.bold(bucket.title)} ${chalk.gray(bucket.id?.slice(0, 8))}\n`)
1279
+ }
1280
+ } catch (err) { handleError(err) }
1281
+ })
1282
+
1283
+ bucketCmd
1284
+ .command('update <id>')
1285
+ .description('Update a bucket')
1286
+ .option('--title <text>', 'New title')
1287
+ .option('--order <n>', 'Order (number)', parseInt)
1288
+ .action(async function (id) {
1289
+ const opts = this.opts()
1290
+ try {
1291
+ const patch = {}
1292
+ if (opts.title !== undefined) patch.title = opts.title
1293
+ if (opts.order !== undefined && !Number.isNaN(opts.order)) patch.order = opts.order
1294
+ if (Object.keys(patch).length === 0) return fail('Provide --title or --order to update')
1295
+ await api(`/api/buckets/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1296
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Bucket updated\n'))
1297
+ } catch (err) { handleError(err) }
1298
+ })
1299
+
1300
+ bucketCmd
1301
+ .command('delete <id>')
1302
+ .description('Delete a bucket (tasks move to another column)')
1303
+ .action(async (id) => {
1304
+ try {
1305
+ await api(`/api/buckets/${id}`, { method: 'DELETE' })
1306
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Bucket deleted\n'))
1307
+ } catch (err) { handleError(err) }
1308
+ })
1309
+
1310
+ // ── assignee CRUD ────────────────────────────────────────────────────────────
1311
+
1312
+ const assigneeCmd = program.command('assignee').description('Create, update, or delete an assignee (list: wadah assignees)')
1313
+
1314
+ assigneeCmd
1315
+ .command('create <name>')
1316
+ .description('Create a new assignee')
1317
+ .option('--type <type>', 'Type: human or agent', 'human')
1318
+ .action(async function (name) {
1319
+ try {
1320
+ const body = { name: String(name).trim(), type: this.opts().type || 'human' }
1321
+ const assignee = await api('/api/assignees', { method: 'POST', body: JSON.stringify(body) })
1322
+ if (isMachineOutput()) {
1323
+ console.log(JSON.stringify(assignee, null, runtimeJsonOutput ? 2 : 0))
1324
+ } else {
1325
+ console.log(chalk.green('\n✓ Assignee created') + ` ${chalk.bold(assignee.name)} ${chalk.gray(assignee.id?.slice(0, 8))}\n`)
1326
+ }
1327
+ } catch (err) { handleError(err) }
1328
+ })
1329
+
1330
+ assigneeCmd
1331
+ .command('update <id>')
1332
+ .description('Update an assignee')
1333
+ .option('--name <text>', 'New name')
1334
+ .option('--type <type>', 'Type: human or agent')
1335
+ .action(async function (id) {
1336
+ const opts = this.opts()
1337
+ try {
1338
+ const patch = {}
1339
+ if (opts.name !== undefined) patch.name = opts.name
1340
+ if (opts.type !== undefined) patch.type = opts.type
1341
+ if (Object.keys(patch).length === 0) return fail('Provide --name or --type to update')
1342
+ await api(`/api/assignees/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1343
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Assignee updated\n'))
1344
+ } catch (err) { handleError(err) }
1345
+ })
1346
+
1347
+ assigneeCmd
1348
+ .command('delete <id>')
1349
+ .description('Delete an assignee (tasks unassigned)')
1350
+ .action(async (id) => {
1351
+ try {
1352
+ await api(`/api/assignees/${id}`, { method: 'DELETE' })
1353
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Assignee deleted\n'))
1354
+ } catch (err) { handleError(err) }
1355
+ })
1356
+
1357
+ program
1358
+ .command('folders')
1359
+ .description('List all folders (Files tab)')
1360
+ .action(async () => {
1361
+ try {
1362
+ const folders = await api('/api/folders').catch(() => [])
1363
+ const list = Array.isArray(folders) ? folders : []
1364
+ if (isMachineOutput()) {
1365
+ console.log(JSON.stringify(list, null, runtimeJsonOutput ? 2 : 0))
1366
+ } else if (list.length === 0) {
1367
+ console.log(chalk.gray('\nNo folders. Create one: wadah folder create "Name"\n'))
1368
+ } else {
1369
+ console.log()
1370
+ list.forEach((f) => console.log(` ${chalk.bold(f.name)} ${chalk.gray(`id: ${f.id}`)}`))
1371
+ console.log()
1372
+ }
1373
+ } catch (err) { handleError(err) }
1374
+ })
1375
+
1376
+ program
1377
+ .command('files')
1378
+ .description('List all files in the workspace')
1379
+ .option('--folder <id>', 'Filter by folder id')
1380
+ .action(async function () {
1381
+ const opts = this.opts()
1382
+ try {
1383
+ const state = await getState()
1384
+ let files = state.files ?? []
1385
+ if (opts.folder) files = files.filter((f) => (f.folderId || f.folder_id) === opts.folder)
1386
+ if (isMachineOutput()) {
1387
+ console.log(JSON.stringify(files, null, runtimeJsonOutput ? 2 : 0))
1388
+ } else if (files.length === 0) {
1389
+ console.log(chalk.gray('\nNo files. Upload one: wadah upload <path>\n'))
1390
+ } else {
1391
+ console.log()
1392
+ files.forEach((f) => {
1393
+ const name = f.name || f.id
1394
+ const url = f.storageUrl || f.storage_url
1395
+ console.log(` ${chalk.bold(name)} ${chalk.gray(url ? url.slice(0, 50) + '…' : f.id)}`)
1396
+ })
1397
+ console.log()
1398
+ }
1399
+ } catch (err) { handleError(err) }
1400
+ })
1401
+
1402
+ program
1403
+ .command('members')
1404
+ .description('List workspace members')
1405
+ .action(async () => {
1406
+ try {
1407
+ const members = await api('/api/workspace/members')
1408
+ const list = Array.isArray(members) ? members : []
1409
+ if (isMachineOutput()) {
1410
+ console.log(JSON.stringify(list, null, runtimeJsonOutput ? 2 : 0))
1411
+ } else if (list.length === 0) {
1412
+ console.log(chalk.gray('\nNo members.\n'))
1413
+ } else {
1414
+ console.log()
1415
+ list.forEach((m) => console.log(` ${chalk.bold(m.role)} ${chalk.gray(m.user_id?.slice(0, 8) ?? '—')}`))
1416
+ console.log()
1417
+ }
1418
+ } catch (err) { handleError(err) }
1419
+ })
1420
+
1421
+ // ── agent tokens ──────────────────────────────────────────────────────────────
1422
+
1423
+ program
1424
+ .command('agent-tokens')
1425
+ .description('List agent tokens for this workspace')
1426
+ .action(async () => {
1427
+ try {
1428
+ const list = await api('/api/agent-tokens').catch(() => [])
1429
+ const arr = Array.isArray(list) ? list : []
1430
+ if (isMachineOutput()) {
1431
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1432
+ } else if (arr.length === 0) {
1433
+ console.log(chalk.gray('\nNo agent tokens. Create one: wadah agent-token create "Bot name"\n'))
1434
+ } else {
1435
+ console.log()
1436
+ arr.forEach((t) => {
1437
+ const as = t.assigneeName ? chalk.gray(` · acts as ${t.assigneeName}`) : ''
1438
+ console.log(` ${chalk.bold(t.name)} ${chalk.gray(`id: ${t.id?.slice(0, 8)}${as}`)}`)
1439
+ })
1440
+ console.log()
1441
+ }
1442
+ } catch (err) { handleError(err) }
1443
+ })
1444
+
1445
+ const agentTokenCmd = program.command('agent-token').description('Create or delete an agent token (for CLI/CI)')
1446
+
1447
+ agentTokenCmd
1448
+ .command('create [name]')
1449
+ .description('Create an agent token; token is shown once — store it as TASK_MANAGER_TOKEN')
1450
+ .option('--name <text>', 'Token name (if not passed as argument)')
1451
+ .option('--assignee-name <text>', 'Display name for the agent (default: same as name)')
1452
+ .action(async function (nameArg) {
1453
+ const opts = this.opts()
1454
+ const name = (nameArg || opts.name || 'CLI Agent').trim()
1455
+ try {
1456
+ const body = { name }
1457
+ if (opts.assigneeName) body.assigneeName = opts.assigneeName
1458
+ const result = await api('/api/agent-tokens', { method: 'POST', body: JSON.stringify(body) })
1459
+ if (isMachineOutput()) {
1460
+ console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1461
+ } else {
1462
+ console.log(chalk.green('\n✓ Agent token created'))
1463
+ console.log(chalk.gray(` Name: ${result.name} Id: ${result.id?.slice(0, 8)} Acts as: ${result.assigneeName ?? result.name}`))
1464
+ console.log(chalk.yellow('\n Token (save it — shown once):'))
1465
+ console.log(` ${result.token}`)
1466
+ console.log(chalk.gray('\n Use: export TASK_MANAGER_TOKEN="<token>"\n'))
1467
+ }
1468
+ } catch (err) { handleError(err) }
1469
+ })
1470
+
1471
+ agentTokenCmd
1472
+ .command('delete <id>')
1473
+ .description('Revoke an agent token')
1474
+ .action(async (id) => {
1475
+ try {
1476
+ await api(`/api/agent-tokens/${id}`, { method: 'DELETE' })
1477
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Agent token revoked\n'))
1478
+ } catch (err) { handleError(err) }
1479
+ })
1480
+
1481
+ // ── calendar events ──────────────────────────────────────────────────────────
1482
+
1483
+ const calendarCmd = program
1484
+ .command('calendar')
1485
+ .description('List calendar events (optional: --assignee, --task, --from, --to)')
1486
+ .option('--assignee <id>', 'Filter by assignee id')
1487
+ .option('--task <id>', 'Filter by task id')
1488
+ .option('--from <iso>', 'From date (ISO)')
1489
+ .option('--to <iso>', 'To date (ISO)')
1490
+ .action(async function () {
1491
+ const opts = this.opts()
1492
+ try {
1493
+ let path = '/api/calendar-events'
1494
+ const q = []
1495
+ if (opts.assignee) q.push(`assigneeId=${encodeURIComponent(opts.assignee)}`)
1496
+ if (opts.task) q.push(`taskId=${encodeURIComponent(opts.task)}`)
1497
+ if (opts.from) q.push(`from=${encodeURIComponent(opts.from)}`)
1498
+ if (opts.to) q.push(`to=${encodeURIComponent(opts.to)}`)
1499
+ if (q.length) path += '?' + q.join('&')
1500
+ const list = await api(path).catch(() => [])
1501
+ const arr = Array.isArray(list) ? list : []
1502
+ if (isMachineOutput()) {
1503
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1504
+ } else if (arr.length === 0) {
1505
+ console.log(chalk.gray('\nNo calendar events.\n'))
1506
+ } else {
1507
+ console.log()
1508
+ arr.forEach((e) => {
1509
+ const start = e.startTime ? new Date(e.startTime).toLocaleString() : '—'
1510
+ const end = e.endTime ? new Date(e.endTime).toLocaleString() : '—'
1511
+ console.log(` ${chalk.bold(e.title || 'Untitled')} ${chalk.gray(`${start} → ${end}`)} ${chalk.gray(`id: ${e.id?.slice(0, 8)}`)}`)
1512
+ })
1513
+ console.log()
1514
+ }
1515
+ } catch (err) { handleError(err) }
1516
+ })
1517
+
1518
+ calendarCmd
1519
+ .command('add')
1520
+ .description('Create a calendar event (--start and --end required, ISO or YYYY-MM-DD)')
1521
+ .option('--title <text>', 'Event title', 'Work block')
1522
+ .option('--start <iso>', 'Start time (ISO or YYYY-MM-DD)')
1523
+ .option('--end <iso>', 'End time (ISO or YYYY-MM-DD)')
1524
+ .option('--task <id>', 'Task id')
1525
+ .option('--assignee <id>', 'Assignee id')
1526
+ .option('--color <hex>', 'Color')
1527
+ .action(async function () {
1528
+ const opts = this.opts()
1529
+ try {
1530
+ if (!opts.start || !opts.end) return fail('--start and --end are required')
1531
+ const startTime = new Date(opts.start).toISOString()
1532
+ const endTime = new Date(opts.end).toISOString()
1533
+ const body = { title: opts.title, startTime, endTime }
1534
+ if (opts.task) body.taskId = opts.task
1535
+ if (opts.assignee) body.assigneeId = opts.assignee
1536
+ if (opts.color) body.color = opts.color
1537
+ const event = await api('/api/calendar-events', { method: 'POST', body: JSON.stringify(body) })
1538
+ if (isMachineOutput()) {
1539
+ console.log(JSON.stringify(event, null, runtimeJsonOutput ? 2 : 0))
1540
+ } else {
1541
+ console.log(chalk.green('\n✓ Event created') + ` ${chalk.bold(event.title)} ${chalk.gray(event.id?.slice(0, 8))}\n`)
1542
+ }
1543
+ } catch (err) { handleError(err) }
1544
+ })
1545
+
1546
+ calendarCmd
1547
+ .command('update <id>')
1548
+ .description('Update a calendar event')
1549
+ .option('--title <text>', 'Event title')
1550
+ .option('--start <iso>', 'Start time')
1551
+ .option('--end <iso>', 'End time')
1552
+ .option('--task <id>', 'Task id')
1553
+ .option('--assignee <id>', 'Assignee id')
1554
+ .option('--color <hex>', 'Color')
1555
+ .action(async function (id) {
1556
+ const opts = this.opts()
1557
+ try {
1558
+ const patch = {}
1559
+ if (opts.title !== undefined) patch.title = opts.title
1560
+ if (opts.start !== undefined) patch.startTime = new Date(opts.start).toISOString()
1561
+ if (opts.end !== undefined) patch.endTime = new Date(opts.end).toISOString()
1562
+ if (opts.task !== undefined) patch.taskId = opts.task
1563
+ if (opts.assignee !== undefined) patch.assigneeId = opts.assignee
1564
+ if (opts.color !== undefined) patch.color = opts.color
1565
+ if (Object.keys(patch).length === 0) return fail('Provide at least one option to update')
1566
+ await api(`/api/calendar-events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1567
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Event updated\n'))
1568
+ } catch (err) { handleError(err) }
1569
+ })
1570
+
1571
+ calendarCmd
1572
+ .command('delete <id>')
1573
+ .description('Delete a calendar event')
1574
+ .action(async (id) => {
1575
+ try {
1576
+ await api(`/api/calendar-events/${id}`, { method: 'DELETE' })
1577
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Event deleted\n'))
1578
+ } catch (err) { handleError(err) }
1579
+ })
1580
+
1581
+ // ── workspace docs ─────────────────────────────────────────────────────────────
1582
+
1583
+ program
1584
+ .command('docs')
1585
+ .description('List workspace docs')
1586
+ .action(async () => {
1587
+ try {
1588
+ const list = await api('/api/docs').catch(() => [])
1589
+ const arr = Array.isArray(list) ? list : []
1590
+ if (isMachineOutput()) {
1591
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1592
+ } else if (arr.length === 0) {
1593
+ console.log(chalk.gray('\nNo docs.\n'))
1594
+ } else {
1595
+ console.log()
1596
+ arr.forEach((d) => console.log(` ${chalk.bold(d.title || 'Untitled')} ${chalk.gray(`id: ${d.id?.slice(0, 8)}`)}`))
1597
+ console.log()
1598
+ }
1599
+ } catch (err) { handleError(err) }
1600
+ })
1601
+
1602
+ const docCmd = program.command('doc').description('Workspace doc (create, show, update, delete)')
1603
+
1604
+ docCmd
1605
+ .command('create [title]')
1606
+ .description('Create a workspace doc')
1607
+ .option('--title <text>', 'Doc title (if not passed as argument)')
1608
+ .option('--parent <id>', 'Parent doc id')
1609
+ .option('--emoji <char>', 'Emoji')
1610
+ .action(async function (titleArg) {
1611
+ const opts = this.opts()
1612
+ const title = (titleArg || opts.title || 'Untitled').trim()
1613
+ try {
1614
+ const body = { title }
1615
+ if (opts.parent) body.parentId = opts.parent
1616
+ if (opts.emoji) body.emoji = opts.emoji
1617
+ const doc = await api('/api/docs', { method: 'POST', body: JSON.stringify(body) })
1618
+ if (isMachineOutput()) {
1619
+ console.log(JSON.stringify(doc, null, runtimeJsonOutput ? 2 : 0))
1620
+ } else {
1621
+ console.log(chalk.green('\n✓ Doc created') + ` ${chalk.bold(doc.title)} ${chalk.gray(doc.id?.slice(0, 8))}\n`)
1622
+ }
1623
+ } catch (err) { handleError(err) }
1624
+ })
1625
+
1626
+ docCmd
1627
+ .command('show <id>')
1628
+ .description('Show a workspace doc')
1629
+ .action(async (id) => {
1630
+ try {
1631
+ const doc = await api(`/api/docs/${id}`)
1632
+ if (isMachineOutput()) {
1633
+ console.log(JSON.stringify(doc, null, runtimeJsonOutput ? 2 : 0))
1634
+ } else {
1635
+ console.log()
1636
+ console.log(chalk.bold(doc.title || 'Untitled'))
1637
+ console.log(chalk.gray(`id: ${doc.id}`))
1638
+ if (doc.content && typeof doc.content === 'object') {
1639
+ const text = JSON.stringify(doc.content)
1640
+ console.log(chalk.gray(text.slice(0, 200) + (text.length > 200 ? '…' : '')))
1641
+ }
1642
+ console.log()
1643
+ }
1644
+ } catch (err) { handleError(err) }
1645
+ })
1646
+
1647
+ docCmd
1648
+ .command('update <id>')
1649
+ .description('Update a workspace doc')
1650
+ .option('--title <text>', 'Doc title')
1651
+ .option('--content <json>', 'Content as JSON string')
1652
+ .option('--emoji <char>', 'Emoji')
1653
+ .option('--parent <id>', 'Parent doc id')
1654
+ .action(async function (id) {
1655
+ const opts = this.opts()
1656
+ try {
1657
+ const patch = {}
1658
+ if (opts.title !== undefined) patch.title = opts.title
1659
+ if (opts.content !== undefined) {
1660
+ try {
1661
+ patch.content = typeof opts.content === 'string' ? JSON.parse(opts.content) : opts.content
1662
+ } catch (_) {
1663
+ return fail('--content must be valid JSON')
1664
+ }
1665
+ }
1666
+ if (opts.emoji !== undefined) patch.emoji = opts.emoji
1667
+ if (opts.parent !== undefined) patch.parentId = opts.parent
1668
+ if (Object.keys(patch).length === 0) return fail('Provide at least one option to update')
1669
+ await api(`/api/docs/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1670
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Doc updated\n'))
1671
+ } catch (err) { handleError(err) }
1672
+ })
1673
+
1674
+ docCmd
1675
+ .command('delete <id>')
1676
+ .description('Delete a workspace doc')
1677
+ .action(async (id) => {
1678
+ try {
1679
+ await api(`/api/docs/${id}`, { method: 'DELETE' })
1680
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Doc deleted\n'))
1681
+ } catch (err) { handleError(err) }
1682
+ })
1683
+
1684
+ program
1685
+ .command('folder create <name>')
1686
+ .alias('mkdir')
1687
+ .description('Create a folder in the workspace (Files tab)')
1688
+ .action(async (name) => {
1689
+ try {
1690
+ const folder = await api('/api/folders', {
1691
+ method: 'POST',
1692
+ body: JSON.stringify({ name: String(name).trim() }),
1693
+ })
1694
+ if (isMachineOutput()) {
1695
+ console.log(JSON.stringify(folder, null, runtimeJsonOutput ? 2 : 0))
1696
+ } else {
1697
+ console.log(chalk.green('\n✓ Folder created') + ` ${chalk.bold(folder.name)} ${chalk.gray(`(id: ${folder.id})\n`)}`)
1698
+ }
1699
+ } catch (err) { handleError(err) }
1700
+ })
1701
+
1702
+ program
1703
+ .command('upload <path>')
1704
+ .description('Upload a file to the workspace (optional: --folder <id>)')
1705
+ .option('--folder <id>', 'Put the file in this folder id')
1706
+ .action(async (pathArg, opts) => {
1707
+ try {
1708
+ await ensureAuth()
1709
+ const filePath = resolve(pathArg)
1710
+ if (!existsSync(filePath)) return fail(`File not found: ${filePath}`)
1711
+ const form = new FormData()
1712
+ form.append('file', createReadStream(filePath), { filename: basename(filePath) })
1713
+ if (opts.folder) form.append('folderId', String(opts.folder).trim())
1714
+ const headers = { ...authHeaders() }
1715
+ delete headers['Content-Type']
1716
+ const res = await fetch(`${getApiBase()}/api/files/upload`, {
1717
+ method: 'POST',
1718
+ headers: { ...headers, ...form.getHeaders() },
1719
+ body: form,
1720
+ })
1721
+ const data = await res.json().catch(() => ({}))
1722
+ if (!res.ok) throw new CliError(data.error ?? 'Upload failed', data.error ?? res.statusText)
1723
+ if (isMachineOutput()) {
1724
+ console.log(JSON.stringify(data, null, runtimeJsonOutput ? 2 : 0))
1725
+ } else {
1726
+ console.log(chalk.green('\n✓ Uploaded') + ` ${chalk.bold(data.name)} ${chalk.gray(`→ ${data.storageUrl ?? data.id}\n`)}`)
1727
+ }
1728
+ } catch (err) { handleError(err) }
1729
+ })
1730
+
958
1731
  program
959
1732
  .command('state')
960
1733
  .description('Print full workspace state as JSON')
961
1734
  .action(async () => {
962
1735
  try {
963
- const state = await api('/api/state')
1736
+ const state = await getState()
964
1737
  console.log(JSON.stringify(state, null, 2))
965
1738
  } catch (err) { handleError(err) }
966
1739
  })
@@ -1115,4 +1888,39 @@ program
1115
1888
  console.log()
1116
1889
  })
1117
1890
 
1891
+ // ── shell completion ──────────────────────────────────────────────────────────
1892
+
1893
+ const CLI_COMMANDS = [
1894
+ 'add', 'agent-token', 'agent-tokens', 'assign', 'assignee', 'assignees', 'board', 'boards',
1895
+ 'bucket', 'buckets', 'calendar', 'comment', 'complete', 'config', 'delete', 'doc', 'docs',
1896
+ 'do', 'doctor', 'files', 'folder', 'folders', 'invite', 'list', 'login', 'members', 'mkdir',
1897
+ 'move', 'open', 'reopen', 'requested', 'search', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
1898
+ ]
1899
+
1900
+ program
1901
+ .command('completion [shell]')
1902
+ .description('Print shell completion script (bash or zsh). Add to your profile: wadah completion bash >> ~/.bashrc')
1903
+ .action((shell) => {
1904
+ const sh = (shell || process.env.SHELL || 'bash').toLowerCase()
1905
+ if (sh.includes('zsh')) {
1906
+ console.log(`# zsh completion for wadah
1907
+ _wadah() {
1908
+ local cur
1909
+ cur=\${words[CURRENT]}
1910
+ reply=(${CLI_COMMANDS.join(' ')})
1911
+ }
1912
+ compctl -K _wadah wadah tm ow
1913
+ `)
1914
+ } else {
1915
+ console.log(`# bash completion for wadah
1916
+ _wadah_completion() {
1917
+ local cur=\${COMP_WORDS[COMP_CWORD]}
1918
+ local cmds="${CLI_COMMANDS.join(' ')}"
1919
+ COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
1920
+ }
1921
+ complete -F _wadah_completion wadah tm ow
1922
+ `)
1923
+ }
1924
+ })
1925
+
1118
1926
  program.parse()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-wadah",
3
- "version": "1.0.6",
3
+ "version": "1.2.0",
4
4
  "description": "Open Wadah CLI — shared task board for humans and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,13 +9,15 @@
9
9
  "tm": "cli.js"
10
10
  },
11
11
  "scripts": {
12
- "start": "node cli.js"
12
+ "start": "node cli.js",
13
+ "test": "node --test test/cli.test.js"
13
14
  },
14
15
  "dependencies": {
15
16
  "@inquirer/prompts": "^8.3.0",
16
17
  "chalk": "^5.4.1",
17
18
  "commander": "^13.1.0",
18
19
  "conf": "^13.1.0",
20
+ "form-data": "^4.0.0",
19
21
  "node-fetch": "^3.3.2"
20
22
  }
21
23
  }
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { test } from 'node:test'
3
+ import { strict as assert } from 'node:assert'
4
+ import { resolve, dirname } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+ const cliPath = resolve(__dirname, '../cli.js')
9
+
10
+ function runCli(args) {
11
+ return new Promise((resolvePromise, reject) => {
12
+ const child = spawn(process.execPath, [cliPath, ...args], {
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ env: { ...process.env, TASK_MANAGER_TOKEN: '' },
15
+ })
16
+ let stdout = ''
17
+ let stderr = ''
18
+ child.stdout?.on('data', (d) => { stdout += d })
19
+ child.stderr?.on('data', (d) => { stderr += d })
20
+ child.on('close', (code) => resolvePromise({ code, stdout, stderr }))
21
+ child.on('error', reject)
22
+ })
23
+ }
24
+
25
+ test('wadah --help exits 0 and prints usage', async () => {
26
+ const { code, stdout } = await runCli(['--help'])
27
+ assert.equal(code, 0)
28
+ assert.match(stdout, /Open Wadah|Usage|Commands/)
29
+ })
30
+
31
+ test('wadah --version exits 0 and prints version', async () => {
32
+ const { code, stdout } = await runCli(['--version'])
33
+ assert.equal(code, 0)
34
+ assert.match(stdout, /\d+\.\d+\.\d+/)
35
+ })
36
+
37
+ test('wadah (no args) prints help or exits', async () => {
38
+ const { code, stdout, stderr } = await runCli([])
39
+ assert(code === 0 || code === 1)
40
+ const out = stdout + stderr
41
+ assert.match(out, /Usage|Commands|Open Wadah/)
42
+ })
43
+
44
+ test('wadah open --help shows open command help', async () => {
45
+ const { code, stdout } = await runCli(['open', '--help'])
46
+ assert.equal(code, 0)
47
+ assert.match(stdout, /open|List/)
48
+ })
49
+
50
+ test('wadah doctor runs without crashing', async () => {
51
+ const { code } = await runCli(['doctor'])
52
+ // Doctor may exit 0 or 1 depending on API/token; we only assert it doesn't throw
53
+ assert(code === 0 || code === 1)
54
+ })
55
+
56
+ test('wadah completion bash prints script', async () => {
57
+ const { code, stdout } = await runCli(['completion', 'bash'])
58
+ assert.equal(code, 0)
59
+ assert.match(stdout, /complete -F _wadah_completion/)
60
+ assert.match(stdout, /wadah tm ow/)
61
+ })
62
+
63
+ test('wadah completion zsh prints script', async () => {
64
+ const { code, stdout } = await runCli(['completion', 'zsh'])
65
+ assert.equal(code, 0)
66
+ assert.match(stdout, /compctl|_wadah/)
67
+ })