@shzlwio/windrunner-cli 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/dist/client.js +104 -0
- package/dist/index.js +372 -0
- package/dist/types.js +1 -0
- package/package.json +29 -0
- package/src/client.ts +119 -0
- package/src/index.ts +514 -0
- package/src/types.ts +74 -0
- package/tsconfig.json +16 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "CliError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class WindrunnerClient {
|
|
8
|
+
options;
|
|
9
|
+
apiBaseUrl;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
const baseUrl = options.url.replace(/\/+$/, "");
|
|
13
|
+
this.apiBaseUrl = baseUrl.endsWith("/api/v1") ? baseUrl : `${baseUrl}/api/v1`;
|
|
14
|
+
}
|
|
15
|
+
async get(path) {
|
|
16
|
+
return this.request("GET", path);
|
|
17
|
+
}
|
|
18
|
+
async post(path, body) {
|
|
19
|
+
return this.mutate("POST", path, body);
|
|
20
|
+
}
|
|
21
|
+
async put(path, body) {
|
|
22
|
+
return this.mutate("PUT", path, body);
|
|
23
|
+
}
|
|
24
|
+
async delete(path) {
|
|
25
|
+
return this.mutate("DELETE", path);
|
|
26
|
+
}
|
|
27
|
+
async mutate(method, path, body) {
|
|
28
|
+
if (this.options.dryRun) {
|
|
29
|
+
return {
|
|
30
|
+
dryRun: true,
|
|
31
|
+
method,
|
|
32
|
+
path: `${this.apiBaseUrl}${path}`,
|
|
33
|
+
...(body === undefined ? {} : { body }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return this.request(method, path, body);
|
|
37
|
+
}
|
|
38
|
+
async request(method, path, body) {
|
|
39
|
+
const apiKey = process.env.WINDRUNNER_API_KEY;
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
throw new CliError("WINDRUNNER_API_KEY is required for API requests.");
|
|
42
|
+
}
|
|
43
|
+
const headers = {
|
|
44
|
+
Accept: "application/json",
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
};
|
|
47
|
+
if (body !== undefined) {
|
|
48
|
+
headers["Content-Type"] = "application/json";
|
|
49
|
+
}
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers,
|
|
55
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new CliError(`Could not connect to Windrunner: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
let payload = null;
|
|
64
|
+
if (text.trim()) {
|
|
65
|
+
try {
|
|
66
|
+
payload = JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new CliError(`Windrunner returned invalid JSON (${response.status}).`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new CliError(formatApiFailure(response.status, payload));
|
|
74
|
+
}
|
|
75
|
+
if (isApiResponse(payload) && payload.errors && payload.errors.length > 0) {
|
|
76
|
+
throw new CliError(formatApiErrors(payload.errors));
|
|
77
|
+
}
|
|
78
|
+
if (!isApiResponse(payload)) {
|
|
79
|
+
return { data: payload };
|
|
80
|
+
}
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function isApiResponse(value) {
|
|
85
|
+
return Boolean(value && typeof value === "object" && ("data" in value || "errors" in value || "meta" in value));
|
|
86
|
+
}
|
|
87
|
+
function formatApiFailure(status, payload) {
|
|
88
|
+
if (isApiResponse(payload) && payload.errors?.length) {
|
|
89
|
+
return `Request failed (${status}): ${formatApiErrors(payload.errors)}`;
|
|
90
|
+
}
|
|
91
|
+
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
|
|
92
|
+
return `Request failed (${status}): ${payload.message}`;
|
|
93
|
+
}
|
|
94
|
+
return `Request failed (${status}).`;
|
|
95
|
+
}
|
|
96
|
+
function formatApiErrors(errors) {
|
|
97
|
+
return errors
|
|
98
|
+
.map((error) => {
|
|
99
|
+
const prefix = error.code ? `${error.code}: ` : "";
|
|
100
|
+
const field = error.field ? ` (${error.field})` : "";
|
|
101
|
+
return `${prefix}${error.message ?? "Unknown API error"}${field}`;
|
|
102
|
+
})
|
|
103
|
+
.join("; ");
|
|
104
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stderr as output } from "node:process";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { CliError, WindrunnerClient } from "./client.js";
|
|
6
|
+
function getGlobalOptions(command) {
|
|
7
|
+
const options = command.optsWithGlobals();
|
|
8
|
+
return {
|
|
9
|
+
url: options.url || process.env.WINDRUNNER_URL || "http://localhost:8080",
|
|
10
|
+
json: Boolean(options.json),
|
|
11
|
+
dryRun: Boolean(options.dryRun),
|
|
12
|
+
yes: Boolean(options.yes),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function printResult(value, options) {
|
|
16
|
+
if (options.json) {
|
|
17
|
+
console.log(JSON.stringify(value));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
console.log(JSON.stringify(value, null, 2));
|
|
21
|
+
}
|
|
22
|
+
function printResponse(response, options) {
|
|
23
|
+
if (isDryRunResult(response)) {
|
|
24
|
+
printResult(response, options);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
printResult(response.data, options);
|
|
28
|
+
}
|
|
29
|
+
function isDryRunResult(value) {
|
|
30
|
+
return "dryRun" in value && value.dryRun === true;
|
|
31
|
+
}
|
|
32
|
+
function encode(value) {
|
|
33
|
+
return encodeURIComponent(value);
|
|
34
|
+
}
|
|
35
|
+
function numberValue(value, name) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
const parsed = Number(value);
|
|
39
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
40
|
+
throw new CliError(`--${name} must be a non-negative integer.`);
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
function queryString(parameters) {
|
|
45
|
+
const query = new URLSearchParams();
|
|
46
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
47
|
+
if (value !== undefined && value !== "") {
|
|
48
|
+
query.set(key, String(value));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const encoded = query.toString();
|
|
52
|
+
return encoded ? `?${encoded}` : "";
|
|
53
|
+
}
|
|
54
|
+
function collectOption(value, previous = []) {
|
|
55
|
+
return [...previous, value];
|
|
56
|
+
}
|
|
57
|
+
function parseAssignees(values) {
|
|
58
|
+
if (values === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
return values.map((value) => {
|
|
61
|
+
const separator = value.indexOf(":");
|
|
62
|
+
if (separator <= 0 || separator === value.length - 1) {
|
|
63
|
+
throw new CliError(`Invalid assignee '${value}'. Use USER:<id> or TEAM:<id>.`);
|
|
64
|
+
}
|
|
65
|
+
const assigneeType = value.slice(0, separator).toUpperCase();
|
|
66
|
+
const assigneeId = value.slice(separator + 1).trim();
|
|
67
|
+
if (assigneeType !== "USER" && assigneeType !== "TEAM") {
|
|
68
|
+
throw new CliError(`Invalid assignee type '${assigneeType}'. Use USER or TEAM.`);
|
|
69
|
+
}
|
|
70
|
+
if (!assigneeId) {
|
|
71
|
+
throw new CliError("Assignee id is required.");
|
|
72
|
+
}
|
|
73
|
+
return { assigneeType, assigneeId };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function requireText(value, optionName) {
|
|
77
|
+
if (!value || !value.trim()) {
|
|
78
|
+
throw new CliError(`${optionName} is required.`);
|
|
79
|
+
}
|
|
80
|
+
return value.trim();
|
|
81
|
+
}
|
|
82
|
+
function validateDate(value) {
|
|
83
|
+
if (value === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
86
|
+
throw new CliError("--due-date must use YYYY-MM-DD format.");
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function workItemFields(options, current) {
|
|
91
|
+
const title = options.title === undefined ? current?.title : String(options.title);
|
|
92
|
+
const result = {
|
|
93
|
+
title: requireText(title, "--title"),
|
|
94
|
+
};
|
|
95
|
+
const values = [
|
|
96
|
+
["type", options.type === undefined ? current?.type : options.type],
|
|
97
|
+
["status", options.status === undefined ? current?.status : options.status],
|
|
98
|
+
["dueDate", options.dueDate === undefined ? current?.dueDate : validateDate(String(options.dueDate))],
|
|
99
|
+
["priority", options.priority === undefined ? current?.priority : options.priority],
|
|
100
|
+
[
|
|
101
|
+
"parentWorkItemId",
|
|
102
|
+
options.parentId === undefined ? current?.parentWorkItemId : String(options.parentId),
|
|
103
|
+
],
|
|
104
|
+
];
|
|
105
|
+
for (const [key, value] of values) {
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
result[key] = value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
async function confirmDelete(message, options) {
|
|
113
|
+
if (options.yes)
|
|
114
|
+
return;
|
|
115
|
+
if (!input.isTTY) {
|
|
116
|
+
throw new CliError(`${message} Re-run with --yes when using a non-interactive terminal.`);
|
|
117
|
+
}
|
|
118
|
+
const readline = createInterface({ input, output });
|
|
119
|
+
try {
|
|
120
|
+
const answer = await readline.question(`${message} [y/N] `);
|
|
121
|
+
if (!/^(y|yes)$/i.test(answer.trim())) {
|
|
122
|
+
throw new CliError("Cancelled.");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
readline.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function addPagination(command) {
|
|
130
|
+
return command
|
|
131
|
+
.option("--page <number>", "Page number (zero-based)", "0")
|
|
132
|
+
.option("--size <number>", "Items per page; the server caps this at 100", "50")
|
|
133
|
+
.option("--updated-after <timestamp>", "Only return records updated after an ISO-8601 timestamp (UTC recommended)");
|
|
134
|
+
}
|
|
135
|
+
const sharedHelp = `
|
|
136
|
+
Environment:
|
|
137
|
+
WINDRUNNER_URL Server URL (default: http://localhost:8080)
|
|
138
|
+
WINDRUNNER_API_KEY Bearer API key; keep it in the environment, not in arguments
|
|
139
|
+
|
|
140
|
+
Global options:
|
|
141
|
+
--url <url> Override WINDRUNNER_URL
|
|
142
|
+
--json Print compact machine-readable JSON
|
|
143
|
+
--dry-run Preview a mutation without sending the mutation request
|
|
144
|
+
-y, --yes Skip destructive-operation confirmation prompts
|
|
145
|
+
|
|
146
|
+
Output and safety:
|
|
147
|
+
Normal output is pretty JSON. Use --json when another agent or script will parse it.
|
|
148
|
+
Destructive commands require confirmation unless --yes is explicitly provided.
|
|
149
|
+
A dry run never sends a mutation request and does not require an API key.
|
|
150
|
+
`;
|
|
151
|
+
function addAgentHelp(command, details) {
|
|
152
|
+
return command.addHelpText("after", `${sharedHelp}\n${details.trim()}\n`);
|
|
153
|
+
}
|
|
154
|
+
const program = new Command();
|
|
155
|
+
program
|
|
156
|
+
.name("windrunner")
|
|
157
|
+
.description("Command-line interface for Windrunner")
|
|
158
|
+
.version("0.1.0")
|
|
159
|
+
.option("--url <url>", "Windrunner server URL", process.env.WINDRUNNER_URL || "http://localhost:8080")
|
|
160
|
+
.option("--json", "Print compact JSON output")
|
|
161
|
+
.option("--dry-run", "Preview mutations without sending them")
|
|
162
|
+
.option("-y, --yes", "Skip confirmation prompts");
|
|
163
|
+
addAgentHelp(program, `Quick start:
|
|
164
|
+
export WINDRUNNER_URL=http://localhost:8080
|
|
165
|
+
export WINDRUNNER_API_KEY=your-api-key
|
|
166
|
+
windrunner projects list --json
|
|
167
|
+
|
|
168
|
+
Use '<command> --help' for command-specific arguments and examples.`);
|
|
169
|
+
const projects = program.command("projects").description("Manage projects");
|
|
170
|
+
addAgentHelp(projects, "Permissions: projects:read for both project commands.");
|
|
171
|
+
addAgentHelp(projects
|
|
172
|
+
.command("list")
|
|
173
|
+
.description("List projects visible to the API key")
|
|
174
|
+
.option("--page <number>", "Page number (zero-based)", "0")
|
|
175
|
+
.option("--size <number>", "Items per page; the server caps this at 100", "50"), `Permissions: projects:read
|
|
176
|
+
|
|
177
|
+
Examples:
|
|
178
|
+
windrunner projects list
|
|
179
|
+
windrunner projects list --page 1 --size 25 --json`)
|
|
180
|
+
.action(async (options, command) => {
|
|
181
|
+
const globalOptions = getGlobalOptions(command);
|
|
182
|
+
const client = new WindrunnerClient(globalOptions);
|
|
183
|
+
const response = await client.get(`/projects${queryString({ page: numberValue(options.page, "page"), size: numberValue(options.size, "size") })}`);
|
|
184
|
+
printResponse(response, globalOptions);
|
|
185
|
+
});
|
|
186
|
+
addAgentHelp(projects
|
|
187
|
+
.command("get")
|
|
188
|
+
.description("Get a project")
|
|
189
|
+
.argument("<projectId>", "Project id"), `Permissions: projects:read
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
windrunner projects get PROJECT_ID --json`)
|
|
193
|
+
.action(async (projectId, _options, command) => {
|
|
194
|
+
const globalOptions = getGlobalOptions(command);
|
|
195
|
+
const client = new WindrunnerClient(globalOptions);
|
|
196
|
+
printResponse(await client.get(`/projects/${encode(projectId)}`), globalOptions);
|
|
197
|
+
});
|
|
198
|
+
const workItems = program.command("work-items").description("Manage work items");
|
|
199
|
+
addAgentHelp(workItems, "Use --json for machine-readable results. Work item type and status values are validated by the server.");
|
|
200
|
+
addAgentHelp(addPagination(workItems
|
|
201
|
+
.command("list")
|
|
202
|
+
.description("List work items in a project")
|
|
203
|
+
.argument("<projectId>", "Project id")
|
|
204
|
+
.option("--status <status>", "Filter by status")
|
|
205
|
+
.option("--type <type>", "Filter by type")
|
|
206
|
+
.option("--priority <priority>", "Filter by priority")), `Permissions: work_items:read
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
windrunner work-items list PROJECT_ID
|
|
210
|
+
windrunner work-items list PROJECT_ID --status OPEN --size 25 --json`).action(async (projectId, options, command) => {
|
|
211
|
+
const globalOptions = getGlobalOptions(command);
|
|
212
|
+
const client = new WindrunnerClient(globalOptions);
|
|
213
|
+
const response = await client.get(`/projects/${encode(projectId)}/work-items${queryString({
|
|
214
|
+
page: numberValue(options.page, "page"),
|
|
215
|
+
size: numberValue(options.size, "size"),
|
|
216
|
+
status: options.status,
|
|
217
|
+
type: options.type,
|
|
218
|
+
priority: options.priority,
|
|
219
|
+
updated_after: options.updatedAfter,
|
|
220
|
+
})}`);
|
|
221
|
+
printResponse(response, globalOptions);
|
|
222
|
+
});
|
|
223
|
+
addAgentHelp(workItems
|
|
224
|
+
.command("get")
|
|
225
|
+
.description("Get a work item")
|
|
226
|
+
.argument("<workItemId>", "Work item id"), `Permissions: work_items:read
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
windrunner work-items get WORK_ITEM_ID --json`)
|
|
230
|
+
.action(async (workItemId, _options, command) => {
|
|
231
|
+
const globalOptions = getGlobalOptions(command);
|
|
232
|
+
const client = new WindrunnerClient(globalOptions);
|
|
233
|
+
printResponse(await client.get(`/work-items/${encode(workItemId)}`), globalOptions);
|
|
234
|
+
});
|
|
235
|
+
function addWorkItemWriteOptions(command) {
|
|
236
|
+
return command
|
|
237
|
+
.requiredOption("--title <title>", "Work item title")
|
|
238
|
+
.option("--type <type>", "Work item type")
|
|
239
|
+
.option("--status <status>", "Work item status")
|
|
240
|
+
.option("--due-date <date>", "Due date in YYYY-MM-DD format")
|
|
241
|
+
.option("--priority <priority>", "Work item priority")
|
|
242
|
+
.option("--parent-id <workItemId>", "Parent work item id")
|
|
243
|
+
.option("--assignee <type:id>", "Assignee in USER:<id> or TEAM:<id> format", collectOption);
|
|
244
|
+
}
|
|
245
|
+
addAgentHelp(addWorkItemWriteOptions(workItems
|
|
246
|
+
.command("create")
|
|
247
|
+
.description("Create a work item")
|
|
248
|
+
.argument("<projectId>", "Project id")), `Permissions: work_items:write
|
|
249
|
+
Required: --title.
|
|
250
|
+
Repeat --assignee for multiple assignments using USER:<id> or TEAM:<id>.
|
|
251
|
+
|
|
252
|
+
Examples:
|
|
253
|
+
windrunner work-items create PROJECT_ID --title "Fix login"
|
|
254
|
+
windrunner work-items create PROJECT_ID --title "Release" --status OPEN --assignee USER:user-1 --dry-run`).action(async (projectId, options, command) => {
|
|
255
|
+
const globalOptions = getGlobalOptions(command);
|
|
256
|
+
const client = new WindrunnerClient(globalOptions);
|
|
257
|
+
const body = {
|
|
258
|
+
workItem: workItemFields(options),
|
|
259
|
+
};
|
|
260
|
+
const assignees = parseAssignees(options.assignee);
|
|
261
|
+
if (assignees !== undefined)
|
|
262
|
+
body.assignees = assignees;
|
|
263
|
+
printResponse(await client.post(`/projects/${encode(projectId)}/work-items`, body), globalOptions);
|
|
264
|
+
});
|
|
265
|
+
addAgentHelp(addWorkItemWriteOptions(workItems
|
|
266
|
+
.command("update")
|
|
267
|
+
.description("Update a work item")
|
|
268
|
+
.argument("<workItemId>", "Work item id")), `Permissions: work_items:read and work_items:write.
|
|
269
|
+
Required: --title. The CLI reads the current item first so omitted fields are preserved.
|
|
270
|
+
|
|
271
|
+
Examples:
|
|
272
|
+
windrunner work-items update WORK_ITEM_ID --title "Updated title"
|
|
273
|
+
windrunner work-items update WORK_ITEM_ID --title "Done" --status DONE --json`).action(async (workItemId, options, command) => {
|
|
274
|
+
const globalOptions = getGlobalOptions(command);
|
|
275
|
+
const client = new WindrunnerClient(globalOptions);
|
|
276
|
+
let current;
|
|
277
|
+
// The API accepts a full WorkItem representation for PUT. Fetching first
|
|
278
|
+
// lets the CLI offer a safe field-oriented update without clearing fields
|
|
279
|
+
// the caller did not mention.
|
|
280
|
+
if (!globalOptions.dryRun) {
|
|
281
|
+
const existing = await client.get(`/work-items/${encode(workItemId)}`);
|
|
282
|
+
current = existing.data?.workItem;
|
|
283
|
+
if (!current)
|
|
284
|
+
throw new CliError("Work item response did not include a work item.");
|
|
285
|
+
}
|
|
286
|
+
const body = {
|
|
287
|
+
workItem: workItemFields(options, current),
|
|
288
|
+
};
|
|
289
|
+
const assignees = parseAssignees(options.assignee);
|
|
290
|
+
if (assignees !== undefined)
|
|
291
|
+
body.assignees = assignees;
|
|
292
|
+
printResponse(await client.put(`/work-items/${encode(workItemId)}`, body), globalOptions);
|
|
293
|
+
});
|
|
294
|
+
addAgentHelp(workItems
|
|
295
|
+
.command("delete")
|
|
296
|
+
.description("Delete a work item and its descendants")
|
|
297
|
+
.argument("<workItemId>", "Work item id"), `Permissions: work_items:write
|
|
298
|
+
This permanently deletes the work item, descendants, entries, and relationships.
|
|
299
|
+
Use --dry-run to preview the request. Use --yes only when deletion is explicitly intended.
|
|
300
|
+
|
|
301
|
+
Examples:
|
|
302
|
+
windrunner work-items delete WORK_ITEM_ID --dry-run
|
|
303
|
+
windrunner work-items delete WORK_ITEM_ID --yes`)
|
|
304
|
+
.action(async (workItemId, _options, command) => {
|
|
305
|
+
const globalOptions = getGlobalOptions(command);
|
|
306
|
+
const client = new WindrunnerClient(globalOptions);
|
|
307
|
+
if (!globalOptions.dryRun) {
|
|
308
|
+
await confirmDelete(`Delete work item ${workItemId}? This cannot be undone.`, globalOptions);
|
|
309
|
+
}
|
|
310
|
+
printResponse(await client.delete(`/work-items/${encode(workItemId)}`), globalOptions);
|
|
311
|
+
});
|
|
312
|
+
const entries = program.command("entries").description("Manage entries");
|
|
313
|
+
addAgentHelp(entries, "Entries are attached to work items. Use --json for machine-readable results.");
|
|
314
|
+
addAgentHelp(addPagination(entries
|
|
315
|
+
.command("list")
|
|
316
|
+
.description("List entries attached to a work item")
|
|
317
|
+
.argument("<workItemId>", "Work item id")), `Permissions: entries:read
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
windrunner entries list WORK_ITEM_ID --updated-after 2026-01-01T00:00:00Z --json`).action(async (workItemId, options, command) => {
|
|
321
|
+
const globalOptions = getGlobalOptions(command);
|
|
322
|
+
const client = new WindrunnerClient(globalOptions);
|
|
323
|
+
const response = await client.get(`/work-items/${encode(workItemId)}/entries${queryString({
|
|
324
|
+
page: numberValue(options.page, "page"),
|
|
325
|
+
size: numberValue(options.size, "size"),
|
|
326
|
+
updated_after: options.updatedAfter,
|
|
327
|
+
})}`);
|
|
328
|
+
printResponse(response, globalOptions);
|
|
329
|
+
});
|
|
330
|
+
addAgentHelp(entries
|
|
331
|
+
.command("create")
|
|
332
|
+
.description("Create an entry on a work item")
|
|
333
|
+
.argument("<workItemId>", "Work item id")
|
|
334
|
+
.requiredOption("--body <body>", "Entry body")
|
|
335
|
+
.option("--type <type>", "Entry type"), `Permissions: entries:write
|
|
336
|
+
Required: --body.
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
windrunner entries create WORK_ITEM_ID --body "Deployment completed"
|
|
340
|
+
windrunner entries create WORK_ITEM_ID --body "Comment" --type COMMENT --dry-run`)
|
|
341
|
+
.action(async (workItemId, options, command) => {
|
|
342
|
+
const globalOptions = getGlobalOptions(command);
|
|
343
|
+
const client = new WindrunnerClient(globalOptions);
|
|
344
|
+
const body = {
|
|
345
|
+
body: requireText(options.body, "--body"),
|
|
346
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
347
|
+
};
|
|
348
|
+
printResponse(await client.post(`/work-items/${encode(workItemId)}/entries`, body), globalOptions);
|
|
349
|
+
});
|
|
350
|
+
addAgentHelp(program
|
|
351
|
+
.command("search")
|
|
352
|
+
.description("Search project work items, entries, and relationships")
|
|
353
|
+
.argument("<projectId>", "Project id")
|
|
354
|
+
.argument("<query>", "Search query")
|
|
355
|
+
.option("--limit <number>", "Maximum number of matches"), `Permissions: work_items:read
|
|
356
|
+
|
|
357
|
+
Example:
|
|
358
|
+
windrunner search PROJECT_ID "login failure" --limit 20 --json`)
|
|
359
|
+
.action(async (projectId, query, options, command) => {
|
|
360
|
+
const globalOptions = getGlobalOptions(command);
|
|
361
|
+
const client = new WindrunnerClient(globalOptions);
|
|
362
|
+
const limit = numberValue(options.limit, "limit");
|
|
363
|
+
printResponse(await client.get(`/projects/${encode(projectId)}/search${queryString({ q: query, limit })}`), globalOptions);
|
|
364
|
+
});
|
|
365
|
+
try {
|
|
366
|
+
await program.parseAsync(process.argv);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
370
|
+
console.error(`Error: ${message}`);
|
|
371
|
+
process.exitCode = 1;
|
|
372
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shzlwio/windrunner-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Command-line interface for Windrunner.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"windrunner": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^26.3.0",
|
|
23
|
+
"tsx": "^4.23.12",
|
|
24
|
+
"typescript": "^7.0.2"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^15.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ApiError, ApiResponse, DryRunResult, GlobalOptions, JsonObject } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WindrunnerClient {
|
|
11
|
+
private readonly apiBaseUrl: string;
|
|
12
|
+
|
|
13
|
+
constructor(private readonly options: GlobalOptions) {
|
|
14
|
+
const baseUrl = options.url.replace(/\/+$/, "");
|
|
15
|
+
this.apiBaseUrl = baseUrl.endsWith("/api/v1") ? baseUrl : `${baseUrl}/api/v1`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async get<T>(path: string): Promise<ApiResponse<T>> {
|
|
19
|
+
return this.request<T>("GET", path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async post<T>(path: string, body: unknown): Promise<ApiResponse<T> | DryRunResult> {
|
|
23
|
+
return this.mutate<T>("POST", path, body);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async put<T>(path: string, body: unknown): Promise<ApiResponse<T> | DryRunResult> {
|
|
27
|
+
return this.mutate<T>("PUT", path, body);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async delete(path: string): Promise<ApiResponse<null> | DryRunResult> {
|
|
31
|
+
return this.mutate<null>("DELETE", path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async mutate<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T> | DryRunResult> {
|
|
35
|
+
if (this.options.dryRun) {
|
|
36
|
+
return {
|
|
37
|
+
dryRun: true,
|
|
38
|
+
method,
|
|
39
|
+
path: `${this.apiBaseUrl}${path}`,
|
|
40
|
+
...(body === undefined ? {} : { body }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return this.request<T>(method, path, body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
47
|
+
const apiKey = process.env.WINDRUNNER_API_KEY;
|
|
48
|
+
if (!apiKey) {
|
|
49
|
+
throw new CliError("WINDRUNNER_API_KEY is required for API requests.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const headers: Record<string, string> = {
|
|
53
|
+
Accept: "application/json",
|
|
54
|
+
Authorization: `Bearer ${apiKey}`,
|
|
55
|
+
};
|
|
56
|
+
if (body !== undefined) {
|
|
57
|
+
headers["Content-Type"] = "application/json";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let response: Response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
throw new CliError(`Could not connect to Windrunner: ${message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const text = await response.text();
|
|
73
|
+
let payload: ApiResponse<T> | JsonObject | null = null;
|
|
74
|
+
if (text.trim()) {
|
|
75
|
+
try {
|
|
76
|
+
payload = JSON.parse(text) as ApiResponse<T> | JsonObject;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new CliError(`Windrunner returned invalid JSON (${response.status}).`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new CliError(formatApiFailure(response.status, payload));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isApiResponse(payload) && payload.errors && payload.errors.length > 0) {
|
|
87
|
+
throw new CliError(formatApiErrors(payload.errors));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!isApiResponse(payload)) {
|
|
91
|
+
return { data: payload as T | null };
|
|
92
|
+
}
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isApiResponse(value: unknown): value is ApiResponse<unknown> {
|
|
98
|
+
return Boolean(value && typeof value === "object" && ("data" in value || "errors" in value || "meta" in value));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatApiFailure(status: number, payload: unknown): string {
|
|
102
|
+
if (isApiResponse(payload) && payload.errors?.length) {
|
|
103
|
+
return `Request failed (${status}): ${formatApiErrors(payload.errors)}`;
|
|
104
|
+
}
|
|
105
|
+
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
|
|
106
|
+
return `Request failed (${status}): ${payload.message}`;
|
|
107
|
+
}
|
|
108
|
+
return `Request failed (${status}).`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatApiErrors(errors: ApiError[]): string {
|
|
112
|
+
return errors
|
|
113
|
+
.map((error) => {
|
|
114
|
+
const prefix = error.code ? `${error.code}: ` : "";
|
|
115
|
+
const field = error.field ? ` (${error.field})` : "";
|
|
116
|
+
return `${prefix}${error.message ?? "Unknown API error"}${field}`;
|
|
117
|
+
})
|
|
118
|
+
.join("; ");
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stderr as output } from "node:process";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
import { CliError, WindrunnerClient } from "./client.js";
|
|
8
|
+
import type {
|
|
9
|
+
ApiResponse,
|
|
10
|
+
Assignee,
|
|
11
|
+
DryRunResult,
|
|
12
|
+
GlobalOptions,
|
|
13
|
+
JsonObject,
|
|
14
|
+
Project,
|
|
15
|
+
WorkItem,
|
|
16
|
+
WorkItemResponse,
|
|
17
|
+
Entry,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
function getGlobalOptions(command: Command): GlobalOptions {
|
|
21
|
+
const options = command.optsWithGlobals() as GlobalOptions;
|
|
22
|
+
return {
|
|
23
|
+
url: options.url || process.env.WINDRUNNER_URL || "http://localhost:8080",
|
|
24
|
+
json: Boolean(options.json),
|
|
25
|
+
dryRun: Boolean(options.dryRun),
|
|
26
|
+
yes: Boolean(options.yes),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printResult(value: unknown, options: GlobalOptions): void {
|
|
31
|
+
if (options.json) {
|
|
32
|
+
console.log(JSON.stringify(value));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log(JSON.stringify(value, null, 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printResponse<T>(response: ApiResponse<T> | DryRunResult, options: GlobalOptions): void {
|
|
39
|
+
if (isDryRunResult(response)) {
|
|
40
|
+
printResult(response, options);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
printResult(response.data, options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isDryRunResult(value: ApiResponse<unknown> | DryRunResult): value is DryRunResult {
|
|
47
|
+
return "dryRun" in value && value.dryRun === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function encode(value: string): string {
|
|
51
|
+
return encodeURIComponent(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function numberValue(value: string | undefined, name: string): number | undefined {
|
|
55
|
+
if (value === undefined) return undefined;
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
58
|
+
throw new CliError(`--${name} must be a non-negative integer.`);
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function queryString(parameters: Record<string, string | number | undefined>): string {
|
|
64
|
+
const query = new URLSearchParams();
|
|
65
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
66
|
+
if (value !== undefined && value !== "") {
|
|
67
|
+
query.set(key, String(value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const encoded = query.toString();
|
|
71
|
+
return encoded ? `?${encoded}` : "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function collectOption(value: string, previous: string[] = []): string[] {
|
|
75
|
+
return [...previous, value];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseAssignees(values: string[] | undefined): Assignee[] | undefined {
|
|
79
|
+
if (values === undefined) return undefined;
|
|
80
|
+
return values.map((value) => {
|
|
81
|
+
const separator = value.indexOf(":");
|
|
82
|
+
if (separator <= 0 || separator === value.length - 1) {
|
|
83
|
+
throw new CliError(`Invalid assignee '${value}'. Use USER:<id> or TEAM:<id>.`);
|
|
84
|
+
}
|
|
85
|
+
const assigneeType = value.slice(0, separator).toUpperCase();
|
|
86
|
+
const assigneeId = value.slice(separator + 1).trim();
|
|
87
|
+
if (assigneeType !== "USER" && assigneeType !== "TEAM") {
|
|
88
|
+
throw new CliError(`Invalid assignee type '${assigneeType}'. Use USER or TEAM.`);
|
|
89
|
+
}
|
|
90
|
+
if (!assigneeId) {
|
|
91
|
+
throw new CliError("Assignee id is required.");
|
|
92
|
+
}
|
|
93
|
+
return { assigneeType, assigneeId };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireText(value: string | undefined, optionName: string): string {
|
|
98
|
+
if (!value || !value.trim()) {
|
|
99
|
+
throw new CliError(`${optionName} is required.`);
|
|
100
|
+
}
|
|
101
|
+
return value.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateDate(value: string | undefined): string | undefined {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
107
|
+
throw new CliError("--due-date must use YYYY-MM-DD format.");
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function workItemFields(options: Record<string, unknown>, current?: WorkItem): WorkItem {
|
|
113
|
+
const title = options.title === undefined ? current?.title : String(options.title);
|
|
114
|
+
const result: WorkItem = {
|
|
115
|
+
title: requireText(title, "--title"),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const values: Array<[keyof WorkItem, unknown]> = [
|
|
119
|
+
["type", options.type === undefined ? current?.type : options.type],
|
|
120
|
+
["status", options.status === undefined ? current?.status : options.status],
|
|
121
|
+
["dueDate", options.dueDate === undefined ? current?.dueDate : validateDate(String(options.dueDate))],
|
|
122
|
+
["priority", options.priority === undefined ? current?.priority : options.priority],
|
|
123
|
+
[
|
|
124
|
+
"parentWorkItemId",
|
|
125
|
+
options.parentId === undefined ? current?.parentWorkItemId : String(options.parentId),
|
|
126
|
+
],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
for (const [key, value] of values) {
|
|
130
|
+
if (value !== undefined) {
|
|
131
|
+
result[key] = value as never;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function confirmDelete(message: string, options: GlobalOptions): Promise<void> {
|
|
138
|
+
if (options.yes) return;
|
|
139
|
+
if (!input.isTTY) {
|
|
140
|
+
throw new CliError(`${message} Re-run with --yes when using a non-interactive terminal.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const readline = createInterface({ input, output });
|
|
144
|
+
try {
|
|
145
|
+
const answer = await readline.question(`${message} [y/N] `);
|
|
146
|
+
if (!/^(y|yes)$/i.test(answer.trim())) {
|
|
147
|
+
throw new CliError("Cancelled.");
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
readline.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function addPagination(command: Command): Command {
|
|
155
|
+
return command
|
|
156
|
+
.option("--page <number>", "Page number (zero-based)", "0")
|
|
157
|
+
.option("--size <number>", "Items per page; the server caps this at 100", "50")
|
|
158
|
+
.option("--updated-after <timestamp>", "Only return records updated after an ISO-8601 timestamp (UTC recommended)");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sharedHelp = `
|
|
162
|
+
Environment:
|
|
163
|
+
WINDRUNNER_URL Server URL (default: http://localhost:8080)
|
|
164
|
+
WINDRUNNER_API_KEY Bearer API key; keep it in the environment, not in arguments
|
|
165
|
+
|
|
166
|
+
Global options:
|
|
167
|
+
--url <url> Override WINDRUNNER_URL
|
|
168
|
+
--json Print compact machine-readable JSON
|
|
169
|
+
--dry-run Preview a mutation without sending the mutation request
|
|
170
|
+
-y, --yes Skip destructive-operation confirmation prompts
|
|
171
|
+
|
|
172
|
+
Output and safety:
|
|
173
|
+
Normal output is pretty JSON. Use --json when another agent or script will parse it.
|
|
174
|
+
Destructive commands require confirmation unless --yes is explicitly provided.
|
|
175
|
+
A dry run never sends a mutation request and does not require an API key.
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
function addAgentHelp(command: Command, details: string): Command {
|
|
179
|
+
return command.addHelpText("after", `${sharedHelp}\n${details.trim()}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const program = new Command();
|
|
183
|
+
|
|
184
|
+
program
|
|
185
|
+
.name("windrunner")
|
|
186
|
+
.description("Command-line interface for Windrunner")
|
|
187
|
+
.version("0.1.0")
|
|
188
|
+
.option("--url <url>", "Windrunner server URL", process.env.WINDRUNNER_URL || "http://localhost:8080")
|
|
189
|
+
.option("--json", "Print compact JSON output")
|
|
190
|
+
.option("--dry-run", "Preview mutations without sending them")
|
|
191
|
+
.option("-y, --yes", "Skip confirmation prompts");
|
|
192
|
+
|
|
193
|
+
addAgentHelp(
|
|
194
|
+
program,
|
|
195
|
+
`Quick start:
|
|
196
|
+
export WINDRUNNER_URL=http://localhost:8080
|
|
197
|
+
export WINDRUNNER_API_KEY=your-api-key
|
|
198
|
+
windrunner projects list --json
|
|
199
|
+
|
|
200
|
+
Use '<command> --help' for command-specific arguments and examples.`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const projects = program.command("projects").description("Manage projects");
|
|
204
|
+
addAgentHelp(projects, "Permissions: projects:read for both project commands.");
|
|
205
|
+
|
|
206
|
+
addAgentHelp(
|
|
207
|
+
projects
|
|
208
|
+
.command("list")
|
|
209
|
+
.description("List projects visible to the API key")
|
|
210
|
+
.option("--page <number>", "Page number (zero-based)", "0")
|
|
211
|
+
.option("--size <number>", "Items per page; the server caps this at 100", "50"),
|
|
212
|
+
`Permissions: projects:read
|
|
213
|
+
|
|
214
|
+
Examples:
|
|
215
|
+
windrunner projects list
|
|
216
|
+
windrunner projects list --page 1 --size 25 --json`,
|
|
217
|
+
)
|
|
218
|
+
.action(async (options: { page: string; size: string }, command: Command) => {
|
|
219
|
+
const globalOptions = getGlobalOptions(command);
|
|
220
|
+
const client = new WindrunnerClient(globalOptions);
|
|
221
|
+
const response = await client.get<Project[]>(
|
|
222
|
+
`/projects${queryString({ page: numberValue(options.page, "page"), size: numberValue(options.size, "size") })}`,
|
|
223
|
+
);
|
|
224
|
+
printResponse(response, globalOptions);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
addAgentHelp(
|
|
228
|
+
projects
|
|
229
|
+
.command("get")
|
|
230
|
+
.description("Get a project")
|
|
231
|
+
.argument("<projectId>", "Project id"),
|
|
232
|
+
`Permissions: projects:read
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
windrunner projects get PROJECT_ID --json`,
|
|
236
|
+
)
|
|
237
|
+
.action(async (projectId: string, _options: unknown, command: Command) => {
|
|
238
|
+
const globalOptions = getGlobalOptions(command);
|
|
239
|
+
const client = new WindrunnerClient(globalOptions);
|
|
240
|
+
printResponse(await client.get<Project>(`/projects/${encode(projectId)}`), globalOptions);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const workItems = program.command("work-items").description("Manage work items");
|
|
244
|
+
addAgentHelp(workItems, "Use --json for machine-readable results. Work item type and status values are validated by the server.");
|
|
245
|
+
|
|
246
|
+
addAgentHelp(
|
|
247
|
+
addPagination(
|
|
248
|
+
workItems
|
|
249
|
+
.command("list")
|
|
250
|
+
.description("List work items in a project")
|
|
251
|
+
.argument("<projectId>", "Project id")
|
|
252
|
+
.option("--status <status>", "Filter by status")
|
|
253
|
+
.option("--type <type>", "Filter by type")
|
|
254
|
+
.option("--priority <priority>", "Filter by priority"),
|
|
255
|
+
),
|
|
256
|
+
`Permissions: work_items:read
|
|
257
|
+
|
|
258
|
+
Examples:
|
|
259
|
+
windrunner work-items list PROJECT_ID
|
|
260
|
+
windrunner work-items list PROJECT_ID --status OPEN --size 25 --json`,
|
|
261
|
+
).action(
|
|
262
|
+
async (
|
|
263
|
+
projectId: string,
|
|
264
|
+
options: {
|
|
265
|
+
page: string;
|
|
266
|
+
size: string;
|
|
267
|
+
status?: string;
|
|
268
|
+
type?: string;
|
|
269
|
+
priority?: string;
|
|
270
|
+
updatedAfter?: string;
|
|
271
|
+
},
|
|
272
|
+
command: Command,
|
|
273
|
+
) => {
|
|
274
|
+
const globalOptions = getGlobalOptions(command);
|
|
275
|
+
const client = new WindrunnerClient(globalOptions);
|
|
276
|
+
const response = await client.get<WorkItemResponse[]>(
|
|
277
|
+
`/projects/${encode(projectId)}/work-items${queryString({
|
|
278
|
+
page: numberValue(options.page, "page"),
|
|
279
|
+
size: numberValue(options.size, "size"),
|
|
280
|
+
status: options.status,
|
|
281
|
+
type: options.type,
|
|
282
|
+
priority: options.priority,
|
|
283
|
+
updated_after: options.updatedAfter,
|
|
284
|
+
})}`,
|
|
285
|
+
);
|
|
286
|
+
printResponse(response, globalOptions);
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
addAgentHelp(
|
|
291
|
+
workItems
|
|
292
|
+
.command("get")
|
|
293
|
+
.description("Get a work item")
|
|
294
|
+
.argument("<workItemId>", "Work item id"),
|
|
295
|
+
`Permissions: work_items:read
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
windrunner work-items get WORK_ITEM_ID --json`,
|
|
299
|
+
)
|
|
300
|
+
.action(async (workItemId: string, _options: unknown, command: Command) => {
|
|
301
|
+
const globalOptions = getGlobalOptions(command);
|
|
302
|
+
const client = new WindrunnerClient(globalOptions);
|
|
303
|
+
printResponse(await client.get<WorkItemResponse>(`/work-items/${encode(workItemId)}`), globalOptions);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function addWorkItemWriteOptions(command: Command): Command {
|
|
307
|
+
return command
|
|
308
|
+
.requiredOption("--title <title>", "Work item title")
|
|
309
|
+
.option("--type <type>", "Work item type")
|
|
310
|
+
.option("--status <status>", "Work item status")
|
|
311
|
+
.option("--due-date <date>", "Due date in YYYY-MM-DD format")
|
|
312
|
+
.option("--priority <priority>", "Work item priority")
|
|
313
|
+
.option("--parent-id <workItemId>", "Parent work item id")
|
|
314
|
+
.option("--assignee <type:id>", "Assignee in USER:<id> or TEAM:<id> format", collectOption);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
addAgentHelp(
|
|
318
|
+
addWorkItemWriteOptions(
|
|
319
|
+
workItems
|
|
320
|
+
.command("create")
|
|
321
|
+
.description("Create a work item")
|
|
322
|
+
.argument("<projectId>", "Project id"),
|
|
323
|
+
),
|
|
324
|
+
`Permissions: work_items:write
|
|
325
|
+
Required: --title.
|
|
326
|
+
Repeat --assignee for multiple assignments using USER:<id> or TEAM:<id>.
|
|
327
|
+
|
|
328
|
+
Examples:
|
|
329
|
+
windrunner work-items create PROJECT_ID --title "Fix login"
|
|
330
|
+
windrunner work-items create PROJECT_ID --title "Release" --status OPEN --assignee USER:user-1 --dry-run`,
|
|
331
|
+
).action(
|
|
332
|
+
async (
|
|
333
|
+
projectId: string,
|
|
334
|
+
options: {
|
|
335
|
+
title: string;
|
|
336
|
+
type?: string;
|
|
337
|
+
status?: string;
|
|
338
|
+
dueDate?: string;
|
|
339
|
+
priority?: string;
|
|
340
|
+
parentId?: string;
|
|
341
|
+
assignee?: string[];
|
|
342
|
+
},
|
|
343
|
+
command: Command,
|
|
344
|
+
) => {
|
|
345
|
+
const globalOptions = getGlobalOptions(command);
|
|
346
|
+
const client = new WindrunnerClient(globalOptions);
|
|
347
|
+
const body: JsonObject = {
|
|
348
|
+
workItem: workItemFields(options),
|
|
349
|
+
};
|
|
350
|
+
const assignees = parseAssignees(options.assignee);
|
|
351
|
+
if (assignees !== undefined) body.assignees = assignees;
|
|
352
|
+
printResponse(await client.post<WorkItemResponse>(`/projects/${encode(projectId)}/work-items`, body), globalOptions);
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
addAgentHelp(
|
|
357
|
+
addWorkItemWriteOptions(
|
|
358
|
+
workItems
|
|
359
|
+
.command("update")
|
|
360
|
+
.description("Update a work item")
|
|
361
|
+
.argument("<workItemId>", "Work item id"),
|
|
362
|
+
),
|
|
363
|
+
`Permissions: work_items:read and work_items:write.
|
|
364
|
+
Required: --title. The CLI reads the current item first so omitted fields are preserved.
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
windrunner work-items update WORK_ITEM_ID --title "Updated title"
|
|
368
|
+
windrunner work-items update WORK_ITEM_ID --title "Done" --status DONE --json`,
|
|
369
|
+
).action(
|
|
370
|
+
async (
|
|
371
|
+
workItemId: string,
|
|
372
|
+
options: {
|
|
373
|
+
title: string;
|
|
374
|
+
type?: string;
|
|
375
|
+
status?: string;
|
|
376
|
+
dueDate?: string;
|
|
377
|
+
priority?: string;
|
|
378
|
+
parentId?: string;
|
|
379
|
+
assignee?: string[];
|
|
380
|
+
},
|
|
381
|
+
command: Command,
|
|
382
|
+
) => {
|
|
383
|
+
const globalOptions = getGlobalOptions(command);
|
|
384
|
+
const client = new WindrunnerClient(globalOptions);
|
|
385
|
+
let current: WorkItem | undefined;
|
|
386
|
+
|
|
387
|
+
// The API accepts a full WorkItem representation for PUT. Fetching first
|
|
388
|
+
// lets the CLI offer a safe field-oriented update without clearing fields
|
|
389
|
+
// the caller did not mention.
|
|
390
|
+
if (!globalOptions.dryRun) {
|
|
391
|
+
const existing = await client.get<WorkItemResponse>(`/work-items/${encode(workItemId)}`);
|
|
392
|
+
current = existing.data?.workItem;
|
|
393
|
+
if (!current) throw new CliError("Work item response did not include a work item.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const body: JsonObject = {
|
|
397
|
+
workItem: workItemFields(options, current),
|
|
398
|
+
};
|
|
399
|
+
const assignees = parseAssignees(options.assignee);
|
|
400
|
+
if (assignees !== undefined) body.assignees = assignees;
|
|
401
|
+
printResponse(await client.put<WorkItemResponse>(`/work-items/${encode(workItemId)}`, body), globalOptions);
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
addAgentHelp(
|
|
406
|
+
workItems
|
|
407
|
+
.command("delete")
|
|
408
|
+
.description("Delete a work item and its descendants")
|
|
409
|
+
.argument("<workItemId>", "Work item id"),
|
|
410
|
+
`Permissions: work_items:write
|
|
411
|
+
This permanently deletes the work item, descendants, entries, and relationships.
|
|
412
|
+
Use --dry-run to preview the request. Use --yes only when deletion is explicitly intended.
|
|
413
|
+
|
|
414
|
+
Examples:
|
|
415
|
+
windrunner work-items delete WORK_ITEM_ID --dry-run
|
|
416
|
+
windrunner work-items delete WORK_ITEM_ID --yes`,
|
|
417
|
+
)
|
|
418
|
+
.action(async (workItemId: string, _options: unknown, command: Command) => {
|
|
419
|
+
const globalOptions = getGlobalOptions(command);
|
|
420
|
+
const client = new WindrunnerClient(globalOptions);
|
|
421
|
+
if (!globalOptions.dryRun) {
|
|
422
|
+
await confirmDelete(`Delete work item ${workItemId}? This cannot be undone.`, globalOptions);
|
|
423
|
+
}
|
|
424
|
+
printResponse(await client.delete(`/work-items/${encode(workItemId)}`), globalOptions);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const entries = program.command("entries").description("Manage entries");
|
|
428
|
+
addAgentHelp(entries, "Entries are attached to work items. Use --json for machine-readable results.");
|
|
429
|
+
|
|
430
|
+
addAgentHelp(
|
|
431
|
+
addPagination(
|
|
432
|
+
entries
|
|
433
|
+
.command("list")
|
|
434
|
+
.description("List entries attached to a work item")
|
|
435
|
+
.argument("<workItemId>", "Work item id"),
|
|
436
|
+
),
|
|
437
|
+
`Permissions: entries:read
|
|
438
|
+
|
|
439
|
+
Example:
|
|
440
|
+
windrunner entries list WORK_ITEM_ID --updated-after 2026-01-01T00:00:00Z --json`,
|
|
441
|
+
).action(
|
|
442
|
+
async (
|
|
443
|
+
workItemId: string,
|
|
444
|
+
options: { page: string; size: string; updatedAfter?: string },
|
|
445
|
+
command: Command,
|
|
446
|
+
) => {
|
|
447
|
+
const globalOptions = getGlobalOptions(command);
|
|
448
|
+
const client = new WindrunnerClient(globalOptions);
|
|
449
|
+
const response = await client.get<Entry[]>(
|
|
450
|
+
`/work-items/${encode(workItemId)}/entries${queryString({
|
|
451
|
+
page: numberValue(options.page, "page"),
|
|
452
|
+
size: numberValue(options.size, "size"),
|
|
453
|
+
updated_after: options.updatedAfter,
|
|
454
|
+
})}`,
|
|
455
|
+
);
|
|
456
|
+
printResponse(response, globalOptions);
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
addAgentHelp(
|
|
461
|
+
entries
|
|
462
|
+
.command("create")
|
|
463
|
+
.description("Create an entry on a work item")
|
|
464
|
+
.argument("<workItemId>", "Work item id")
|
|
465
|
+
.requiredOption("--body <body>", "Entry body")
|
|
466
|
+
.option("--type <type>", "Entry type"),
|
|
467
|
+
`Permissions: entries:write
|
|
468
|
+
Required: --body.
|
|
469
|
+
|
|
470
|
+
Examples:
|
|
471
|
+
windrunner entries create WORK_ITEM_ID --body "Deployment completed"
|
|
472
|
+
windrunner entries create WORK_ITEM_ID --body "Comment" --type COMMENT --dry-run`,
|
|
473
|
+
)
|
|
474
|
+
.action(async (workItemId: string, options: { body: string; type?: string }, command: Command) => {
|
|
475
|
+
const globalOptions = getGlobalOptions(command);
|
|
476
|
+
const client = new WindrunnerClient(globalOptions);
|
|
477
|
+
const body: Entry = {
|
|
478
|
+
body: requireText(options.body, "--body"),
|
|
479
|
+
...(options.type === undefined ? {} : { type: options.type }),
|
|
480
|
+
};
|
|
481
|
+
printResponse(await client.post<Entry>(`/work-items/${encode(workItemId)}/entries`, body), globalOptions);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
addAgentHelp(
|
|
485
|
+
program
|
|
486
|
+
.command("search")
|
|
487
|
+
.description("Search project work items, entries, and relationships")
|
|
488
|
+
.argument("<projectId>", "Project id")
|
|
489
|
+
.argument("<query>", "Search query")
|
|
490
|
+
.option("--limit <number>", "Maximum number of matches"),
|
|
491
|
+
`Permissions: work_items:read
|
|
492
|
+
|
|
493
|
+
Example:
|
|
494
|
+
windrunner search PROJECT_ID "login failure" --limit 20 --json`,
|
|
495
|
+
)
|
|
496
|
+
.action(async (projectId: string, query: string, options: { limit?: string }, command: Command) => {
|
|
497
|
+
const globalOptions = getGlobalOptions(command);
|
|
498
|
+
const client = new WindrunnerClient(globalOptions);
|
|
499
|
+
const limit = numberValue(options.limit, "limit");
|
|
500
|
+
printResponse(
|
|
501
|
+
await client.get<JsonObject>(
|
|
502
|
+
`/projects/${encode(projectId)}/search${queryString({ q: query, limit })}`,
|
|
503
|
+
),
|
|
504
|
+
globalOptions,
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
await program.parseAsync(process.argv);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
+
console.error(`Error: ${message}`);
|
|
513
|
+
process.exitCode = 1;
|
|
514
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type JsonObject = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface ApiError {
|
|
4
|
+
code?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
field?: string;
|
|
7
|
+
details?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ApiResponse<T> {
|
|
11
|
+
data: T | null;
|
|
12
|
+
errors?: ApiError[];
|
|
13
|
+
meta?: JsonObject | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GlobalOptions {
|
|
17
|
+
url: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
yes?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Project {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
createdByUserId?: string;
|
|
27
|
+
createdAt?: string;
|
|
28
|
+
updatedAt?: string;
|
|
29
|
+
archivedAt?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkItem {
|
|
33
|
+
id?: string;
|
|
34
|
+
projectId?: string;
|
|
35
|
+
parentWorkItemId?: string | null;
|
|
36
|
+
sortIndex?: number;
|
|
37
|
+
type?: string;
|
|
38
|
+
title: string;
|
|
39
|
+
status?: string;
|
|
40
|
+
dueDate?: string | null;
|
|
41
|
+
priority?: string | null;
|
|
42
|
+
createdByUserId?: string;
|
|
43
|
+
createdAt?: string;
|
|
44
|
+
updatedAt?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Assignee {
|
|
48
|
+
assigneeType: string;
|
|
49
|
+
assigneeId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WorkItemResponse {
|
|
53
|
+
workItem: WorkItem;
|
|
54
|
+
assignees: Assignee[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Entry {
|
|
58
|
+
id?: string;
|
|
59
|
+
projectId?: string;
|
|
60
|
+
workItemId?: string;
|
|
61
|
+
sortIndex?: number;
|
|
62
|
+
authorUserId?: string;
|
|
63
|
+
type?: string;
|
|
64
|
+
body: string;
|
|
65
|
+
createdAt?: string;
|
|
66
|
+
updatedAt?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DryRunResult {
|
|
70
|
+
dryRun: true;
|
|
71
|
+
method: string;
|
|
72
|
+
path: string;
|
|
73
|
+
body?: unknown;
|
|
74
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"]
|
|
16
|
+
}
|