taskchef 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,303 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ addProject,
6
+ buildReconciliationCandidates,
7
+ buildTaskSummary,
8
+ createTask,
9
+ doctorWorkspace,
10
+ filterTasks,
11
+ importProjects,
12
+ initializeWorkspace,
13
+ listProjects,
14
+ readTask,
15
+ removeProject,
16
+ updateTask,
17
+ } from "./workspace.js";
18
+
19
+ async function readStdin() {
20
+ let input = "";
21
+ process.stdin.setEncoding("utf8");
22
+ for await (const chunk of process.stdin) input += chunk;
23
+ return input;
24
+ }
25
+
26
+ async function readJsonStdin() {
27
+ const input = await readStdin();
28
+ if (input.trim().length === 0) throw new Error("expected JSON on standard input");
29
+ return JSON.parse(input);
30
+ }
31
+
32
+ function option(args, name, fallback) {
33
+ const index = args.indexOf(name);
34
+ if (index === -1) return fallback;
35
+ if (!args[index + 1] || args[index + 1].startsWith("--")) {
36
+ throw new Error(`${name} requires a value`);
37
+ }
38
+ return args[index + 1];
39
+ }
40
+
41
+ function options(args, name) {
42
+ const values = [];
43
+ for (let index = 0; index < args.length; index += 1) {
44
+ if (args[index] !== name) continue;
45
+ if (!args[index + 1] || args[index + 1].startsWith("--")) {
46
+ throw new Error(`${name} requires a value`);
47
+ }
48
+ values.push(args[index + 1]);
49
+ index += 1;
50
+ }
51
+ return values;
52
+ }
53
+
54
+ function validateCommandArgs(
55
+ args,
56
+ startIndex,
57
+ { values = [], switches = [], repeatable = [] } = {},
58
+ ) {
59
+ const valueOptions = new Set(values);
60
+ const booleanOptions = new Set(switches);
61
+ const repeatableOptions = new Set(repeatable);
62
+ const seen = new Set();
63
+ for (let index = startIndex; index < args.length; index += 1) {
64
+ const token = args[index];
65
+ if (!token.startsWith("--") || token.includes("=")) {
66
+ throw new Error(`unexpected argument: ${token}`);
67
+ }
68
+ if (!valueOptions.has(token) && !booleanOptions.has(token)) {
69
+ throw new Error(`unsupported option: ${token}`);
70
+ }
71
+ if (seen.has(token) && !repeatableOptions.has(token)) {
72
+ throw new Error(`duplicate option: ${token}`);
73
+ }
74
+ seen.add(token);
75
+ if (valueOptions.has(token)) {
76
+ if (!args[index + 1] || args[index + 1].startsWith("--")) {
77
+ throw new Error(`${token} requires a value`);
78
+ }
79
+ index += 1;
80
+ }
81
+ }
82
+ }
83
+
84
+ function workspaceRoot(args) {
85
+ return path.resolve(option(args, "--workspace", process.cwd()));
86
+ }
87
+
88
+ function print(value, args, human) {
89
+ if (args.includes("--json")) {
90
+ process.stdout.write(`${JSON.stringify(value)}\n`);
91
+ return;
92
+ }
93
+ process.stdout.write(`${human ? human(value) : JSON.stringify(value, null, 2)}\n`);
94
+ }
95
+
96
+ function table(headers, rows) {
97
+ const widths = headers.map((header, index) =>
98
+ Math.max(header.length, ...rows.map((row) => String(row[index]).length)));
99
+ const format = (row) => row.map((value, index) => String(value).padEnd(widths[index])).join(" ");
100
+ return [format(headers), ...rows.map(format)].join("\n");
101
+ }
102
+
103
+ async function initialize(args) {
104
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
105
+ const result = await initializeWorkspace(workspaceRoot(args));
106
+ print(result, args, (value) => [
107
+ `Workspace: ${value.workspace}`,
108
+ `Configuration: ${value.config.action}`,
109
+ `Instructions: ${value.instructions.action}`,
110
+ `Skills: ${value.skills.skills.map((skill) => `${skill.name}=${skill.action}`).join(", ")}`,
111
+ ].join("\n"));
112
+ return 0;
113
+ }
114
+
115
+ async function doctor(args) {
116
+ validateCommandArgs(args, 1, { values: ["--workspace"], switches: ["--json"] });
117
+ const result = await doctorWorkspace(workspaceRoot(args));
118
+ print(result, args, (value) => value.checks
119
+ .map((check) => `${check.status === "pass" ? "✓" : "✗"} ${check.name}: ${check.message}`)
120
+ .join("\n"));
121
+ return result.ok ? 0 : 1;
122
+ }
123
+
124
+ async function projectAdd(args) {
125
+ if (!args[2] || args[2].startsWith("--")) throw new Error("project add requires a path");
126
+ validateCommandArgs(args, 3, {
127
+ values: ["--workspace", "--name", "--description", "--github-repo"],
128
+ switches: ["--json", "--no-github"],
129
+ });
130
+ if (args.includes("--no-github") && args.includes("--github-repo")) {
131
+ throw new Error("--no-github and --github-repo cannot be used together");
132
+ }
133
+ const input = { path: args[2] };
134
+ const name = option(args, "--name", null);
135
+ const description = option(args, "--description", null);
136
+ if (name !== null) input.name = name;
137
+ if (description !== null) input.description = description;
138
+ if (args.includes("--no-github")) input.githubRepo = null;
139
+ else if (args.includes("--github-repo")) input.githubRepo = option(args, "--github-repo");
140
+ const project = await addProject(workspaceRoot(args), input);
141
+ print(project, args, (value) => `Added ${value.name}: ${value.path}`);
142
+ return 0;
143
+ }
144
+
145
+ async function projectImport(args) {
146
+ const hasSource = Boolean(args[2] && !args[2].startsWith("--"));
147
+ const source = hasSource ? args[2] : "-";
148
+ validateCommandArgs(args, hasSource ? 3 : 2, {
149
+ values: ["--workspace"],
150
+ switches: ["--json", "--replace"],
151
+ });
152
+ const content = source === "-"
153
+ ? await readStdin()
154
+ : await readFile(path.resolve(source), "utf8");
155
+ if (content.trim().length === 0) throw new Error("project import input is empty");
156
+ const result = await importProjects(workspaceRoot(args), JSON.parse(content), {
157
+ replace: args.includes("--replace"),
158
+ });
159
+ print(result, args, (value) =>
160
+ `Imported ${value.importedCount} project(s); ${value.projectCount} configured (${value.mode}).`);
161
+ return 0;
162
+ }
163
+
164
+ async function projectList(args) {
165
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
166
+ const projects = await listProjects(workspaceRoot(args));
167
+ print({ projectCount: projects.length, projects }, args, (value) => table(
168
+ ["NAME", "KIND", "PATH"],
169
+ value.projects.map((project) => [
170
+ project.name,
171
+ project.isGitRepository ? "git" : "folder",
172
+ project.path,
173
+ ]),
174
+ ));
175
+ return 0;
176
+ }
177
+
178
+ async function projectRemove(args) {
179
+ if (!args[2] || args[2].startsWith("--")) throw new Error("project remove requires a name");
180
+ validateCommandArgs(args, 3, {
181
+ values: ["--workspace"],
182
+ switches: ["--json", "--force"],
183
+ });
184
+ const result = await removeProject(workspaceRoot(args), args[2], {
185
+ force: args.includes("--force"),
186
+ });
187
+ print(result, args, (value) => `Removed ${value.project.name}: ${value.project.path}`);
188
+ return 0;
189
+ }
190
+
191
+ async function create(args) {
192
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
193
+ const task = await createTask(workspaceRoot(args), await readJsonStdin());
194
+ print(task, args, (value) => `Created ${value.id}: ${value.title} (${value.status})`);
195
+ return 0;
196
+ }
197
+
198
+ async function update(args) {
199
+ validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
200
+ const task = await updateTask(workspaceRoot(args), args[2], await readJsonStdin());
201
+ print(task, args, (value) => `Updated ${value.id}: ${value.status}`);
202
+ return 0;
203
+ }
204
+
205
+ async function show(args) {
206
+ validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
207
+ print(await readTask(workspaceRoot(args), args[2]), args);
208
+ return 0;
209
+ }
210
+
211
+ async function taskList(args) {
212
+ validateCommandArgs(args, 2, {
213
+ values: ["--workspace", "--status", "--project"],
214
+ switches: ["--json"],
215
+ repeatable: ["--status"],
216
+ });
217
+ const tasks = await filterTasks(workspaceRoot(args), {
218
+ statuses: options(args, "--status"),
219
+ project: option(args, "--project", null),
220
+ });
221
+ const projects = await listProjects(workspaceRoot(args));
222
+ const projectNames = new Map(projects.map((project) => [project.path, project.name]));
223
+ const result = { taskCount: tasks.length, tasks };
224
+ print(result, args, (value) => table(
225
+ ["ID", "STATUS", "PROJECT", "TITLE"],
226
+ value.tasks.map((task) => [
227
+ task.id,
228
+ task.status,
229
+ projectNames.get(task.project) ?? path.basename(task.project),
230
+ task.title,
231
+ ]),
232
+ ));
233
+ return 0;
234
+ }
235
+
236
+ async function taskSummary(args) {
237
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
238
+ const summary = await buildTaskSummary(workspaceRoot(args));
239
+ print(summary, args, (value) => [
240
+ `Tasks: ${value.taskCount}`,
241
+ ...Object.entries(value.statusCounts).map(([status, count]) => `${status}: ${count}`),
242
+ ].join("\n"));
243
+ return 0;
244
+ }
245
+
246
+ async function reconciliationCandidates(args) {
247
+ validateCommandArgs(args, 2, {
248
+ values: ["--workspace"],
249
+ switches: ["--json", "--include-finished"],
250
+ });
251
+ const result = await buildReconciliationCandidates(workspaceRoot(args), {
252
+ includeFinished: args.includes("--include-finished"),
253
+ });
254
+ print(result, args);
255
+ return 0;
256
+ }
257
+
258
+ function usage() {
259
+ process.stdout.write(`TaskChef workspace utility
260
+
261
+ Usage:
262
+ taskchef help
263
+ taskchef doctor [--json] [--workspace <path>]
264
+ taskchef workspace init [--json] [--workspace <path>]
265
+ taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> | --no-github] [--json] [--workspace <path>]
266
+ taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
267
+ taskchef project list [--json] [--workspace <path>]
268
+ taskchef project remove <name> [--force] [--json] [--workspace <path>]
269
+ taskchef task create [--json] [--workspace <path>]
270
+ taskchef task update <task-id> [--json] [--workspace <path>]
271
+ taskchef task show <task-id> [--json] [--workspace <path>]
272
+ taskchef task list [--status <status>]... [--project <name-or-path>] [--json] [--workspace <path>]
273
+ taskchef task summary [--json] [--workspace <path>]
274
+ taskchef task reconcile-candidates [--include-finished] [--json] [--workspace <path>]
275
+
276
+ Task create and update read JSON from standard input. Project import reads a
277
+ JSON array from a file, or from standard input when the source is '-' or omitted.
278
+ `);
279
+ }
280
+
281
+ export async function runCli(args) {
282
+ if (args.length === 0 || args[0] === "help" || args[0] === "--help") {
283
+ usage();
284
+ return 0;
285
+ }
286
+ if (args[0] === "doctor") return doctor(args);
287
+ if (args[0] === "workspace" && args[1] === "init") return initialize(args);
288
+ if (args[0] === "project" && args[1] === "add") return projectAdd(args);
289
+ if (args[0] === "project" && args[1] === "import") return projectImport(args);
290
+ if (args[0] === "project" && args[1] === "list") return projectList(args);
291
+ if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
292
+ if (args[0] === "task" && args[1] === "create") return create(args);
293
+ if (args[0] === "task" && args[1] === "update" && args[2]) return update(args);
294
+ if (args[0] === "task" && args[1] === "show" && args[2]) return show(args);
295
+ if (args[0] === "task" && args[1] === "list") return taskList(args);
296
+ if (args[0] === "task" && args[1] === "summary") return taskSummary(args);
297
+ if (args[0] === "task" && args[1] === "reconcile-candidates") {
298
+ return reconciliationCandidates(args);
299
+ }
300
+ process.stderr.write(`Unknown command: ${args.join(" ")}\n`);
301
+ usage();
302
+ return 2;
303
+ }