azdo-cli 0.2.0-develop.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +48 -54
  2. package/dist/index.js +989 -2
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -1,79 +1,73 @@
1
- # bd - Beads
1
+ # azdo-cli
2
2
 
3
- **Distributed, git-backed graph issue tracker for AI agents.**
3
+ A command-line interface for Azure DevOps.
4
4
 
5
- **Platforms:** macOS, Linux, Windows, FreeBSD
5
+ [![npm version](https://img.shields.io/npm/v/azdo-cli)](https://www.npmjs.com/package/azdo-cli)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=alkampfergit_azdo-cli&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=alkampfergit_azdo-cli)
6
8
 
7
- [![License](https://img.shields.io/github/license/steveyegge/beads)](LICENSE)
8
- [![Go Report Card](https://goreportcard.com/badge/github.com/steveyegge/beads)](https://goreportcard.com/report/github.com/steveyegge/beads)
9
- [![Release](https://img.shields.io/github/v/release/steveyegge/beads)](https://github.com/steveyegge/beads/releases)
10
- [![npm version](https://img.shields.io/npm/v/@beads/bd)](https://www.npmjs.com/package/@beads/bd)
11
- [![PyPI](https://img.shields.io/pypi/v/beads-mcp)](https://pypi.org/project/beads-mcp/)
9
+ ## Installation
12
10
 
13
- Beads provides a persistent, structured memory for coding agents. It replaces messy markdown plans with a dependency-aware graph, allowing agents to handle long-horizon tasks without losing context.
11
+ ```bash
12
+ npm install -g azdo-cli
13
+ ```
14
14
 
15
- ## ⚡ Quick Start
15
+ ## Usage
16
16
 
17
17
  ```bash
18
- # Install beads CLI (system-wide - don't clone this repo into your project)
19
- curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
20
-
21
- # Initialize in YOUR project
22
- cd your-project
23
- bd init
18
+ # Show help
19
+ azdo --help
24
20
 
25
- # Tell your agent
26
- echo "Use 'bd' for task tracking" >> AGENTS.md
21
+ # Show version
22
+ azdo --version
23
+ azdo -v
27
24
  ```
28
25
 
29
- **Note:** Beads is a CLI tool you install once and use everywhere. You don't need to clone this repository into your project.
26
+ ## Current Status
30
27
 
31
- ## 🛠 Features
28
+ This project is in early development (v0.2.0). The base CLI scaffold is in place with support for `--help` and `--version`. Azure DevOps commands (work items, pipelines, repos, etc.) will be added in future releases.
32
29
 
33
- * **Git as Database:** Issues stored as JSONL in `.beads/`. Versioned, branched, and merged like code.
34
- * **Agent-Optimized:** JSON output, dependency tracking, and auto-ready task detection.
35
- * **Zero Conflict:** Hash-based IDs (`bd-a1b2`) prevent merge collisions in multi-agent/multi-branch workflows.
36
- * **Invisible Infrastructure:** SQLite local cache for speed; background daemon for auto-sync.
37
- * **Compaction:** Semantic "memory decay" summarizes old closed tasks to save context window.
30
+ ## Development
38
31
 
39
- ## 📖 Essential Commands
40
-
41
- | Command | Action |
42
- | --- | --- |
43
- | `bd ready` | List tasks with no open blockers. |
44
- | `bd create "Title" -p 0` | Create a P0 task. |
45
- | `bd update <id> --claim` | Atomically claim a task (sets assignee + in_progress). |
46
- | `bd dep add <child> <parent>` | Link tasks (blocks, related, parent-child). |
47
- | `bd show <id>` | View task details and audit trail. |
32
+ ### Prerequisites
48
33
 
49
- ## 🔗 Hierarchy & Workflow
34
+ - Node.js LTS (20+)
35
+ - npm
50
36
 
51
- Beads supports hierarchical IDs for epics:
37
+ ### Setup
52
38
 
53
- * `bd-a3f8` (Epic)
54
- * `bd-a3f8.1` (Task)
55
- * `bd-a3f8.1.1` (Sub-task)
56
-
57
- **Stealth Mode:** Run `bd init --stealth` to use Beads locally without committing files to the main repo. Perfect for personal use on shared projects.
39
+ ```bash
40
+ git clone https://github.com/alkampfergit/azdo-cli.git
41
+ cd azdo-cli
42
+ npm install
43
+ ```
58
44
 
59
- **Contributor vs Maintainer:** When working on open-source projects:
45
+ ### Scripts
60
46
 
61
- * **Contributors** (forked repos): Run `bd init --contributor` to route planning issues to a separate repo (e.g., `~/.beads-planning`). Keeps experimental work out of PRs.
62
- * **Maintainers** (write access): Beads auto-detects maintainer role via SSH URLs or HTTPS with credentials. Only need `git config beads.role maintainer` if using GitHub HTTPS without credentials but you have write access.
47
+ | Command | Description |
48
+ | --- | --- |
49
+ | `npm run build` | Build the CLI with tsup |
50
+ | `npm test` | Run tests with vitest |
51
+ | `npm run lint` | Lint source files with ESLint |
52
+ | `npm run typecheck` | Type-check with tsc (no emit) |
53
+ | `npm run format` | Check formatting with Prettier |
63
54
 
64
- ## 📦 Installation
55
+ ### Tech Stack
65
56
 
66
- * **npm:** `npm install -g @beads/bd`
67
- * **Homebrew:** `brew install beads`
68
- * **Go:** `go install github.com/steveyegge/beads/cmd/bd@latest`
57
+ - **TypeScript 5.x** (strict mode, ES modules)
58
+ - **commander.js** CLI framework
59
+ - **tsup** Bundler (single-file ESM output)
60
+ - **vitest** — Test runner
61
+ - **ESLint + Prettier** — Linting and formatting
69
62
 
70
- **Requirements:** Linux, FreeBSD, macOS, or Windows.
63
+ ### Branch Strategy
71
64
 
72
- ## 🌐 Community Tools
65
+ This project follows **GitFlow**:
73
66
 
74
- See [docs/COMMUNITY_TOOLS.md](docs/COMMUNITY_TOOLS.md) for a curated list of community-built UIs, extensions, and integrationsincluding terminal interfaces, web UIs, editor extensions, and native apps.
67
+ - `master`stable releases
68
+ - `develop` — integration branch
69
+ - `feature/*` — feature branches off `develop`
75
70
 
76
- ## 📝 Documentation
71
+ ## License
77
72
 
78
- * [Installing](docs/INSTALLING.md) | [Agent Workflow](AGENT_INSTRUCTIONS.md) | [Copilot Setup](docs/COPILOT_INTEGRATION.md) | [Articles](ARTICLES.md) | [Sync Branch Mode](docs/PROTECTED_BRANCHES.md) | [Troubleshooting](docs/TROUBLESHOOTING.md) | [FAQ](docs/FAQ.md)
79
- * [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/steveyegge/beads)
73
+ [MIT](LICENSE)
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command } from "commander";
4
+ import { Command as Command9 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -11,9 +11,996 @@ var __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
12
12
  var version = pkg.version;
13
13
 
14
+ // src/commands/get-item.ts
15
+ import { Command } from "commander";
16
+
17
+ // src/services/azdo-client.ts
18
+ var DEFAULT_FIELDS = [
19
+ "System.Title",
20
+ "System.State",
21
+ "System.WorkItemType",
22
+ "System.AssignedTo",
23
+ "System.Description",
24
+ "Microsoft.VSTS.Common.AcceptanceCriteria",
25
+ "Microsoft.VSTS.TCM.ReproSteps",
26
+ "System.AreaPath",
27
+ "System.IterationPath"
28
+ ];
29
+ function authHeaders(pat) {
30
+ const token = Buffer.from(`:${pat}`).toString("base64");
31
+ return { Authorization: `Basic ${token}` };
32
+ }
33
+ async function fetchWithErrors(url, init) {
34
+ let response;
35
+ try {
36
+ response = await fetch(url, init);
37
+ } catch {
38
+ throw new Error("NETWORK_ERROR");
39
+ }
40
+ if (response.status === 401) throw new Error("AUTH_FAILED");
41
+ if (response.status === 403) throw new Error("PERMISSION_DENIED");
42
+ if (response.status === 404) throw new Error("NOT_FOUND");
43
+ return response;
44
+ }
45
+ function buildExtraFields(fields, requested) {
46
+ const result = {};
47
+ for (const name of requested) {
48
+ const val = fields[name];
49
+ if (val !== void 0 && val !== null) {
50
+ result[name] = String(val);
51
+ }
52
+ }
53
+ return Object.keys(result).length > 0 ? result : null;
54
+ }
55
+ async function getWorkItem(context, id, pat, extraFields) {
56
+ const url = new URL(
57
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
58
+ );
59
+ url.searchParams.set("api-version", "7.1");
60
+ if (extraFields && extraFields.length > 0) {
61
+ const allFields = [...DEFAULT_FIELDS, ...extraFields];
62
+ url.searchParams.set("fields", allFields.join(","));
63
+ }
64
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
65
+ if (!response.ok) {
66
+ throw new Error(`HTTP_${response.status}`);
67
+ }
68
+ const data = await response.json();
69
+ const descriptionParts = [];
70
+ if (data.fields["System.Description"]) {
71
+ descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
72
+ }
73
+ if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
74
+ descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
75
+ }
76
+ if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
77
+ descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
78
+ }
79
+ let combinedDescription = null;
80
+ if (descriptionParts.length === 1) {
81
+ combinedDescription = descriptionParts[0].value;
82
+ } else if (descriptionParts.length > 1) {
83
+ combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
84
+ }
85
+ return {
86
+ id: data.id,
87
+ rev: data.rev,
88
+ title: data.fields["System.Title"],
89
+ state: data.fields["System.State"],
90
+ type: data.fields["System.WorkItemType"],
91
+ assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
92
+ description: combinedDescription,
93
+ areaPath: data.fields["System.AreaPath"],
94
+ iterationPath: data.fields["System.IterationPath"],
95
+ url: data._links.html.href,
96
+ extraFields: extraFields && extraFields.length > 0 ? buildExtraFields(data.fields, extraFields) : null
97
+ };
98
+ }
99
+ async function getWorkItemFieldValue(context, id, pat, fieldName) {
100
+ const url = new URL(
101
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
102
+ );
103
+ url.searchParams.set("api-version", "7.1");
104
+ url.searchParams.set("fields", fieldName);
105
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
106
+ if (!response.ok) {
107
+ throw new Error(`HTTP_${response.status}`);
108
+ }
109
+ const data = await response.json();
110
+ const value = data.fields[fieldName];
111
+ if (value === void 0 || value === null || value === "") {
112
+ return null;
113
+ }
114
+ return typeof value === "object" ? JSON.stringify(value) : `${value}`;
115
+ }
116
+ async function updateWorkItem(context, id, pat, fieldName, operations) {
117
+ const url = new URL(
118
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
119
+ );
120
+ url.searchParams.set("api-version", "7.1");
121
+ const response = await fetchWithErrors(url.toString(), {
122
+ method: "PATCH",
123
+ headers: {
124
+ ...authHeaders(pat),
125
+ "Content-Type": "application/json-patch+json"
126
+ },
127
+ body: JSON.stringify(operations)
128
+ });
129
+ if (response.status === 400) {
130
+ let serverMessage = "Unknown error";
131
+ try {
132
+ const body = await response.json();
133
+ if (body.message) serverMessage = body.message;
134
+ } catch {
135
+ }
136
+ throw new Error(`UPDATE_REJECTED: ${serverMessage}`);
137
+ }
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP_${response.status}`);
140
+ }
141
+ const data = await response.json();
142
+ const lastOp = operations[operations.length - 1];
143
+ const fieldValue = lastOp.value ?? null;
144
+ return {
145
+ id: data.id,
146
+ rev: data.rev,
147
+ title: data.fields["System.Title"],
148
+ fieldName,
149
+ fieldValue
150
+ };
151
+ }
152
+
153
+ // src/services/auth.ts
154
+ import { createInterface } from "readline";
155
+
156
+ // src/services/credential-store.ts
157
+ import { Entry } from "@napi-rs/keyring";
158
+ var SERVICE = "azdo-cli";
159
+ var ACCOUNT = "pat";
160
+ async function getPat() {
161
+ try {
162
+ const entry = new Entry(SERVICE, ACCOUNT);
163
+ return entry.getPassword();
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ async function storePat(pat) {
169
+ try {
170
+ const entry = new Entry(SERVICE, ACCOUNT);
171
+ entry.setPassword(pat);
172
+ } catch {
173
+ }
174
+ }
175
+ async function deletePat() {
176
+ try {
177
+ const entry = new Entry(SERVICE, ACCOUNT);
178
+ entry.deletePassword();
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // src/services/auth.ts
186
+ function normalizePat(rawPat) {
187
+ const trimmedPat = rawPat.trim();
188
+ return trimmedPat.length > 0 ? trimmedPat : null;
189
+ }
190
+ async function promptForPat() {
191
+ if (!process.stdin.isTTY) {
192
+ return null;
193
+ }
194
+ return new Promise((resolve2) => {
195
+ const rl = createInterface({
196
+ input: process.stdin,
197
+ output: process.stderr
198
+ });
199
+ process.stderr.write("Enter your Azure DevOps PAT: ");
200
+ process.stdin.setRawMode(true);
201
+ process.stdin.resume();
202
+ let pat = "";
203
+ const onData = (key) => {
204
+ const ch = key.toString("utf8");
205
+ if (ch === "") {
206
+ process.stdin.setRawMode(false);
207
+ process.stdin.removeListener("data", onData);
208
+ rl.close();
209
+ process.stderr.write("\n");
210
+ resolve2(null);
211
+ } else if (ch === "\r" || ch === "\n") {
212
+ process.stdin.setRawMode(false);
213
+ process.stdin.removeListener("data", onData);
214
+ rl.close();
215
+ process.stderr.write("\n");
216
+ resolve2(pat);
217
+ } else if (ch === "\x7F" || ch === "\b") {
218
+ if (pat.length > 0) {
219
+ pat = pat.slice(0, -1);
220
+ process.stderr.write("\b \b");
221
+ }
222
+ } else {
223
+ pat += ch;
224
+ process.stderr.write("*".repeat(ch.length));
225
+ }
226
+ };
227
+ process.stdin.on("data", onData);
228
+ });
229
+ }
230
+ async function resolvePat() {
231
+ const envPat = process.env.AZDO_PAT;
232
+ if (envPat) {
233
+ return { pat: envPat, source: "env" };
234
+ }
235
+ const storedPat = await getPat();
236
+ if (storedPat !== null) {
237
+ return { pat: storedPat, source: "credential-store" };
238
+ }
239
+ const promptedPat = await promptForPat();
240
+ if (promptedPat !== null) {
241
+ const normalizedPat = normalizePat(promptedPat);
242
+ if (normalizedPat !== null) {
243
+ await storePat(normalizedPat);
244
+ return { pat: normalizedPat, source: "prompt" };
245
+ }
246
+ }
247
+ throw new Error(
248
+ "Authentication cancelled. Set AZDO_PAT environment variable or run again to enter a PAT."
249
+ );
250
+ }
251
+
252
+ // src/services/git-remote.ts
253
+ import { execSync } from "child_process";
254
+ var patterns = [
255
+ // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
256
+ /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/.+$/,
257
+ // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
258
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/.+$/,
259
+ // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
260
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/.+$/,
261
+ // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
262
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/.+$/,
263
+ // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
264
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/.+$/
265
+ ];
266
+ function parseAzdoRemote(url) {
267
+ for (const pattern of patterns) {
268
+ const match = url.match(pattern);
269
+ if (match) {
270
+ const project = match[2];
271
+ if (/^DefaultCollection$/i.test(project)) {
272
+ return { org: match[1], project: "" };
273
+ }
274
+ return { org: match[1], project };
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ function detectAzdoContext() {
280
+ let remoteUrl;
281
+ try {
282
+ remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
283
+ } catch {
284
+ throw new Error("Not in a git repository. Provide --org and --project explicitly.");
285
+ }
286
+ const context = parseAzdoRemote(remoteUrl);
287
+ if (!context || !context.org && !context.project) {
288
+ throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
289
+ }
290
+ return context;
291
+ }
292
+
293
+ // src/services/config-store.ts
294
+ import fs from "fs";
295
+ import path from "path";
296
+ import os from "os";
297
+ var SETTINGS = [
298
+ {
299
+ key: "org",
300
+ description: "Azure DevOps organization name",
301
+ type: "string",
302
+ example: "mycompany",
303
+ required: true
304
+ },
305
+ {
306
+ key: "project",
307
+ description: "Azure DevOps project name",
308
+ type: "string",
309
+ example: "MyProject",
310
+ required: true
311
+ },
312
+ {
313
+ key: "fields",
314
+ description: "Extra work item fields to include (comma-separated reference names)",
315
+ type: "string[]",
316
+ example: "System.Tags,Custom.Priority",
317
+ required: false
318
+ }
319
+ ];
320
+ var VALID_KEYS = SETTINGS.map((s) => s.key);
321
+ function getConfigPath() {
322
+ return path.join(os.homedir(), ".azdo", "config.json");
323
+ }
324
+ function loadConfig() {
325
+ const configPath = getConfigPath();
326
+ let raw;
327
+ try {
328
+ raw = fs.readFileSync(configPath, "utf-8");
329
+ } catch (err) {
330
+ if (err.code === "ENOENT") {
331
+ return {};
332
+ }
333
+ throw err;
334
+ }
335
+ try {
336
+ return JSON.parse(raw);
337
+ } catch {
338
+ process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
339
+ `);
340
+ return {};
341
+ }
342
+ }
343
+ function saveConfig(config) {
344
+ const configPath = getConfigPath();
345
+ const dir = path.dirname(configPath);
346
+ fs.mkdirSync(dir, { recursive: true });
347
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
348
+ }
349
+ function validateKey(key) {
350
+ if (!VALID_KEYS.includes(key)) {
351
+ throw new Error(`Unknown setting key "${key}". Valid keys: org, project, fields`);
352
+ }
353
+ }
354
+ function getConfigValue(key) {
355
+ validateKey(key);
356
+ const config = loadConfig();
357
+ return config[key];
358
+ }
359
+ function setConfigValue(key, value) {
360
+ validateKey(key);
361
+ const config = loadConfig();
362
+ if (value === "") {
363
+ delete config[key];
364
+ } else if (key === "fields") {
365
+ config.fields = value.split(",").map((s) => s.trim());
366
+ } else {
367
+ config[key] = value;
368
+ }
369
+ saveConfig(config);
370
+ }
371
+ function unsetConfigValue(key) {
372
+ validateKey(key);
373
+ const config = loadConfig();
374
+ delete config[key];
375
+ saveConfig(config);
376
+ }
377
+
378
+ // src/services/context.ts
379
+ function resolveContext(options) {
380
+ if (options.org && options.project) {
381
+ return { org: options.org, project: options.project };
382
+ }
383
+ const config = loadConfig();
384
+ if (config.org && config.project) {
385
+ return { org: config.org, project: config.project };
386
+ }
387
+ let gitContext = null;
388
+ try {
389
+ gitContext = detectAzdoContext();
390
+ } catch {
391
+ }
392
+ const org = config.org || gitContext?.org;
393
+ const project = config.project || gitContext?.project;
394
+ if (org && project) {
395
+ return { org, project };
396
+ }
397
+ throw new Error(
398
+ 'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
399
+ );
400
+ }
401
+
402
+ // src/services/command-helpers.ts
403
+ function parseWorkItemId(idStr) {
404
+ const id = Number.parseInt(idStr, 10);
405
+ if (!Number.isInteger(id) || id <= 0) {
406
+ process.stderr.write(
407
+ `Error: Work item ID must be a positive integer. Got: "${idStr}"
408
+ `
409
+ );
410
+ process.exit(1);
411
+ }
412
+ return id;
413
+ }
414
+ function validateOrgProjectPair(options) {
415
+ const hasOrg = options.org !== void 0;
416
+ const hasProject = options.project !== void 0;
417
+ if (hasOrg !== hasProject) {
418
+ process.stderr.write(
419
+ "Error: --org and --project must both be provided, or both omitted.\n"
420
+ );
421
+ process.exit(1);
422
+ }
423
+ }
424
+ function handleCommandError(err, id, context, scope = "write") {
425
+ const error = err instanceof Error ? err : new Error(String(err));
426
+ const msg = error.message;
427
+ const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
428
+ if (msg === "AUTH_FAILED") {
429
+ process.stderr.write(
430
+ `Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
431
+ `
432
+ );
433
+ } else if (msg === "PERMISSION_DENIED") {
434
+ process.stderr.write(
435
+ `Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
436
+ `
437
+ );
438
+ } else if (msg === "NOT_FOUND") {
439
+ process.stderr.write(
440
+ `Error: Work item ${id} not found in ${context?.org}/${context?.project}.
441
+ `
442
+ );
443
+ } else if (msg === "NETWORK_ERROR") {
444
+ process.stderr.write(
445
+ "Error: Could not connect to Azure DevOps. Check your network connection.\n"
446
+ );
447
+ } else if (msg.startsWith("UPDATE_REJECTED:")) {
448
+ const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
449
+ process.stderr.write(`Error: Update rejected: ${serverMsg}
450
+ `);
451
+ } else {
452
+ process.stderr.write(`Error: ${msg}
453
+ `);
454
+ }
455
+ process.exit(1);
456
+ }
457
+
458
+ // src/commands/get-item.ts
459
+ function stripHtml(html) {
460
+ let text = html;
461
+ text = text.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
462
+ text = text.replace(/<br\s*\/?>/gi, "\n");
463
+ text = text.replace(/<\/?(p|div)>/gi, "\n");
464
+ text = text.replace(/<li>/gi, "\n");
465
+ text = text.replace(/<[^>]*>/g, "");
466
+ text = text.replace(/&amp;/g, "&");
467
+ text = text.replace(/&lt;/g, "<");
468
+ text = text.replace(/&gt;/g, ">");
469
+ text = text.replace(/&quot;/g, '"');
470
+ text = text.replace(/&#39;/g, "'");
471
+ text = text.replace(/&nbsp;/g, " ");
472
+ text = text.replace(/\n{3,}/g, "\n\n");
473
+ return text.trim();
474
+ }
475
+ function formatWorkItem(workItem, short) {
476
+ const lines = [];
477
+ const label = (name) => name.padEnd(13);
478
+ lines.push(`${label("ID:")}${workItem.id}`);
479
+ lines.push(`${label("Type:")}${workItem.type}`);
480
+ lines.push(`${label("Title:")}${workItem.title}`);
481
+ lines.push(`${label("State:")}${workItem.state}`);
482
+ lines.push(`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`);
483
+ if (!short) {
484
+ lines.push(`${label("Area:")}${workItem.areaPath}`);
485
+ lines.push(`${label("Iteration:")}${workItem.iterationPath}`);
486
+ }
487
+ lines.push(`${label("URL:")}${workItem.url}`);
488
+ if (workItem.extraFields) {
489
+ for (const [refName, value] of Object.entries(workItem.extraFields)) {
490
+ const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
491
+ lines.push(`${fieldLabel.padEnd(13)}${value}`);
492
+ }
493
+ }
494
+ lines.push("");
495
+ const descriptionText = workItem.description ? stripHtml(workItem.description) : "";
496
+ if (short) {
497
+ const descLines = descriptionText.split("\n").filter((l) => l.trim() !== "");
498
+ const firstThree = descLines.slice(0, 3);
499
+ const truncated = descLines.length > 3;
500
+ const descSummary = firstThree.join("\n") + (truncated ? "\n..." : "");
501
+ lines.push(`${label("Description:")}${descSummary}`);
502
+ } else {
503
+ lines.push("Description:");
504
+ lines.push(descriptionText);
505
+ }
506
+ return lines.join("\n");
507
+ }
508
+ function createGetItemCommand() {
509
+ const command = new Command("get-item");
510
+ command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").action(
511
+ async (idStr, options) => {
512
+ const id = parseWorkItemId(idStr);
513
+ validateOrgProjectPair(options);
514
+ let context;
515
+ try {
516
+ context = resolveContext(options);
517
+ const credential = await resolvePat();
518
+ const fieldsList = options.fields ? options.fields.split(",").map((f) => f.trim()) : loadConfig().fields;
519
+ const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
520
+ const output = formatWorkItem(workItem, options.short ?? false);
521
+ process.stdout.write(output + "\n");
522
+ } catch (err) {
523
+ handleCommandError(err, id, context, "read");
524
+ }
525
+ }
526
+ );
527
+ return command;
528
+ }
529
+
530
+ // src/commands/clear-pat.ts
531
+ import { Command as Command2 } from "commander";
532
+ function createClearPatCommand() {
533
+ const command = new Command2("clear-pat");
534
+ command.description("Remove the stored Azure DevOps PAT from the credential store").action(async () => {
535
+ const deleted = await deletePat();
536
+ if (deleted) {
537
+ process.stdout.write("PAT removed from credential store.\n");
538
+ } else {
539
+ process.stdout.write("No stored PAT found.\n");
540
+ }
541
+ });
542
+ return command;
543
+ }
544
+
545
+ // src/commands/config.ts
546
+ import { Command as Command3 } from "commander";
547
+ import { createInterface as createInterface2 } from "readline";
548
+ function createConfigCommand() {
549
+ const config = new Command3("config");
550
+ config.description("Manage CLI settings");
551
+ const set = new Command3("set");
552
+ set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
553
+ try {
554
+ setConfigValue(key, value);
555
+ if (options.json) {
556
+ const output = { key, value };
557
+ if (key === "fields") {
558
+ output.value = value.split(",").map((s) => s.trim());
559
+ }
560
+ process.stdout.write(JSON.stringify(output) + "\n");
561
+ } else {
562
+ process.stdout.write(`Set "${key}" to "${value}"
563
+ `);
564
+ }
565
+ } catch (err) {
566
+ const message = err instanceof Error ? err.message : String(err);
567
+ process.stderr.write(`Error: ${message}
568
+ `);
569
+ process.exit(1);
570
+ }
571
+ });
572
+ const get = new Command3("get");
573
+ get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
574
+ try {
575
+ const value = getConfigValue(key);
576
+ if (options.json) {
577
+ process.stdout.write(
578
+ JSON.stringify({ key, value: value ?? null }) + "\n"
579
+ );
580
+ } else if (value === void 0) {
581
+ process.stdout.write(`Setting "${key}" is not configured.
582
+ `);
583
+ } else if (Array.isArray(value)) {
584
+ process.stdout.write(value.join(",") + "\n");
585
+ } else {
586
+ process.stdout.write(value + "\n");
587
+ }
588
+ } catch (err) {
589
+ const message = err instanceof Error ? err.message : String(err);
590
+ process.stderr.write(`Error: ${message}
591
+ `);
592
+ process.exit(1);
593
+ }
594
+ });
595
+ const list = new Command3("list");
596
+ list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
597
+ const cfg = loadConfig();
598
+ if (options.json) {
599
+ process.stdout.write(JSON.stringify(cfg) + "\n");
600
+ } else {
601
+ const keyWidth = 10;
602
+ const valueWidth = 30;
603
+ for (const setting of SETTINGS) {
604
+ const raw = cfg[setting.key];
605
+ const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
606
+ const marker = raw === void 0 && setting.required ? " *" : "";
607
+ process.stdout.write(
608
+ `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
609
+ `
610
+ );
611
+ }
612
+ const hasUnset = SETTINGS.some(
613
+ (s) => s.required && cfg[s.key] === void 0
614
+ );
615
+ if (hasUnset) {
616
+ process.stdout.write(
617
+ '\n* = required but not configured. Run "azdo config wizard" to set up.\n'
618
+ );
619
+ }
620
+ }
621
+ });
622
+ const unset = new Command3("unset");
623
+ unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
624
+ try {
625
+ unsetConfigValue(key);
626
+ if (options.json) {
627
+ process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
628
+ } else {
629
+ process.stdout.write(`Unset "${key}"
630
+ `);
631
+ }
632
+ } catch (err) {
633
+ const message = err instanceof Error ? err.message : String(err);
634
+ process.stderr.write(`Error: ${message}
635
+ `);
636
+ process.exit(1);
637
+ }
638
+ });
639
+ const wizard = new Command3("wizard");
640
+ wizard.description("Interactive wizard to configure all settings").action(async () => {
641
+ if (!process.stdin.isTTY) {
642
+ process.stderr.write(
643
+ "Error: Wizard requires an interactive terminal.\n"
644
+ );
645
+ process.exit(1);
646
+ }
647
+ const cfg = loadConfig();
648
+ const rl = createInterface2({
649
+ input: process.stdin,
650
+ output: process.stderr
651
+ });
652
+ const ask = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
653
+ process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
654
+ process.stderr.write("=======================================\n\n");
655
+ for (const setting of SETTINGS) {
656
+ const current = cfg[setting.key];
657
+ const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
658
+ const requiredTag = setting.required ? " (required)" : " (optional)";
659
+ process.stderr.write(`${setting.description}${requiredTag}
660
+ `);
661
+ if (setting.example) {
662
+ process.stderr.write(` Example: ${setting.example}
663
+ `);
664
+ }
665
+ const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
666
+ const answer = await ask(` ${setting.key}${defaultHint}: `);
667
+ const trimmed = answer.trim();
668
+ if (trimmed) {
669
+ setConfigValue(setting.key, trimmed);
670
+ process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
671
+
672
+ `);
673
+ } else if (currentDisplay) {
674
+ process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
675
+
676
+ `);
677
+ } else {
678
+ process.stderr.write(` -> Skipped "${setting.key}"
679
+
680
+ `);
681
+ }
682
+ }
683
+ rl.close();
684
+ process.stderr.write("Configuration complete!\n");
685
+ });
686
+ config.addCommand(set);
687
+ config.addCommand(get);
688
+ config.addCommand(list);
689
+ config.addCommand(unset);
690
+ config.addCommand(wizard);
691
+ return config;
692
+ }
693
+
694
+ // src/commands/set-state.ts
695
+ import { Command as Command4 } from "commander";
696
+ function createSetStateCommand() {
697
+ const command = new Command4("set-state");
698
+ command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
699
+ async (idStr, state, options) => {
700
+ const id = parseWorkItemId(idStr);
701
+ validateOrgProjectPair(options);
702
+ let context;
703
+ try {
704
+ context = resolveContext(options);
705
+ const credential = await resolvePat();
706
+ const operations = [
707
+ { op: "add", path: "/fields/System.State", value: state }
708
+ ];
709
+ const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
710
+ if (options.json) {
711
+ process.stdout.write(
712
+ JSON.stringify({
713
+ id: result.id,
714
+ rev: result.rev,
715
+ title: result.title,
716
+ field: result.fieldName,
717
+ value: result.fieldValue
718
+ }) + "\n"
719
+ );
720
+ } else {
721
+ process.stdout.write(`Updated work item ${result.id}: State -> ${state}
722
+ `);
723
+ }
724
+ } catch (err) {
725
+ handleCommandError(err, id, context, "write");
726
+ }
727
+ }
728
+ );
729
+ return command;
730
+ }
731
+
732
+ // src/commands/assign.ts
733
+ import { Command as Command5 } from "commander";
734
+ function createAssignCommand() {
735
+ const command = new Command5("assign");
736
+ command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
737
+ async (idStr, name, options) => {
738
+ const id = parseWorkItemId(idStr);
739
+ if (!name && !options.unassign) {
740
+ process.stderr.write(
741
+ "Error: Either provide a user name or use --unassign.\n"
742
+ );
743
+ process.exit(1);
744
+ }
745
+ if (name && options.unassign) {
746
+ process.stderr.write(
747
+ "Error: Cannot provide both a user name and --unassign.\n"
748
+ );
749
+ process.exit(1);
750
+ }
751
+ validateOrgProjectPair(options);
752
+ let context;
753
+ try {
754
+ context = resolveContext(options);
755
+ const credential = await resolvePat();
756
+ const value = options.unassign ? "" : name;
757
+ const operations = [
758
+ { op: "add", path: "/fields/System.AssignedTo", value }
759
+ ];
760
+ const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
761
+ if (options.json) {
762
+ process.stdout.write(
763
+ JSON.stringify({
764
+ id: result.id,
765
+ rev: result.rev,
766
+ title: result.title,
767
+ field: result.fieldName,
768
+ value: result.fieldValue
769
+ }) + "\n"
770
+ );
771
+ } else {
772
+ const displayValue = options.unassign ? "(unassigned)" : name;
773
+ process.stdout.write(`Updated work item ${result.id}: Assigned To -> ${displayValue}
774
+ `);
775
+ }
776
+ } catch (err) {
777
+ handleCommandError(err, id, context, "write");
778
+ }
779
+ }
780
+ );
781
+ return command;
782
+ }
783
+
784
+ // src/commands/set-field.ts
785
+ import { Command as Command6 } from "commander";
786
+ function createSetFieldCommand() {
787
+ const command = new Command6("set-field");
788
+ command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
789
+ async (idStr, field, value, options) => {
790
+ const id = parseWorkItemId(idStr);
791
+ validateOrgProjectPair(options);
792
+ let context;
793
+ try {
794
+ context = resolveContext(options);
795
+ const credential = await resolvePat();
796
+ const operations = [
797
+ { op: "add", path: `/fields/${field}`, value }
798
+ ];
799
+ const result = await updateWorkItem(context, id, credential.pat, field, operations);
800
+ if (options.json) {
801
+ process.stdout.write(
802
+ JSON.stringify({
803
+ id: result.id,
804
+ rev: result.rev,
805
+ title: result.title,
806
+ field: result.fieldName,
807
+ value: result.fieldValue
808
+ }) + "\n"
809
+ );
810
+ } else {
811
+ process.stdout.write(`Updated work item ${result.id}: ${field} -> ${value}
812
+ `);
813
+ }
814
+ } catch (err) {
815
+ handleCommandError(err, id, context, "write");
816
+ }
817
+ }
818
+ );
819
+ return command;
820
+ }
821
+
822
+ // src/commands/get-md-field.ts
823
+ import { Command as Command7 } from "commander";
824
+
825
+ // src/services/md-convert.ts
826
+ import { NodeHtmlMarkdown } from "node-html-markdown";
827
+
828
+ // src/services/html-detect.ts
829
+ var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
830
+ var HTML_TAGS = /* @__PURE__ */ new Set([
831
+ "p",
832
+ "br",
833
+ "div",
834
+ "span",
835
+ "strong",
836
+ "em",
837
+ "b",
838
+ "i",
839
+ "u",
840
+ "a",
841
+ "ul",
842
+ "ol",
843
+ "li",
844
+ "h1",
845
+ "h2",
846
+ "h3",
847
+ "h4",
848
+ "h5",
849
+ "h6",
850
+ "table",
851
+ "tr",
852
+ "td",
853
+ "th",
854
+ "img",
855
+ "pre",
856
+ "code"
857
+ ]);
858
+ function isHtml(content) {
859
+ let match;
860
+ HTML_TAG_REGEX.lastIndex = 0;
861
+ while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
862
+ if (HTML_TAGS.has(match[1].toLowerCase())) {
863
+ return true;
864
+ }
865
+ }
866
+ return false;
867
+ }
868
+
869
+ // src/services/md-convert.ts
870
+ function htmlToMarkdown(html) {
871
+ return NodeHtmlMarkdown.translate(html);
872
+ }
873
+ function toMarkdown(content) {
874
+ if (isHtml(content)) {
875
+ return htmlToMarkdown(content);
876
+ }
877
+ return content;
878
+ }
879
+
880
+ // src/commands/get-md-field.ts
881
+ function createGetMdFieldCommand() {
882
+ const command = new Command7("get-md-field");
883
+ command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
884
+ async (idStr, field, options) => {
885
+ const id = parseWorkItemId(idStr);
886
+ validateOrgProjectPair(options);
887
+ let context;
888
+ try {
889
+ context = resolveContext(options);
890
+ const credential = await resolvePat();
891
+ const value = await getWorkItemFieldValue(context, id, credential.pat, field);
892
+ if (value === null) {
893
+ process.stdout.write("\n");
894
+ } else {
895
+ process.stdout.write(toMarkdown(value) + "\n");
896
+ }
897
+ } catch (err) {
898
+ handleCommandError(err, id, context, "read");
899
+ }
900
+ }
901
+ );
902
+ return command;
903
+ }
904
+
905
+ // src/commands/set-md-field.ts
906
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
907
+ import { Command as Command8 } from "commander";
908
+ function fail(message) {
909
+ process.stderr.write(`Error: ${message}
910
+ `);
911
+ process.exit(1);
912
+ }
913
+ function resolveContent(inlineContent, options) {
914
+ if (inlineContent && options.file) {
915
+ fail("Cannot specify both inline content and --file.");
916
+ }
917
+ if (options.file) {
918
+ return readFileContent(options.file);
919
+ }
920
+ if (inlineContent) {
921
+ return inlineContent;
922
+ }
923
+ return null;
924
+ }
925
+ function readFileContent(filePath) {
926
+ if (!existsSync(filePath)) {
927
+ fail(`File not found: ${filePath}`);
928
+ }
929
+ try {
930
+ return readFileSync2(filePath, "utf-8");
931
+ } catch {
932
+ fail(`Cannot read file: ${filePath}`);
933
+ }
934
+ }
935
+ async function readStdinContent() {
936
+ if (process.stdin.isTTY) {
937
+ fail(
938
+ "No content provided. Pass markdown content as the third argument, use --file, or pipe via stdin."
939
+ );
940
+ }
941
+ const chunks = [];
942
+ for await (const chunk of process.stdin) {
943
+ chunks.push(chunk);
944
+ }
945
+ const stdinContent = Buffer.concat(chunks).toString("utf-8").trimEnd();
946
+ if (!stdinContent) {
947
+ fail(
948
+ "No content provided via stdin. Pipe markdown content or use inline content or --file."
949
+ );
950
+ }
951
+ return stdinContent;
952
+ }
953
+ function formatOutput(result, options, field) {
954
+ if (options.json) {
955
+ process.stdout.write(
956
+ JSON.stringify({
957
+ id: result.id,
958
+ rev: result.rev,
959
+ field: result.fieldName,
960
+ value: result.fieldValue
961
+ }) + "\n"
962
+ );
963
+ } else {
964
+ process.stdout.write(`Updated work item ${result.id}: ${field} set with markdown content
965
+ `);
966
+ }
967
+ }
968
+ function createSetMdFieldCommand() {
969
+ const command = new Command8("set-md-field");
970
+ command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
971
+ async (idStr, field, inlineContent, options) => {
972
+ const id = parseWorkItemId(idStr);
973
+ validateOrgProjectPair(options);
974
+ const content = resolveContent(inlineContent, options) ?? await readStdinContent();
975
+ let context;
976
+ try {
977
+ context = resolveContext(options);
978
+ const credential = await resolvePat();
979
+ const operations = [
980
+ { op: "add", path: `/fields/${field}`, value: content },
981
+ { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
982
+ ];
983
+ const result = await updateWorkItem(context, id, credential.pat, field, operations);
984
+ formatOutput(result, options, field);
985
+ } catch (err) {
986
+ handleCommandError(err, id, context, "write");
987
+ }
988
+ }
989
+ );
990
+ return command;
991
+ }
992
+
14
993
  // src/index.ts
15
- var program = new Command();
994
+ var program = new Command9();
16
995
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
996
+ program.addCommand(createGetItemCommand());
997
+ program.addCommand(createClearPatCommand());
998
+ program.addCommand(createConfigCommand());
999
+ program.addCommand(createSetStateCommand());
1000
+ program.addCommand(createAssignCommand());
1001
+ program.addCommand(createSetFieldCommand());
1002
+ program.addCommand(createGetMdFieldCommand());
1003
+ program.addCommand(createSetMdFieldCommand());
17
1004
  program.showHelpAfterError();
18
1005
  program.parse();
19
1006
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.2.0-develop.8",
3
+ "version": "0.2.0",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "lint": "eslint src/",
15
15
  "typecheck": "tsc --noEmit",
16
16
  "format": "prettier --check src/",
17
- "test": "vitest run"
17
+ "test": "npm run build && vitest run"
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
@@ -22,7 +22,9 @@
22
22
  },
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "commander": "^14.0.3"
25
+ "@napi-rs/keyring": "^1.2.0",
26
+ "commander": "^14.0.3",
27
+ "node-html-markdown": "^2.0.0"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@eslint/js": "^10.0.1",