open-wadah 1.0.6 → 1.1.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,67 @@ 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`, `requested`, `add`, `complete`, `reopen`, `view`, `update`, `move`, `assign`, `comment`, `delete` |
31
+ | **Board** | `boards`, `buckets`, `assignees`; `board create/delete`, `bucket create/update/delete`, `assignee create/update/delete` |
32
+ | **Files** | `folders`, `files`, `folder create` / `mkdir`, `upload` |
33
+ | **Calendar** | `calendar` (list; `--assignee`, `--task`, `--from`, `--to`), `calendar add`, `calendar update <id>`, `calendar delete <id>` |
34
+ | **Docs** | `docs` (list), `doc create [title]`, `doc show <id>`, `doc update <id>`, `doc delete <id>` |
35
+ | **Agent tokens** | `agent-tokens` (list), `agent-token create [name]`, `agent-token delete <id>` |
36
+ | **Workspace** | `state`, `members`, `invite`, `config` |
37
+ | **Other** | `do "<sentence>"` (natural language, needs `OPENAI_API_KEY`), `doctor`, `completion [bash\|zsh]` |
35
38
 
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`
39
+ Run `wadah --help` for full list and options.
43
40
 
44
- ## Global Flags
41
+ ## Shell completion
45
42
 
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)
43
+ **Bash:**
50
44
 
51
- ## Stable Error Codes
45
+ ```bash
46
+ wadah completion bash >> ~/.bashrc
47
+ source ~/.bashrc
48
+ ```
52
49
 
53
- When `--json` is enabled (or output is non-interactive), errors return:
50
+ **Zsh:**
54
51
 
55
- ```json
56
- {"error":{"code":"auth_error","message":"Not authenticated"}}
52
+ ```bash
53
+ wadah completion zsh >> ~/.zshrc
54
+ source ~/.zshrc
57
55
  ```
58
56
 
59
- Common codes:
57
+ ## Agent / AI use
60
58
 
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`
59
+ - Set `TASK_MANAGER_TOKEN` (create a token in the app under **Agent access**).
60
+ - Use `--json` for machine-readable output: `wadah open --json`, `wadah list --json`.
61
+ - Natural language: `wadah do "add a task to fix the bug"` (requires `OPENAI_API_KEY`).
62
+ - In Cursor/Claude/Kimi: see **WADAH_CLI.md** in the repo root for a short reference.
70
63
 
71
- ## API URL Override
64
+ ## Global flags
72
65
 
73
- Use env var:
66
+ - `--api <url>` — API base URL
67
+ - `--profile <name>` — config profile
68
+ - `--token <token>` — auth token for this run
69
+ - `--json` — JSON output
70
+ - `--quiet` — minimal output
74
71
 
75
- ```bash
76
- TASK_MANAGER_API_URL=https://api.openwadah.com wadah open
77
- ```
72
+ ## Develop & test
78
73
 
79
- Or pass per command:
74
+ From the repo root:
80
75
 
81
76
  ```bash
82
- wadah --api https://api.openwadah.com open
77
+ npm run install:all
78
+ npm run tm -- --help
79
+ cd task-manager-cli && npm test
83
80
  ```
84
81
 
85
- Use your own API URL if self-hosting (for example `https://api.yourcompany.com`).
82
+ ## Error codes (with `--json`)
83
+
84
+ - `auth_error` — not authenticated
85
+ - `validation_error` — bad input
86
+ - `network_error` — request failed
87
+ - `api_error` — API returned an error
88
+ - `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()
@@ -203,12 +204,11 @@ function handleError(err) {
203
204
  async function resolveMyAssigneeId(state) {
204
205
  try {
205
206
  const me = await api('/api/auth/me')
207
+ if (me.assigneeId) return me.assigneeId
206
208
  const userId = me.user?.id
207
- // Match by user_id field on assignee (if backend returns it)
208
209
  const byUserId = state.assignees.find((a) => a.user_id === userId)
209
210
  if (byUserId) return byUserId.id
210
211
  } catch {}
211
- // Fallback: 'Me' assignee
212
212
  return state.assignees.find((a) => a.name === 'Me')?.id ?? null
213
213
  }
214
214
 
@@ -266,6 +266,106 @@ function sleep(ms) {
266
266
  return new Promise((resolve) => setTimeout(resolve, ms))
267
267
  }
268
268
 
269
+ // ── natural language (do) ─────────────────────────────────────────────────────
270
+
271
+ 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:
272
+ {"command": "<command>", "args": [<array of string arguments>]}
273
+
274
+ Available commands and their args (use these exact command names):
275
+ - add: args = [task title string]. Example: add a task "Fix login" -> {"command": "add", "args": ["Fix login"]}
276
+ - folder create (or mkdir): args = [folder name]. Example: create a folder called Reports -> {"command": "folder create", "args": ["Reports"]}
277
+ - 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"]}
278
+ - open: args = []. List open tasks.
279
+ - list: args = []. List all tasks.
280
+ - complete: args = [task id]. Mark task done. Example: complete task abc123 -> {"command": "complete", "args": ["abc123"]}
281
+ - requested: args = []. List tasks I requested.
282
+ - view: args = [task id]. Show task details.
283
+ - boards: args = []. List boards.
284
+ - buckets: args = []. List columns.
285
+ - assignees: args = []. List assignees.
286
+ - state: args = []. Full state JSON.
287
+ - move: args = [task id, bucket name or id]. Move task to another column.
288
+ - assign: args = [task id, assignee name]. Assign a task.
289
+ - whoami: args = []. Show current user/workspace.
290
+ - folders: args = []. List folders.
291
+ - files: args = []. List files; optionally include "--folder", "<id>" in args.
292
+ - members: args = []. List workspace members.
293
+ - calendar: args = []. List calendar events.
294
+ - calendar add: args = []; include "--title", "<t>", "--start", "<iso>", "--end", "<iso>" for new event (start/end required).
295
+ - docs: args = []. List workspace docs.
296
+ - doc create: args = [title]. Create a doc. Optional: "--parent", "<id>", "--emoji", "<char>".
297
+ - doc show: args = [doc id]. Show one doc.
298
+ - doc update: args = [doc id]. Optional: "--title", "<t>", "--content", "<json>", "--emoji", "--parent", "<id>".
299
+ - doc delete: args = [doc id]. Delete a doc.
300
+ - agent-tokens: args = []. List agent tokens.
301
+ - agent-token create: args = [name]. Create agent token; optional "--assignee-name", "<text>".
302
+ - agent-token delete: args = [token id]. Revoke token.
303
+ - board create: args = [board name]. Create board.
304
+ - board delete: args = [board id]. Delete board.
305
+ - bucket create: args = [column title]. Optional "--board", "<id>".
306
+ - bucket update: args = [bucket id]. Optional "--title", "<t>", "--order", "<n>".
307
+ - bucket delete: args = [bucket id]. Delete column.
308
+ - assignee create: args = [name]. Optional "--type", "human" or "agent".
309
+ - assignee update: args = [assignee id]. Optional "--name", "--type".
310
+ - assignee delete: args = [assignee id]. Delete assignee.
311
+
312
+ If the request is unclear or not a valid CLI action, return {"command": "", "args": []}. Output only the JSON object.`
313
+
314
+ async function parseNaturalLanguage(message) {
315
+ const apiKey = process.env.OPENAI_API_KEY || process.env.WADAH_OPENAI_API_KEY
316
+ if (!apiKey || !apiKey.trim()) {
317
+ throw new CliError('config', 'Set OPENAI_API_KEY or WADAH_OPENAI_API_KEY to use natural language (wadah do "...")')
318
+ }
319
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
320
+ method: 'POST',
321
+ headers: {
322
+ 'Content-Type': 'application/json',
323
+ Authorization: `Bearer ${apiKey.trim()}`,
324
+ },
325
+ body: JSON.stringify({
326
+ model: 'gpt-4o-mini',
327
+ messages: [
328
+ { role: 'system', content: DO_SYSTEM_PROMPT },
329
+ { role: 'user', content: String(message).trim() },
330
+ ],
331
+ max_tokens: 200,
332
+ temperature: 0,
333
+ }),
334
+ })
335
+ const data = await res.json().catch(() => ({}))
336
+ if (!res.ok) {
337
+ const err = data.error?.message || data.message || res.statusText
338
+ throw new CliError('api_error', `LLM request failed: ${err}`)
339
+ }
340
+ const text = data.choices?.[0]?.message?.content?.trim() || ''
341
+ const jsonMatch = text.match(/\{[\s\S]*\}/)
342
+ const raw = jsonMatch ? jsonMatch[0] : text
343
+ let parsed
344
+ try {
345
+ parsed = JSON.parse(raw)
346
+ } catch (_) {
347
+ throw new CliError('api_error', 'Could not parse LLM response as JSON')
348
+ }
349
+ const cmd = parsed.command && String(parsed.command).trim()
350
+ const args = Array.isArray(parsed.args) ? parsed.args.map(String) : []
351
+ return { command: cmd, args }
352
+ }
353
+
354
+ function runResolvedCommand(command, args) {
355
+ const cliPath = resolve(__dirname, 'cli.js')
356
+ const childArgs = [cliPath]
357
+ if (runtimeApiBase) childArgs.push('--api', runtimeApiBase)
358
+ if (runtimeToken) childArgs.push('--token', runtimeToken)
359
+ if (runtimeProfile) childArgs.push('--profile', runtimeProfile)
360
+ const cmdParts = String(command).split(/\s+/).filter(Boolean)
361
+ childArgs.push(...cmdParts, ...args)
362
+ return new Promise((resolve, reject) => {
363
+ const child = spawn(process.execPath, childArgs, { stdio: 'inherit', env: process.env, shell: false })
364
+ child.on('error', reject)
365
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Exit ${code}`))))
366
+ })
367
+ }
368
+
269
369
  function openBrowser(url) {
270
370
  let cmd
271
371
  let args
@@ -308,6 +408,27 @@ program.hook('preAction', (_, actionCommand) => {
308
408
  runtimeQuietOutput = Boolean(opts.quiet)
309
409
  })
310
410
 
411
+ // ── do (natural language) ──────────────────────────────────────────────────────
412
+
413
+ program
414
+ .command('do <message>')
415
+ .description('Run a command from natural language (e.g. "add a task to fix the login bug")')
416
+ .action(async (message) => {
417
+ try {
418
+ const { command, args } = await parseNaturalLanguage(message)
419
+ if (!command) {
420
+ 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'))
421
+ process.exit(1)
422
+ }
423
+ if (!runtimeQuietOutput && process.stdout.isTTY) {
424
+ console.log(chalk.gray(`Running: wadah ${command} ${args.join(' ')}\n`))
425
+ }
426
+ await runResolvedCommand(command, args)
427
+ } catch (err) {
428
+ handleError(err)
429
+ }
430
+ })
431
+
311
432
  // ── tm login ──────────────────────────────────────────────────────────────────
312
433
 
313
434
  program
@@ -955,6 +1076,503 @@ program
955
1076
  } catch (err) { handleError(err) }
956
1077
  })
957
1078
 
1079
+ // ── board CRUD ────────────────────────────────────────────────────────────────
1080
+
1081
+ const boardCmd = program.command('board').description('Create or delete a board (list: wadah boards)')
1082
+
1083
+ boardCmd
1084
+ .command('create <name>')
1085
+ .description('Create a new board')
1086
+ .action(async (name) => {
1087
+ try {
1088
+ const board = await api('/api/boards', { method: 'POST', body: JSON.stringify({ name: String(name).trim() }) })
1089
+ if (isMachineOutput()) {
1090
+ console.log(JSON.stringify(board, null, runtimeJsonOutput ? 2 : 0))
1091
+ } else {
1092
+ console.log(chalk.green('\n✓ Board created') + ` ${chalk.bold(board.name)} ${chalk.gray(board.id?.slice(0, 8))}\n`)
1093
+ }
1094
+ } catch (err) { handleError(err) }
1095
+ })
1096
+
1097
+ boardCmd
1098
+ .command('delete <id>')
1099
+ .description('Delete a board (cannot delete the last one)')
1100
+ .action(async (id) => {
1101
+ try {
1102
+ await api(`/api/boards/${id}`, { method: 'DELETE' })
1103
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Board deleted\n'))
1104
+ } catch (err) { handleError(err) }
1105
+ })
1106
+
1107
+ // ── bucket CRUD ──────────────────────────────────────────────────────────────
1108
+
1109
+ const bucketCmd = program.command('bucket').description('Create, update, or delete a column/bucket (list: wadah buckets)')
1110
+
1111
+ bucketCmd
1112
+ .command('create <title>')
1113
+ .description('Create a new bucket (column)')
1114
+ .option('--board <id>', 'Board id (optional)')
1115
+ .action(async function (title) {
1116
+ try {
1117
+ const body = { title: String(title).trim() }
1118
+ if (this.opts().board) body.boardId = this.opts().board
1119
+ const bucket = await api('/api/buckets', { method: 'POST', body: JSON.stringify(body) })
1120
+ if (isMachineOutput()) {
1121
+ console.log(JSON.stringify(bucket, null, runtimeJsonOutput ? 2 : 0))
1122
+ } else {
1123
+ console.log(chalk.green('\n✓ Bucket created') + ` ${chalk.bold(bucket.title)} ${chalk.gray(bucket.id?.slice(0, 8))}\n`)
1124
+ }
1125
+ } catch (err) { handleError(err) }
1126
+ })
1127
+
1128
+ bucketCmd
1129
+ .command('update <id>')
1130
+ .description('Update a bucket')
1131
+ .option('--title <text>', 'New title')
1132
+ .option('--order <n>', 'Order (number)', parseInt)
1133
+ .action(async function (id) {
1134
+ const opts = this.opts()
1135
+ try {
1136
+ const patch = {}
1137
+ if (opts.title !== undefined) patch.title = opts.title
1138
+ if (opts.order !== undefined && !Number.isNaN(opts.order)) patch.order = opts.order
1139
+ if (Object.keys(patch).length === 0) return fail('Provide --title or --order to update')
1140
+ await api(`/api/buckets/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1141
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Bucket updated\n'))
1142
+ } catch (err) { handleError(err) }
1143
+ })
1144
+
1145
+ bucketCmd
1146
+ .command('delete <id>')
1147
+ .description('Delete a bucket (tasks move to another column)')
1148
+ .action(async (id) => {
1149
+ try {
1150
+ await api(`/api/buckets/${id}`, { method: 'DELETE' })
1151
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Bucket deleted\n'))
1152
+ } catch (err) { handleError(err) }
1153
+ })
1154
+
1155
+ // ── assignee CRUD ────────────────────────────────────────────────────────────
1156
+
1157
+ const assigneeCmd = program.command('assignee').description('Create, update, or delete an assignee (list: wadah assignees)')
1158
+
1159
+ assigneeCmd
1160
+ .command('create <name>')
1161
+ .description('Create a new assignee')
1162
+ .option('--type <type>', 'Type: human or agent', 'human')
1163
+ .action(async function (name) {
1164
+ try {
1165
+ const body = { name: String(name).trim(), type: this.opts().type || 'human' }
1166
+ const assignee = await api('/api/assignees', { method: 'POST', body: JSON.stringify(body) })
1167
+ if (isMachineOutput()) {
1168
+ console.log(JSON.stringify(assignee, null, runtimeJsonOutput ? 2 : 0))
1169
+ } else {
1170
+ console.log(chalk.green('\n✓ Assignee created') + ` ${chalk.bold(assignee.name)} ${chalk.gray(assignee.id?.slice(0, 8))}\n`)
1171
+ }
1172
+ } catch (err) { handleError(err) }
1173
+ })
1174
+
1175
+ assigneeCmd
1176
+ .command('update <id>')
1177
+ .description('Update an assignee')
1178
+ .option('--name <text>', 'New name')
1179
+ .option('--type <type>', 'Type: human or agent')
1180
+ .action(async function (id) {
1181
+ const opts = this.opts()
1182
+ try {
1183
+ const patch = {}
1184
+ if (opts.name !== undefined) patch.name = opts.name
1185
+ if (opts.type !== undefined) patch.type = opts.type
1186
+ if (Object.keys(patch).length === 0) return fail('Provide --name or --type to update')
1187
+ await api(`/api/assignees/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1188
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Assignee updated\n'))
1189
+ } catch (err) { handleError(err) }
1190
+ })
1191
+
1192
+ assigneeCmd
1193
+ .command('delete <id>')
1194
+ .description('Delete an assignee (tasks unassigned)')
1195
+ .action(async (id) => {
1196
+ try {
1197
+ await api(`/api/assignees/${id}`, { method: 'DELETE' })
1198
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Assignee deleted\n'))
1199
+ } catch (err) { handleError(err) }
1200
+ })
1201
+
1202
+ program
1203
+ .command('folders')
1204
+ .description('List all folders (Files tab)')
1205
+ .action(async () => {
1206
+ try {
1207
+ const folders = await api('/api/folders').catch(() => [])
1208
+ const list = Array.isArray(folders) ? folders : []
1209
+ if (isMachineOutput()) {
1210
+ console.log(JSON.stringify(list, null, runtimeJsonOutput ? 2 : 0))
1211
+ } else if (list.length === 0) {
1212
+ console.log(chalk.gray('\nNo folders. Create one: wadah folder create "Name"\n'))
1213
+ } else {
1214
+ console.log()
1215
+ list.forEach((f) => console.log(` ${chalk.bold(f.name)} ${chalk.gray(`id: ${f.id}`)}`))
1216
+ console.log()
1217
+ }
1218
+ } catch (err) { handleError(err) }
1219
+ })
1220
+
1221
+ program
1222
+ .command('files')
1223
+ .description('List all files in the workspace')
1224
+ .option('--folder <id>', 'Filter by folder id')
1225
+ .action(async function () {
1226
+ const opts = this.opts()
1227
+ try {
1228
+ const state = await api('/api/state')
1229
+ let files = state.files ?? []
1230
+ if (opts.folder) files = files.filter((f) => (f.folderId || f.folder_id) === opts.folder)
1231
+ if (isMachineOutput()) {
1232
+ console.log(JSON.stringify(files, null, runtimeJsonOutput ? 2 : 0))
1233
+ } else if (files.length === 0) {
1234
+ console.log(chalk.gray('\nNo files. Upload one: wadah upload <path>\n'))
1235
+ } else {
1236
+ console.log()
1237
+ files.forEach((f) => {
1238
+ const name = f.name || f.id
1239
+ const url = f.storageUrl || f.storage_url
1240
+ console.log(` ${chalk.bold(name)} ${chalk.gray(url ? url.slice(0, 50) + '…' : f.id)}`)
1241
+ })
1242
+ console.log()
1243
+ }
1244
+ } catch (err) { handleError(err) }
1245
+ })
1246
+
1247
+ program
1248
+ .command('members')
1249
+ .description('List workspace members')
1250
+ .action(async () => {
1251
+ try {
1252
+ const members = await api('/api/workspace/members')
1253
+ const list = Array.isArray(members) ? members : []
1254
+ if (isMachineOutput()) {
1255
+ console.log(JSON.stringify(list, null, runtimeJsonOutput ? 2 : 0))
1256
+ } else if (list.length === 0) {
1257
+ console.log(chalk.gray('\nNo members.\n'))
1258
+ } else {
1259
+ console.log()
1260
+ list.forEach((m) => console.log(` ${chalk.bold(m.role)} ${chalk.gray(m.user_id?.slice(0, 8) ?? '—')}`))
1261
+ console.log()
1262
+ }
1263
+ } catch (err) { handleError(err) }
1264
+ })
1265
+
1266
+ // ── agent tokens ──────────────────────────────────────────────────────────────
1267
+
1268
+ program
1269
+ .command('agent-tokens')
1270
+ .description('List agent tokens for this workspace')
1271
+ .action(async () => {
1272
+ try {
1273
+ const list = await api('/api/agent-tokens').catch(() => [])
1274
+ const arr = Array.isArray(list) ? list : []
1275
+ if (isMachineOutput()) {
1276
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1277
+ } else if (arr.length === 0) {
1278
+ console.log(chalk.gray('\nNo agent tokens. Create one: wadah agent-token create "Bot name"\n'))
1279
+ } else {
1280
+ console.log()
1281
+ arr.forEach((t) => {
1282
+ const as = t.assigneeName ? chalk.gray(` · acts as ${t.assigneeName}`) : ''
1283
+ console.log(` ${chalk.bold(t.name)} ${chalk.gray(`id: ${t.id?.slice(0, 8)}${as}`)}`)
1284
+ })
1285
+ console.log()
1286
+ }
1287
+ } catch (err) { handleError(err) }
1288
+ })
1289
+
1290
+ const agentTokenCmd = program.command('agent-token').description('Create or delete an agent token (for CLI/CI)')
1291
+
1292
+ agentTokenCmd
1293
+ .command('create [name]')
1294
+ .description('Create an agent token; token is shown once — store it as TASK_MANAGER_TOKEN')
1295
+ .option('--name <text>', 'Token name (if not passed as argument)')
1296
+ .option('--assignee-name <text>', 'Display name for the agent (default: same as name)')
1297
+ .action(async function (nameArg) {
1298
+ const opts = this.opts()
1299
+ const name = (nameArg || opts.name || 'CLI Agent').trim()
1300
+ try {
1301
+ const body = { name }
1302
+ if (opts.assigneeName) body.assigneeName = opts.assigneeName
1303
+ const result = await api('/api/agent-tokens', { method: 'POST', body: JSON.stringify(body) })
1304
+ if (isMachineOutput()) {
1305
+ console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1306
+ } else {
1307
+ console.log(chalk.green('\n✓ Agent token created'))
1308
+ console.log(chalk.gray(` Name: ${result.name} Id: ${result.id?.slice(0, 8)} Acts as: ${result.assigneeName ?? result.name}`))
1309
+ console.log(chalk.yellow('\n Token (save it — shown once):'))
1310
+ console.log(` ${result.token}`)
1311
+ console.log(chalk.gray('\n Use: export TASK_MANAGER_TOKEN="<token>"\n'))
1312
+ }
1313
+ } catch (err) { handleError(err) }
1314
+ })
1315
+
1316
+ agentTokenCmd
1317
+ .command('delete <id>')
1318
+ .description('Revoke an agent token')
1319
+ .action(async (id) => {
1320
+ try {
1321
+ await api(`/api/agent-tokens/${id}`, { method: 'DELETE' })
1322
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Agent token revoked\n'))
1323
+ } catch (err) { handleError(err) }
1324
+ })
1325
+
1326
+ // ── calendar events ──────────────────────────────────────────────────────────
1327
+
1328
+ const calendarCmd = program
1329
+ .command('calendar')
1330
+ .description('List calendar events (optional: --assignee, --task, --from, --to)')
1331
+ .option('--assignee <id>', 'Filter by assignee id')
1332
+ .option('--task <id>', 'Filter by task id')
1333
+ .option('--from <iso>', 'From date (ISO)')
1334
+ .option('--to <iso>', 'To date (ISO)')
1335
+ .action(async function () {
1336
+ const opts = this.opts()
1337
+ try {
1338
+ let path = '/api/calendar-events'
1339
+ const q = []
1340
+ if (opts.assignee) q.push(`assigneeId=${encodeURIComponent(opts.assignee)}`)
1341
+ if (opts.task) q.push(`taskId=${encodeURIComponent(opts.task)}`)
1342
+ if (opts.from) q.push(`from=${encodeURIComponent(opts.from)}`)
1343
+ if (opts.to) q.push(`to=${encodeURIComponent(opts.to)}`)
1344
+ if (q.length) path += '?' + q.join('&')
1345
+ const list = await api(path).catch(() => [])
1346
+ const arr = Array.isArray(list) ? list : []
1347
+ if (isMachineOutput()) {
1348
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1349
+ } else if (arr.length === 0) {
1350
+ console.log(chalk.gray('\nNo calendar events.\n'))
1351
+ } else {
1352
+ console.log()
1353
+ arr.forEach((e) => {
1354
+ const start = e.startTime ? new Date(e.startTime).toLocaleString() : '—'
1355
+ const end = e.endTime ? new Date(e.endTime).toLocaleString() : '—'
1356
+ console.log(` ${chalk.bold(e.title || 'Untitled')} ${chalk.gray(`${start} → ${end}`)} ${chalk.gray(`id: ${e.id?.slice(0, 8)}`)}`)
1357
+ })
1358
+ console.log()
1359
+ }
1360
+ } catch (err) { handleError(err) }
1361
+ })
1362
+
1363
+ calendarCmd
1364
+ .command('add')
1365
+ .description('Create a calendar event (--start and --end required, ISO or YYYY-MM-DD)')
1366
+ .option('--title <text>', 'Event title', 'Work block')
1367
+ .option('--start <iso>', 'Start time (ISO or YYYY-MM-DD)')
1368
+ .option('--end <iso>', 'End time (ISO or YYYY-MM-DD)')
1369
+ .option('--task <id>', 'Task id')
1370
+ .option('--assignee <id>', 'Assignee id')
1371
+ .option('--color <hex>', 'Color')
1372
+ .action(async function () {
1373
+ const opts = this.opts()
1374
+ try {
1375
+ if (!opts.start || !opts.end) return fail('--start and --end are required')
1376
+ const startTime = new Date(opts.start).toISOString()
1377
+ const endTime = new Date(opts.end).toISOString()
1378
+ const body = { title: opts.title, startTime, endTime }
1379
+ if (opts.task) body.taskId = opts.task
1380
+ if (opts.assignee) body.assigneeId = opts.assignee
1381
+ if (opts.color) body.color = opts.color
1382
+ const event = await api('/api/calendar-events', { method: 'POST', body: JSON.stringify(body) })
1383
+ if (isMachineOutput()) {
1384
+ console.log(JSON.stringify(event, null, runtimeJsonOutput ? 2 : 0))
1385
+ } else {
1386
+ console.log(chalk.green('\n✓ Event created') + ` ${chalk.bold(event.title)} ${chalk.gray(event.id?.slice(0, 8))}\n`)
1387
+ }
1388
+ } catch (err) { handleError(err) }
1389
+ })
1390
+
1391
+ calendarCmd
1392
+ .command('update <id>')
1393
+ .description('Update a calendar event')
1394
+ .option('--title <text>', 'Event title')
1395
+ .option('--start <iso>', 'Start time')
1396
+ .option('--end <iso>', 'End time')
1397
+ .option('--task <id>', 'Task id')
1398
+ .option('--assignee <id>', 'Assignee id')
1399
+ .option('--color <hex>', 'Color')
1400
+ .action(async function (id) {
1401
+ const opts = this.opts()
1402
+ try {
1403
+ const patch = {}
1404
+ if (opts.title !== undefined) patch.title = opts.title
1405
+ if (opts.start !== undefined) patch.startTime = new Date(opts.start).toISOString()
1406
+ if (opts.end !== undefined) patch.endTime = new Date(opts.end).toISOString()
1407
+ if (opts.task !== undefined) patch.taskId = opts.task
1408
+ if (opts.assignee !== undefined) patch.assigneeId = opts.assignee
1409
+ if (opts.color !== undefined) patch.color = opts.color
1410
+ if (Object.keys(patch).length === 0) return fail('Provide at least one option to update')
1411
+ await api(`/api/calendar-events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1412
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Event updated\n'))
1413
+ } catch (err) { handleError(err) }
1414
+ })
1415
+
1416
+ calendarCmd
1417
+ .command('delete <id>')
1418
+ .description('Delete a calendar event')
1419
+ .action(async (id) => {
1420
+ try {
1421
+ await api(`/api/calendar-events/${id}`, { method: 'DELETE' })
1422
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Event deleted\n'))
1423
+ } catch (err) { handleError(err) }
1424
+ })
1425
+
1426
+ // ── workspace docs ─────────────────────────────────────────────────────────────
1427
+
1428
+ program
1429
+ .command('docs')
1430
+ .description('List workspace docs')
1431
+ .action(async () => {
1432
+ try {
1433
+ const list = await api('/api/docs').catch(() => [])
1434
+ const arr = Array.isArray(list) ? list : []
1435
+ if (isMachineOutput()) {
1436
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1437
+ } else if (arr.length === 0) {
1438
+ console.log(chalk.gray('\nNo docs.\n'))
1439
+ } else {
1440
+ console.log()
1441
+ arr.forEach((d) => console.log(` ${chalk.bold(d.title || 'Untitled')} ${chalk.gray(`id: ${d.id?.slice(0, 8)}`)}`))
1442
+ console.log()
1443
+ }
1444
+ } catch (err) { handleError(err) }
1445
+ })
1446
+
1447
+ const docCmd = program.command('doc').description('Workspace doc (create, show, update, delete)')
1448
+
1449
+ docCmd
1450
+ .command('create [title]')
1451
+ .description('Create a workspace doc')
1452
+ .option('--title <text>', 'Doc title (if not passed as argument)')
1453
+ .option('--parent <id>', 'Parent doc id')
1454
+ .option('--emoji <char>', 'Emoji')
1455
+ .action(async function (titleArg) {
1456
+ const opts = this.opts()
1457
+ const title = (titleArg || opts.title || 'Untitled').trim()
1458
+ try {
1459
+ const body = { title }
1460
+ if (opts.parent) body.parentId = opts.parent
1461
+ if (opts.emoji) body.emoji = opts.emoji
1462
+ const doc = await api('/api/docs', { method: 'POST', body: JSON.stringify(body) })
1463
+ if (isMachineOutput()) {
1464
+ console.log(JSON.stringify(doc, null, runtimeJsonOutput ? 2 : 0))
1465
+ } else {
1466
+ console.log(chalk.green('\n✓ Doc created') + ` ${chalk.bold(doc.title)} ${chalk.gray(doc.id?.slice(0, 8))}\n`)
1467
+ }
1468
+ } catch (err) { handleError(err) }
1469
+ })
1470
+
1471
+ docCmd
1472
+ .command('show <id>')
1473
+ .description('Show a workspace doc')
1474
+ .action(async (id) => {
1475
+ try {
1476
+ const doc = await api(`/api/docs/${id}`)
1477
+ if (isMachineOutput()) {
1478
+ console.log(JSON.stringify(doc, null, runtimeJsonOutput ? 2 : 0))
1479
+ } else {
1480
+ console.log()
1481
+ console.log(chalk.bold(doc.title || 'Untitled'))
1482
+ console.log(chalk.gray(`id: ${doc.id}`))
1483
+ if (doc.content && typeof doc.content === 'object') {
1484
+ const text = JSON.stringify(doc.content)
1485
+ console.log(chalk.gray(text.slice(0, 200) + (text.length > 200 ? '…' : '')))
1486
+ }
1487
+ console.log()
1488
+ }
1489
+ } catch (err) { handleError(err) }
1490
+ })
1491
+
1492
+ docCmd
1493
+ .command('update <id>')
1494
+ .description('Update a workspace doc')
1495
+ .option('--title <text>', 'Doc title')
1496
+ .option('--content <json>', 'Content as JSON string')
1497
+ .option('--emoji <char>', 'Emoji')
1498
+ .option('--parent <id>', 'Parent doc id')
1499
+ .action(async function (id) {
1500
+ const opts = this.opts()
1501
+ try {
1502
+ const patch = {}
1503
+ if (opts.title !== undefined) patch.title = opts.title
1504
+ if (opts.content !== undefined) {
1505
+ try {
1506
+ patch.content = typeof opts.content === 'string' ? JSON.parse(opts.content) : opts.content
1507
+ } catch (_) {
1508
+ return fail('--content must be valid JSON')
1509
+ }
1510
+ }
1511
+ if (opts.emoji !== undefined) patch.emoji = opts.emoji
1512
+ if (opts.parent !== undefined) patch.parentId = opts.parent
1513
+ if (Object.keys(patch).length === 0) return fail('Provide at least one option to update')
1514
+ await api(`/api/docs/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
1515
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Doc updated\n'))
1516
+ } catch (err) { handleError(err) }
1517
+ })
1518
+
1519
+ docCmd
1520
+ .command('delete <id>')
1521
+ .description('Delete a workspace doc')
1522
+ .action(async (id) => {
1523
+ try {
1524
+ await api(`/api/docs/${id}`, { method: 'DELETE' })
1525
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Doc deleted\n'))
1526
+ } catch (err) { handleError(err) }
1527
+ })
1528
+
1529
+ program
1530
+ .command('folder create <name>')
1531
+ .alias('mkdir')
1532
+ .description('Create a folder in the workspace (Files tab)')
1533
+ .action(async (name) => {
1534
+ try {
1535
+ const folder = await api('/api/folders', {
1536
+ method: 'POST',
1537
+ body: JSON.stringify({ name: String(name).trim() }),
1538
+ })
1539
+ if (isMachineOutput()) {
1540
+ console.log(JSON.stringify(folder, null, runtimeJsonOutput ? 2 : 0))
1541
+ } else {
1542
+ console.log(chalk.green('\n✓ Folder created') + ` ${chalk.bold(folder.name)} ${chalk.gray(`(id: ${folder.id})\n`)}`)
1543
+ }
1544
+ } catch (err) { handleError(err) }
1545
+ })
1546
+
1547
+ program
1548
+ .command('upload <path>')
1549
+ .description('Upload a file to the workspace (optional: --folder <id>)')
1550
+ .option('--folder <id>', 'Put the file in this folder id')
1551
+ .action(async (pathArg, opts) => {
1552
+ try {
1553
+ await ensureAuth()
1554
+ const filePath = resolve(pathArg)
1555
+ if (!existsSync(filePath)) return fail(`File not found: ${filePath}`)
1556
+ const form = new FormData()
1557
+ form.append('file', createReadStream(filePath), { filename: basename(filePath) })
1558
+ if (opts.folder) form.append('folderId', String(opts.folder).trim())
1559
+ const headers = { ...authHeaders() }
1560
+ delete headers['Content-Type']
1561
+ const res = await fetch(`${getApiBase()}/api/files/upload`, {
1562
+ method: 'POST',
1563
+ headers: { ...headers, ...form.getHeaders() },
1564
+ body: form,
1565
+ })
1566
+ const data = await res.json().catch(() => ({}))
1567
+ if (!res.ok) throw new CliError(data.error ?? 'Upload failed', data.error ?? res.statusText)
1568
+ if (isMachineOutput()) {
1569
+ console.log(JSON.stringify(data, null, runtimeJsonOutput ? 2 : 0))
1570
+ } else {
1571
+ console.log(chalk.green('\n✓ Uploaded') + ` ${chalk.bold(data.name)} ${chalk.gray(`→ ${data.storageUrl ?? data.id}\n`)}`)
1572
+ }
1573
+ } catch (err) { handleError(err) }
1574
+ })
1575
+
958
1576
  program
959
1577
  .command('state')
960
1578
  .description('Print full workspace state as JSON')
@@ -1115,4 +1733,39 @@ program
1115
1733
  console.log()
1116
1734
  })
1117
1735
 
1736
+ // ── shell completion ──────────────────────────────────────────────────────────
1737
+
1738
+ const CLI_COMMANDS = [
1739
+ 'add', 'agent-token', 'agent-tokens', 'assign', 'assignee', 'assignees', 'board', 'boards',
1740
+ 'bucket', 'buckets', 'calendar', 'comment', 'complete', 'config', 'delete', 'doc', 'docs',
1741
+ 'do', 'doctor', 'files', 'folder', 'folders', 'invite', 'list', 'login', 'members', 'mkdir',
1742
+ 'move', 'open', 'reopen', 'requested', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
1743
+ ]
1744
+
1745
+ program
1746
+ .command('completion [shell]')
1747
+ .description('Print shell completion script (bash or zsh). Add to your profile: wadah completion bash >> ~/.bashrc')
1748
+ .action((shell) => {
1749
+ const sh = (shell || process.env.SHELL || 'bash').toLowerCase()
1750
+ if (sh.includes('zsh')) {
1751
+ console.log(`# zsh completion for wadah
1752
+ _wadah() {
1753
+ local cur
1754
+ cur=\${words[CURRENT]}
1755
+ reply=(${CLI_COMMANDS.join(' ')})
1756
+ }
1757
+ compctl -K _wadah wadah tm ow
1758
+ `)
1759
+ } else {
1760
+ console.log(`# bash completion for wadah
1761
+ _wadah_completion() {
1762
+ local cur=\${COMP_WORDS[COMP_CWORD]}
1763
+ local cmds="${CLI_COMMANDS.join(' ')}"
1764
+ COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
1765
+ }
1766
+ complete -F _wadah_completion wadah tm ow
1767
+ `)
1768
+ }
1769
+ })
1770
+
1118
1771
  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.1.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
+ })