asana-cli-skill 1.1.0 → 1.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.
package/README.md CHANGED
@@ -189,7 +189,7 @@ Line 2"
189
189
 
190
190
  ### `asana-cli update <url-or-id>`
191
191
 
192
- Updates a task: `--name`, `--notes` (replace), `--append-notes` (add to the end), `--assignee` (`none` unassigns), `--due` (`none` clears), `--complete` or `--reopen`.
192
+ Updates a task: `--name`, `--notes` (replace), `--append-notes` (add to the end), `--assignee` (`none` unassigns), `--due` (`none` clears), `--complete` or `--reopen`, and custom fields with `--field "Name=Value"` (repeatable). Enum options match by name ignoring case, accents and icons, so `--field "Priority=Urgent"` selects an option shown with a leading icon.
193
193
 
194
194
  ```bash
195
195
  asana-cli update 5678 --due 2026-10-31 --append-notes "Blocked by the vendor"
@@ -207,6 +207,10 @@ asana-cli move 9013 --after 9012
207
207
 
208
208
  Deletes a comment. The ID is shown by `comments`. Only comments can be deleted, not system events.
209
209
 
210
+ ### `asana-cli edit-comment <comment-id> -m "text"`
211
+
212
+ Replaces the text of a comment and keeps its inline images.
213
+
210
214
  ## Workspace
211
215
 
212
216
  `search`, `projects`, `my-tasks` and `create-task` need a workspace. They use, in order: `--workspace <id>`, the `ASANA_WORKSPACE` variable, or your only workspace (if you belong to several, the CLI lists them and asks for one).
package/bin/asana-cli.js CHANGED
@@ -23,6 +23,7 @@ import { createTaskCommand } from '../src/commands/create-task.js';
23
23
  import { updateCommand } from '../src/commands/update.js';
24
24
  import { moveCommand } from '../src/commands/move.js';
25
25
  import { deleteCommentCommand } from '../src/commands/delete-comment.js';
26
+ import { editCommentCommand } from '../src/commands/edit-comment.js';
26
27
  import { formatError } from '../src/formatter.js';
27
28
 
28
29
  // Load .env from the current directory first, then from ~/.asana-cli/
@@ -187,6 +188,7 @@ program
187
188
  .option('--due <date>', 'Due date (YYYY-MM-DD), or "none" to clear')
188
189
  .option('--complete', 'Mark as completed (without a comment)')
189
190
  .option('--reopen', 'Mark as not completed')
191
+ .option('--field <name=value>', 'Set a custom field, e.g. "Priority=High" (repeatable)', collectMultiple, [])
190
192
  .action(async (urlOrId, options) => {
191
193
  await updateCommand(urlOrId, options, getClient());
192
194
  });
@@ -210,6 +212,15 @@ program
210
212
  await deleteCommentCommand(commentId, getClient());
211
213
  });
212
214
 
215
+ program
216
+ .command('edit-comment')
217
+ .description('Replace the text of a comment, keeping its inline images')
218
+ .argument('<comment-id>', 'Comment ID')
219
+ .requiredOption('-m, --message <text>', 'New text')
220
+ .action(async (commentId, options) => {
221
+ await editCommentCommand(commentId, options, getClient());
222
+ });
223
+
213
224
  program.parseAsync().catch((err) => {
214
225
  console.error(formatError(err.message));
215
226
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asana-cli-skill",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Minimal CLI for Asana task management, built to power a Claude Code skill",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -144,8 +144,9 @@ Quick reference for `asana-cli`:
144
144
  | `asana-cli my-tasks` | Open tasks assigned to the user |
145
145
  | `asana-cli comments <url-or-id> [--last n]` | Read a task's comments |
146
146
  | `asana-cli create-task -n "name" -p <project> [-s section]` | Create a task |
147
- | `asana-cli update <id> [--name/--notes/--append-notes/--assignee/--due/--complete/--reopen]` | Update a task |
147
+ | `asana-cli update <id> [--name/--notes/--append-notes/--assignee/--due/--complete/--reopen/--field "Name=Value"]` | Update a task, including custom fields |
148
148
  | `asana-cli move <id> --after <sibling-id>` | Reorder subtasks or change the parent |
149
+ | `asana-cli edit-comment <comment-id> -m "text"` | Fix a comment's text, keeping inline images |
149
150
  | `asana-cli delete-comment <comment-id>` | Delete a comment (ID from `comments`) |
150
151
  | `asana-cli me` | Authenticated user and workspaces |
151
152
 
package/src/client.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { basename } from 'path';
2
2
 
3
+ import { normalizeLabel } from './utils.js';
4
+
3
5
  const BASE_URL = 'https://app.asana.com/api/1.0';
4
6
 
5
7
  export class AsanaClient {
@@ -79,7 +81,7 @@ export class AsanaClient {
79
81
 
80
82
  async getTask(taskId) {
81
83
  return this.request(
82
- `/tasks/${taskId}?opt_fields=name,notes,completed,completed_at,due_on,created_at,permalink_url,parent.name,assignee.name,memberships.project.name,memberships.section.name,custom_fields.name,custom_fields.enum_value,custom_fields.enum_options,custom_fields.display_value`
84
+ `/tasks/${taskId}?opt_fields=name,notes,completed,completed_at,due_on,created_at,permalink_url,parent.name,assignee.name,memberships.project.name,memberships.section.name,custom_fields.name,custom_fields.resource_subtype,custom_fields.enum_value,custom_fields.enum_options,custom_fields.display_value`
83
85
  );
84
86
  }
85
87
 
@@ -189,7 +191,14 @@ export class AsanaClient {
189
191
  }
190
192
 
191
193
  async getStory(storyId) {
192
- return this.request(`/stories/${storyId}?opt_fields=type,text,target.name,created_by.name`);
194
+ return this.request(`/stories/${storyId}?opt_fields=type,text,html_text,target.name,created_by.name`);
195
+ }
196
+
197
+ async updateStoryHtml(storyId, htmlText) {
198
+ return this.request(`/stories/${storyId}`, {
199
+ method: 'PUT',
200
+ body: JSON.stringify({ data: { html_text: htmlText } }),
201
+ });
193
202
  }
194
203
 
195
204
  async deleteStory(storyId) {
@@ -204,9 +213,16 @@ export class AsanaClient {
204
213
  }
205
214
 
206
215
  async setCustomFieldEnum(taskId, fieldName, optionName) {
216
+ return this.setCustomField(taskId, fieldName, optionName);
217
+ }
218
+
219
+ // Sets a custom field by name. Enum options match by name ignoring case, accents, spacing and
220
+ // decorations (an option shown as "[icon] Urgente" matches "urgente"); text and number fields
221
+ // take the value as is.
222
+ async setCustomField(taskId, fieldName, value) {
207
223
  const task = await this.getTask(taskId);
208
224
  const field = task.custom_fields?.find(
209
- (f) => f.name.toLowerCase() === fieldName.toLowerCase()
225
+ (f) => normalizeLabel(f.name) === normalizeLabel(fieldName)
210
226
  );
211
227
  if (!field) {
212
228
  const available = task.custom_fields?.map((f) => f.name).join(', ') || 'none';
@@ -215,20 +231,31 @@ export class AsanaClient {
215
231
  );
216
232
  }
217
233
 
218
- const option = field.enum_options?.find(
219
- (o) => o.name.toLowerCase() === optionName.toLowerCase()
220
- );
221
- if (!option) {
222
- const available = field.enum_options?.map((o) => o.name).join(', ') || 'none';
223
- throw new Error(
224
- `Option "${optionName}" not found in field "${fieldName}". Available options: ${available}`
234
+ let fieldValue;
235
+ if (field.resource_subtype === 'number') {
236
+ fieldValue = Number(value);
237
+ if (Number.isNaN(fieldValue)) {
238
+ throw new Error(`Field "${field.name}" is a number; got "${value}"`);
239
+ }
240
+ } else if (field.resource_subtype === 'text') {
241
+ fieldValue = value;
242
+ } else {
243
+ const option = field.enum_options?.find(
244
+ (o) => normalizeLabel(o.name) === normalizeLabel(value)
225
245
  );
246
+ if (!option) {
247
+ const available = field.enum_options?.map((o) => o.name).join(', ') || 'none';
248
+ throw new Error(
249
+ `Option "${value}" not found in field "${field.name}". Available options: ${available}`
250
+ );
251
+ }
252
+ fieldValue = option.gid;
226
253
  }
227
254
 
228
255
  return this.request(`/tasks/${taskId}`, {
229
256
  method: 'PUT',
230
257
  body: JSON.stringify({
231
- data: { custom_fields: { [field.gid]: option.gid } },
258
+ data: { custom_fields: { [field.gid]: fieldValue } },
232
259
  }),
233
260
  });
234
261
  }
@@ -0,0 +1,29 @@
1
+ import { unescapeNewlines } from '../utils.js';
2
+ import { formatSuccess } from '../formatter.js';
3
+
4
+ function escapeHtml(text) {
5
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
+ }
7
+
8
+ export async function editCommentCommand(commentId, options, client) {
9
+ if (!/^\d+$/.test(commentId)) {
10
+ throw new Error(`Invalid comment ID: ${commentId}`);
11
+ }
12
+ if (!options.message) {
13
+ throw new Error('--message is required');
14
+ }
15
+
16
+ const story = await client.getStory(commentId);
17
+ if (story.type !== 'comment') {
18
+ throw new Error(`Story ${commentId} is not a comment (type: ${story.type})`);
19
+ }
20
+
21
+ // Inline images live in the HTML as <img data-asana-gid="..."/>; keep them after the new text.
22
+ const images = (story.html_text || '').match(/<img [^>]*data-asana-gid="\d+"[^>]*\/?>/g) || [];
23
+ const text = escapeHtml(unescapeNewlines(options.message));
24
+ const html = `<body>${text}${images.length > 0 ? '\n' + images.join('\n') : ''}</body>`;
25
+
26
+ await client.updateStoryHtml(commentId, html);
27
+ const kept = images.length > 0 ? `\n Inline images kept: ${images.length}` : '';
28
+ console.log(formatSuccess(`Comment edited on "${story.target?.name || '-'}"${kept}`));
29
+ }
@@ -1,4 +1,4 @@
1
- import { parseTaskId, buildTaskFields, unescapeNewlines } from '../utils.js';
1
+ import { parseTaskId, buildTaskFields, unescapeNewlines, parseFieldAssignments } from '../utils.js';
2
2
  import { formatSuccess } from '../formatter.js';
3
3
 
4
4
  export async function updateCommand(urlOrId, options, client) {
@@ -21,11 +21,20 @@ export async function updateCommand(urlOrId, options, client) {
21
21
  if (options.complete) data.completed = true;
22
22
  if (options.reopen) data.completed = false;
23
23
 
24
- if (Object.keys(data).length === 0) {
25
- throw new Error('Nothing to update. Pass at least one of --name, --notes, --append-notes, --assignee, --due, --complete, --reopen');
24
+ const fields = parseFieldAssignments(options.field || []);
25
+
26
+ if (Object.keys(data).length === 0 && fields.length === 0) {
27
+ throw new Error('Nothing to update. Pass at least one of --name, --notes, --append-notes, --assignee, --due, --complete, --reopen, --field');
28
+ }
29
+
30
+ if (Object.keys(data).length > 0) {
31
+ await client.updateTask(taskId, data);
32
+ }
33
+ for (const { name, value } of fields) {
34
+ await client.setCustomField(taskId, name, value);
26
35
  }
27
36
 
28
- await client.updateTask(taskId, data);
29
37
  const task = await client.getTask(taskId);
30
- console.log(formatSuccess(`Task "${task.name}" updated: ${Object.keys(data).join(', ')}`));
38
+ const changed = [...Object.keys(data), ...fields.map((f) => f.name)];
39
+ console.log(formatSuccess(`Task "${task.name}" updated: ${changed.join(', ')}`));
31
40
  }
package/src/utils.js CHANGED
@@ -110,3 +110,29 @@ export function buildTaskFields(options) {
110
110
 
111
111
  return data;
112
112
  }
113
+
114
+ /**
115
+ * Normalizes a field or option label for matching: lowercase, no accents, and only letters,
116
+ * digits and single spaces (icons and punctuation around the label are ignored).
117
+ */
118
+ export function normalizeLabel(label) {
119
+ return String(label)
120
+ .normalize('NFD')
121
+ .replace(/\p{Diacritic}/gu, '')
122
+ .toLowerCase()
123
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
124
+ .trim();
125
+ }
126
+
127
+ /**
128
+ * Parses "Name=Value" pairs from repeated --field options.
129
+ */
130
+ export function parseFieldAssignments(pairs) {
131
+ return pairs.map((pair) => {
132
+ const index = pair.indexOf('=');
133
+ if (index <= 0 || index === pair.length - 1) {
134
+ throw new Error(`Invalid --field "${pair}": use "Field name=Value"`);
135
+ }
136
+ return { name: pair.slice(0, index).trim(), value: pair.slice(index + 1).trim() };
137
+ });
138
+ }