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 +5 -1
- package/bin/asana-cli.js +11 -0
- package/package.json +1 -1
- package/skills/asana/SKILL.md +2 -1
- package/src/client.js +38 -11
- package/src/commands/edit-comment.js +29 -0
- package/src/commands/update.js +14 -5
- package/src/utils.js +26 -0
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
package/skills/asana/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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]:
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
+
}
|