@nickmeriano/task 0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/author.d.ts +17 -0
  4. package/dist/author.d.ts.map +1 -0
  5. package/dist/author.js +52 -0
  6. package/dist/author.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +415 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/server.d.ts +14 -0
  16. package/dist/server.d.ts.map +1 -0
  17. package/dist/server.js +292 -0
  18. package/dist/server.js.map +1 -0
  19. package/dist/store.d.ts +63 -0
  20. package/dist/store.d.ts.map +1 -0
  21. package/dist/store.js +346 -0
  22. package/dist/store.js.map +1 -0
  23. package/dist/types.d.ts +61 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +13 -0
  26. package/dist/types.js.map +1 -0
  27. package/package.json +81 -0
  28. package/skill/SKILL.md +109 -0
  29. package/src/author.ts +57 -0
  30. package/src/cli.ts +419 -0
  31. package/src/index.ts +6 -0
  32. package/src/server.ts +311 -0
  33. package/src/store.ts +418 -0
  34. package/src/types.ts +75 -0
  35. package/ui/dist/assets/index-BopXdeSy.js +229 -0
  36. package/ui/dist/assets/index-Cl_P2tLU.css +1 -0
  37. package/ui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  38. package/ui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  39. package/ui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  40. package/ui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  41. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  42. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  43. package/ui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  44. package/ui/dist/icon.svg +4 -0
  45. package/ui/dist/index.html +21 -0
package/src/author.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import process from "node:process"
3
+
4
+ export type AuthorSource = "flag" | "env" | "git" | "fallback"
5
+
6
+ export interface Author {
7
+ name: string
8
+ source: AuthorSource
9
+ }
10
+
11
+ export const ANONYMOUS = "anonymous"
12
+
13
+ /** Resolved at most once per process — git config doesn't change mid-command. */
14
+ const gitNameCache = new Map<string, string | null>()
15
+
16
+ /**
17
+ * Ask git who you are. Every way this can fail — git not installed, not a
18
+ * repository, `user.name` never set — is the same non-answer, so they all
19
+ * collapse to null rather than being told apart.
20
+ */
21
+ function gitName(cwd: string): string | null {
22
+ const cached = gitNameCache.get(cwd)
23
+ if (cached !== undefined) return cached
24
+ let name: string | null = null
25
+ try {
26
+ const out = execFileSync("git", ["config", "--get", "user.name"], {
27
+ cwd,
28
+ encoding: "utf8",
29
+ timeout: 1000,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ })
32
+ name = out.trim() || null
33
+ } catch {
34
+ name = null
35
+ }
36
+ gitNameCache.set(cwd, name)
37
+ return name
38
+ }
39
+
40
+ /**
41
+ * Who is writing this comment. No config file: git already knows your name and
42
+ * it's the same one your commits carry, so attribution costs zero setup.
43
+ *
44
+ * --author <who> → $TASK_AUTHOR → git config user.name → "anonymous"
45
+ *
46
+ * Agents keep passing `--author claude` explicitly, which is what the first
47
+ * step is for.
48
+ */
49
+ export function resolveAuthor(flag?: string, cwd: string = process.cwd()): Author {
50
+ const flagged = flag?.trim()
51
+ if (flagged) return { name: flagged, source: "flag" }
52
+ const env = process.env.TASK_AUTHOR?.trim()
53
+ if (env) return { name: env, source: "env" }
54
+ const git = gitName(cwd)
55
+ if (git) return { name: git, source: "git" }
56
+ return { name: ANONYMOUS, source: "fallback" }
57
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ // The `task` CLI — tasks that live in your repo. Zero dependencies: hand-rolled
3
+ // flag parsing, node:sqlite for storage, plain text out (--json for agents).
4
+ //
5
+ // task init
6
+ // task add "Wire up webhooks" --tags api,infra --milestone launch
7
+ // task list --status todo,in_progress
8
+ // task start TASK-3 && task done TASK-3
9
+ // task serve
10
+
11
+ import { readFileSync } from "node:fs"
12
+ import { basename, join } from "node:path"
13
+ import process from "node:process"
14
+ import { resolveAuthor } from "./author.js"
15
+ import { createTaskServer } from "./server.js"
16
+ import { CONFIG_FILE, TASK_DIR, TaskStore, findBoards, findRoot, initProject } from "./store.js"
17
+ import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.js"
18
+
19
+ // node:sqlite still emits an ExperimentalWarning on Node 22 — noise in a CLI
20
+ // that runs it on every invocation. Filter that one warning, keep the rest.
21
+ process.removeAllListeners("warning")
22
+ process.on("warning", (warning) => {
23
+ if (warning.name !== "ExperimentalWarning") console.error(warning)
24
+ })
25
+
26
+ const VERSION = "0.1.0"
27
+ const DEFAULT_PORT = 4400
28
+
29
+ // ── Argument parsing: positionals + --flag value / --flag ────────────────────
30
+
31
+ interface Args {
32
+ positional: string[]
33
+ flags: Record<string, string | boolean>
34
+ }
35
+
36
+ // Every valueless flag has to be listed here: an unregistered one swallows the
37
+ // next argv token, so `task list --needs-human todo` would silently eat `todo`.
38
+ const BOOLEAN_FLAGS = new Set([
39
+ "json",
40
+ "all",
41
+ "help",
42
+ "version",
43
+ "yes",
44
+ "needs-human",
45
+ "no-needs-human",
46
+ ])
47
+
48
+ function parseArgs(argv: string[]): Args {
49
+ const positional: string[] = []
50
+ const flags: Record<string, string | boolean> = {}
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const arg = argv[i]
53
+ if (arg.startsWith("--")) {
54
+ const name = arg.slice(2)
55
+ const eq = name.indexOf("=")
56
+ if (eq >= 0) {
57
+ flags[name.slice(0, eq)] = name.slice(eq + 1)
58
+ } else if (BOOLEAN_FLAGS.has(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) {
59
+ flags[name] = true
60
+ } else {
61
+ flags[name] = argv[++i]
62
+ }
63
+ } else {
64
+ positional.push(arg)
65
+ }
66
+ }
67
+ return { positional, flags }
68
+ }
69
+
70
+ function str(flags: Args["flags"], name: string): string | undefined {
71
+ const v = flags[name]
72
+ return typeof v === "string" ? v : undefined
73
+ }
74
+
75
+ function fail(message: string): never {
76
+ console.error(`error: ${message}`)
77
+ process.exit(1)
78
+ }
79
+
80
+ function openStore(): TaskStore {
81
+ const root = findRoot(process.cwd())
82
+ if (!root) fail("no .task directory found in this directory or any parent — run `task init` first")
83
+ return new TaskStore(root)
84
+ }
85
+
86
+ // ── Input normalization ──────────────────────────────────────────────────────
87
+
88
+ function parseStatus(value: string): Status {
89
+ const normalized = value.toLowerCase().replace(/-/g, "_")
90
+ if (!isStatus(normalized)) fail(`invalid status "${value}" — one of: ${STATUSES.join(", ")}`)
91
+ return normalized
92
+ }
93
+
94
+ function parseTags(value: string): string[] {
95
+ return value.split(",").map((t) => t.trim()).filter(Boolean)
96
+ }
97
+
98
+ function patchFromFlags(flags: Args["flags"]): TaskPatch {
99
+ const patch: TaskPatch = {}
100
+ const title = str(flags, "title")
101
+ if (title !== undefined) patch.title = title
102
+ const description = str(flags, "description") ?? str(flags, "desc")
103
+ if (description !== undefined) patch.description = description
104
+ const status = str(flags, "status")
105
+ if (status !== undefined) patch.status = parseStatus(status)
106
+ const tags = str(flags, "tags") ?? str(flags, "tag")
107
+ if (tags !== undefined) patch.tags = parseTags(tags)
108
+ const milestone = str(flags, "milestone")
109
+ if (milestone !== undefined) patch.milestone = milestone.trim() || null
110
+ if (flags["needs-human"]) patch.needsHuman = true
111
+ if (flags["no-needs-human"]) patch.needsHuman = false
112
+ return patch
113
+ }
114
+
115
+ // ── Output helpers ───────────────────────────────────────────────────────────
116
+
117
+ const STATUS_GLYPH: Record<Status, string> = {
118
+ backlog: "◌",
119
+ todo: "○",
120
+ in_progress: "◐",
121
+ done: "●",
122
+ canceled: "⊘",
123
+ }
124
+
125
+ function table(rows: string[][]): void {
126
+ if (rows.length === 0) return
127
+ const widths = rows[0].map((_, col) => Math.max(...rows.map((r) => (r[col] ?? "").length)))
128
+ for (const row of rows) {
129
+ console.log(row.map((cell, col) => (cell ?? "").padEnd(widths[col])).join(" ").trimEnd())
130
+ }
131
+ }
132
+
133
+ function taskRow(t: Task): string[] {
134
+ // Exactly six cells, always — table() lines columns up by index.
135
+ return [
136
+ t.id,
137
+ `${STATUS_GLYPH[t.status]} ${t.status}`,
138
+ t.needsHuman ? "⚑" : "",
139
+ t.title,
140
+ t.tags.join(","),
141
+ t.milestone ?? "",
142
+ ]
143
+ }
144
+
145
+ function printTask(t: Task): void {
146
+ console.log(`${t.id} ${STATUS_GLYPH[t.status]} ${t.status} ${t.title}`)
147
+ }
148
+
149
+ // ── Commands ─────────────────────────────────────────────────────────────────
150
+
151
+ function cmdInit(args: Args): void {
152
+ const cwd = process.cwd()
153
+ if (findRoot(cwd) === cwd) fail("already initialized — .task/ exists here")
154
+ const name = str(args.flags, "name") ?? basename(cwd)
155
+ const store = initProject(cwd, { name, prefix: str(args.flags, "prefix")?.toUpperCase() })
156
+ console.log(`Initialized ${store.config.name} (${store.config.prefix}-…) in .task/`)
157
+
158
+ console.log(`\nNext:`)
159
+ console.log(` task add "My first task" create a task`)
160
+ console.log(` task list see what's open`)
161
+ console.log(` task serve open the board UI`)
162
+ console.log(`\nCommit .task/ — tasks belong in the repo, next to the code they describe.`)
163
+ // Deliberately not installed for you: where an agent's skills live differs
164
+ // per agent and per repo layout, and a task manager guessing at that is how
165
+ // you end up with a copy somewhere nothing reads. `skill/` ships in this
166
+ // package — point your agent's skill installer at it.
167
+ console.log(`Teach your agent the conventions: skill/SKILL.md in this package.`)
168
+ }
169
+
170
+ function cmdAdd(args: Args): void {
171
+ const title = args.positional.join(" ").trim()
172
+ if (!title) fail(`usage: task add <title> [--description …] [--status …] [--tags a,b] [--milestone …] [--needs-human]`)
173
+ const store = openStore()
174
+ const task = store.create({ title, ...patchFromFlags(args.flags) })
175
+ if (args.flags.json) {
176
+ console.log(JSON.stringify({ task }, null, 2))
177
+ } else {
178
+ printTask(task)
179
+ }
180
+ }
181
+
182
+ function cmdList(args: Args): void {
183
+ const store = openStore()
184
+ const statusFlag = str(args.flags, "status")
185
+ const statuses = statusFlag
186
+ ? statusFlag.split(",").map(parseStatus)
187
+ : args.flags.all
188
+ ? undefined
189
+ : (["backlog", "todo", "in_progress"] as Status[])
190
+ const tagFlag = str(args.flags, "tags") ?? str(args.flags, "tag")
191
+ const tasks = store.list({
192
+ statuses,
193
+ tags: tagFlag ? parseTags(tagFlag) : undefined,
194
+ milestone: str(args.flags, "milestone"),
195
+ needsHuman: args.flags["needs-human"] ? true : undefined,
196
+ })
197
+ // Present in board order: grouped by status column, then position.
198
+ const order = new Map(STATUSES.map((s, i) => [s, i]))
199
+ tasks.sort((a, b) => order.get(a.status)! - order.get(b.status)! || a.position - b.position)
200
+ if (args.flags.json) {
201
+ console.log(JSON.stringify({ tasks }, null, 2))
202
+ } else if (tasks.length === 0) {
203
+ console.log(args.flags.all ? "no tasks" : "no open tasks (--all includes done/canceled)")
204
+ } else {
205
+ table(tasks.map(taskRow))
206
+ }
207
+ }
208
+
209
+ function cmdShow(args: Args): void {
210
+ const ref = args.positional[0]
211
+ if (!ref) fail("usage: task show <id>")
212
+ const store = openStore()
213
+ const number = store.parseId(ref)
214
+ const task = store.get(number)
215
+ if (!task) fail(`no such task: ${store.displayId(number)}`)
216
+ const comments = store.comments(number)
217
+ if (args.flags.json) {
218
+ console.log(JSON.stringify({ task, comments }, null, 2))
219
+ return
220
+ }
221
+ console.log(`${task.id} ${task.title}`)
222
+ console.log(`status ${task.status}`)
223
+ if (task.needsHuman) console.log(`needs a human`)
224
+ if (task.tags.length) console.log(`tags ${task.tags.join(", ")}`)
225
+ if (task.milestone) console.log(`milestone ${task.milestone}`)
226
+ console.log(`created ${task.createdAt}`)
227
+ console.log(`updated ${task.updatedAt}`)
228
+ if (task.description) console.log(`\n${task.description}`)
229
+ if (comments.length) {
230
+ console.log(``)
231
+ for (const c of comments) {
232
+ console.log(`— ${c.author || "anonymous"} · ${c.createdAt}`)
233
+ console.log(` ${c.body.split("\n").join("\n ")}`)
234
+ }
235
+ }
236
+ }
237
+
238
+ function cmdUpdate(args: Args, forcedStatus?: Status): void {
239
+ const ref = args.positional[0]
240
+ if (!ref) fail("usage: task update <id> [--status …] [--title …] …")
241
+ const store = openStore()
242
+ const patch = patchFromFlags(args.flags)
243
+ if (forcedStatus) patch.status = forcedStatus
244
+ if (Object.keys(patch).length === 0) fail("nothing to update — pass at least one flag")
245
+ const task = store.update(store.parseId(ref), patch)
246
+ if (args.flags.json) {
247
+ console.log(JSON.stringify({ task }, null, 2))
248
+ } else {
249
+ printTask(task)
250
+ }
251
+ }
252
+
253
+ function cmdMove(args: Args): void {
254
+ const [ref, status] = args.positional
255
+ if (!ref || !status) fail("usage: task move <id> <status>")
256
+ cmdUpdate({ positional: [ref], flags: { ...args.flags, status } })
257
+ }
258
+
259
+ function cmdComment(args: Args): void {
260
+ const [ref, ...rest] = args.positional
261
+ const body = rest.join(" ").trim()
262
+ if (!ref || !body) fail(`usage: task comment <id> <text> [--author <who>]`)
263
+ const store = openStore()
264
+ const author = resolveAuthor(str(args.flags, "author")).name
265
+ const comment = store.addComment(store.parseId(ref), body, author)
266
+ if (args.flags.json) {
267
+ console.log(JSON.stringify({ comment }, null, 2))
268
+ } else {
269
+ console.log(`commented on ${comment.taskId}`)
270
+ }
271
+ }
272
+
273
+ const AUTHOR_SOURCE: Record<string, string> = {
274
+ flag: "--author",
275
+ env: "$TASK_AUTHOR",
276
+ git: "git config user.name",
277
+ fallback: "no name found — set git config user.name or $TASK_AUTHOR",
278
+ }
279
+
280
+ /** Deliberately never opens the store, so it answers outside a .task/ repo. */
281
+ function cmdWhoami(args: Args): void {
282
+ const author = resolveAuthor(str(args.flags, "author"))
283
+ if (args.flags.json) {
284
+ console.log(JSON.stringify(author, null, 2))
285
+ } else {
286
+ console.log(`${author.name} (${AUTHOR_SOURCE[author.source]})`)
287
+ }
288
+ }
289
+
290
+ function cmdDelete(args: Args): void {
291
+ const ref = args.positional[0]
292
+ if (!ref) fail("usage: task delete <id>")
293
+ const store = openStore()
294
+ const number = store.parseId(ref)
295
+ store.delete(number)
296
+ console.log(`deleted ${store.displayId(number)}`)
297
+ }
298
+
299
+ function cmdServe(args: Args): void {
300
+ // Serve is the one command that's monorepo-aware: anchor at the nearest
301
+ // board like every other command, but if there is none, serve whatever
302
+ // boards live *below* here instead of failing.
303
+ const serveRoot = findRoot(process.cwd()) ?? process.cwd()
304
+ const boards = findBoards(serveRoot)
305
+ if (boards.length === 0) {
306
+ fail("no .task directory found in this directory, any parent, or below — run `task init` first")
307
+ }
308
+ const port = Number(str(args.flags, "port") ?? DEFAULT_PORT)
309
+ if (!Number.isInteger(port) || port <= 0) fail(`invalid port: ${str(args.flags, "port")}`)
310
+ const server = createTaskServer(serveRoot)
311
+ server.on("error", (error: NodeJS.ErrnoException) => {
312
+ if (error.code === "EADDRINUSE") fail(`port ${port} is in use — try --port <n>`)
313
+ fail(error.message)
314
+ })
315
+ server.listen(port, () => {
316
+ const boardName = (root: string): string =>
317
+ (JSON.parse(readFileSync(join(root, TASK_DIR, CONFIG_FILE), "utf8")) as { name: string })
318
+ .name
319
+ if (boards.length === 1) {
320
+ console.log(`${boardName(boards[0].root)} · task board`)
321
+ } else {
322
+ console.log(`${boards.length} boards · task board`)
323
+ for (const board of boards) console.log(` ${board.id} ${boardName(board.root)}`)
324
+ }
325
+ console.log(` http://localhost:${port}`)
326
+ console.log(`Live: edits from any terminal or agent show up as they happen. Ctrl-C to stop.`)
327
+ })
328
+ }
329
+
330
+ // ── Help + dispatch ──────────────────────────────────────────────────────────
331
+
332
+ const HELP = `task — a task manager that lives in your repo
333
+
334
+ State is a SQLite database in .task/ at the project root. Commit it: tasks and
335
+ their status travel with the code they describe. Any command works from any
336
+ subdirectory (it walks up to find .task/, like git).
337
+
338
+ Usage
339
+ task init [--name <name>] [--prefix <PREFIX>]
340
+ task add <title> [--description <text>] [--status <s>] [--tags <a,b>]
341
+ [--milestone <m>] [--needs-human]
342
+ task list [--status <s1,s2>] [--tag <a,b>] [--milestone <m>]
343
+ [--needs-human] [--all]
344
+ task show <id>
345
+ task update <id> [--title <t>] [--description <text>] [--status <s>]
346
+ [--tags <a,b>] [--milestone <m>]
347
+ [--needs-human | --no-needs-human]
348
+ task move <id> <status> shorthand for update --status
349
+ task start <id> → in_progress
350
+ task done <id> → done
351
+ task comment <id> <text> [--author <who>]
352
+ task delete <id>
353
+ task whoami who your comments are attributed to
354
+ task serve [--port <n>] board + table UI with live updates (default port ${DEFAULT_PORT});
355
+ serves every board at or below here — in a
356
+ monorepo the header becomes a board switcher
357
+
358
+ Values
359
+ <id> TASK-12, or just 12
360
+ status ${STATUSES.join(" ")}
361
+ --tag a,b matches a task carrying *either* tag
362
+ --needs-human this can't be finished by an agent alone
363
+ clearing --tags "" drops all tags, --milestone "" clears it
364
+
365
+ Comment authors resolve --author → $TASK_AUTHOR → git config user.name →
366
+ anonymous, so there is nothing to set up. \`task whoami\` shows which one won.
367
+
368
+ Every read/write command accepts --json for machine-readable output — that's
369
+ the interface AI agents should use.
370
+ `
371
+
372
+ function main(): void {
373
+ const [command, ...rest] = process.argv.slice(2)
374
+ const args = parseArgs(rest)
375
+
376
+ if (!command || command === "help" || args.flags.help) {
377
+ console.log(HELP)
378
+ return
379
+ }
380
+ if (command === "--version" || args.flags.version) {
381
+ console.log(VERSION)
382
+ return
383
+ }
384
+
385
+ switch (command) {
386
+ case "init":
387
+ return cmdInit(args)
388
+ case "add":
389
+ return cmdAdd(args)
390
+ case "list":
391
+ return cmdList(args)
392
+ case "show":
393
+ return cmdShow(args)
394
+ case "update":
395
+ return cmdUpdate(args)
396
+ case "move":
397
+ return cmdMove(args)
398
+ case "start":
399
+ return cmdUpdate(args, "in_progress")
400
+ case "done":
401
+ return cmdUpdate(args, "done")
402
+ case "comment":
403
+ return cmdComment(args)
404
+ case "whoami":
405
+ return cmdWhoami(args)
406
+ case "delete":
407
+ return cmdDelete(args)
408
+ case "serve":
409
+ return cmdServe(args)
410
+ default:
411
+ fail(`unknown command "${command}" — run \`task help\``)
412
+ }
413
+ }
414
+
415
+ try {
416
+ main()
417
+ } catch (error) {
418
+ fail(error instanceof Error ? error.message : String(error))
419
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Programmatic access to the same store the CLI and `task serve` use — for
2
+ // scripts that want to read or write .task/ without shelling out.
3
+ export { TaskStore, findRoot, findBoards, initProject, derivePrefix, type BoardRef } from "./store.js"
4
+ export { createTaskServer } from "./server.js"
5
+ export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.js"
6
+ export * from "./types.js"