azdo-cli 0.2.0-develop.8 → 0.2.0-feature-devcontainer.72

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