asana-cli-skill 1.1.0 → 1.3.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 +12 -0
- package/package.json +2 -2
- 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 +32 -5
- package/src/utils.js +36 -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`, custom fields with `--field "Name=Value"` (repeatable), and images shown inline at the end of the description with `--image <path>` (repeatable; the text is kept unless `--notes` replaces it). 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,8 @@ 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, [])
|
|
192
|
+
.option('--image <path>', 'Attach an image and show it inline at the end of the description (repeatable)', collectMultiple, [])
|
|
190
193
|
.action(async (urlOrId, options) => {
|
|
191
194
|
await updateCommand(urlOrId, options, getClient());
|
|
192
195
|
});
|
|
@@ -210,6 +213,15 @@ program
|
|
|
210
213
|
await deleteCommentCommand(commentId, getClient());
|
|
211
214
|
});
|
|
212
215
|
|
|
216
|
+
program
|
|
217
|
+
.command('edit-comment')
|
|
218
|
+
.description('Replace the text of a comment, keeping its inline images')
|
|
219
|
+
.argument('<comment-id>', 'Comment ID')
|
|
220
|
+
.requiredOption('-m, --message <text>', 'New text')
|
|
221
|
+
.action(async (commentId, options) => {
|
|
222
|
+
await editCommentCommand(commentId, options, getClient());
|
|
223
|
+
});
|
|
224
|
+
|
|
213
225
|
program.parseAsync().catch((err) => {
|
|
214
226
|
console.error(formatError(err.message));
|
|
215
227
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "asana-cli-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"url": "https://github.com/giacomeli/asana-cli-skill/issues"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"asana-cli": "
|
|
17
|
+
"asana-cli": "bin/asana-cli.js"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"bin",
|
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"/--image <path>]` | Update a task, including custom fields and inline images in the description |
|
|
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,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
import { parseTaskId, buildTaskFields, unescapeNewlines, parseFieldAssignments, buildHtmlNotes } from '../utils.js';
|
|
2
4
|
import { formatSuccess } from '../formatter.js';
|
|
3
5
|
|
|
4
6
|
export async function updateCommand(urlOrId, options, client) {
|
|
@@ -11,6 +13,11 @@ export async function updateCommand(urlOrId, options, client) {
|
|
|
11
13
|
throw new Error('Use either --complete or --reopen, not both');
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
const images = options.image || [];
|
|
17
|
+
for (const filePath of images) {
|
|
18
|
+
if (!existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
const data = buildTaskFields(options);
|
|
15
22
|
|
|
16
23
|
if (options.appendNotes !== undefined) {
|
|
@@ -21,11 +28,31 @@ export async function updateCommand(urlOrId, options, client) {
|
|
|
21
28
|
if (options.complete) data.completed = true;
|
|
22
29
|
if (options.reopen) data.completed = false;
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
const fields = parseFieldAssignments(options.field || []);
|
|
32
|
+
|
|
33
|
+
if (Object.keys(data).length === 0 && fields.length === 0 && images.length === 0) {
|
|
34
|
+
throw new Error('Nothing to update. Pass at least one of --name, --notes, --append-notes, --assignee, --due, --complete, --reopen, --field, --image');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Images go inline after the description text, which is kept when --notes is not passed.
|
|
38
|
+
if (images.length > 0) {
|
|
39
|
+
const text = data.notes ?? (await client.getTask(taskId)).notes ?? '';
|
|
40
|
+
delete data.notes;
|
|
41
|
+
const gids = [];
|
|
42
|
+
for (const filePath of images) {
|
|
43
|
+
gids.push((await client.uploadAttachment(taskId, filePath)).gid);
|
|
44
|
+
}
|
|
45
|
+
data.html_notes = buildHtmlNotes(text, gids);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Object.keys(data).length > 0) {
|
|
49
|
+
await client.updateTask(taskId, data);
|
|
50
|
+
}
|
|
51
|
+
for (const { name, value } of fields) {
|
|
52
|
+
await client.setCustomField(taskId, name, value);
|
|
26
53
|
}
|
|
27
54
|
|
|
28
|
-
await client.updateTask(taskId, data);
|
|
29
55
|
const task = await client.getTask(taskId);
|
|
30
|
-
|
|
56
|
+
const changed = [...Object.keys(data), ...fields.map((f) => f.name)];
|
|
57
|
+
console.log(formatSuccess(`Task "${task.name}" updated: ${changed.join(', ')}`));
|
|
31
58
|
}
|
package/src/utils.js
CHANGED
|
@@ -110,3 +110,39 @@ 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
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds a rich-text description: the plain text (escaped) followed by the task's own
|
|
142
|
+
* attachments shown inline. Asana only renders <img data-asana-gid> for attachments of the same task.
|
|
143
|
+
*/
|
|
144
|
+
export function buildHtmlNotes(text, imageGids) {
|
|
145
|
+
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
146
|
+
const images = imageGids.map((gid) => `<img data-asana-gid="${gid}"/>`).join('');
|
|
147
|
+
return `<body>${escaped ? `${escaped}\n` : ''}${images}</body>`;
|
|
148
|
+
}
|