asana-cli-skill 1.0.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
@@ -1,17 +1,35 @@
1
- # asana-cli
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/giacomeli/asana-cli-skill/main/promo-assets/asana-cli-skill-readme-hero-1280x640.png" alt="asana-cli banner: Asana task management from your terminal, a minimal Node.js CLI that powers the asana skill for Claude Code" width="100%">
3
+ </p>
2
4
 
3
- Minimal CLI for Asana task management. Built to power the `asana` skill for [Claude Code](https://claude.com/claude-code), but works standalone in any terminal.
5
+ # asana-cli — Asana task management from your terminal
6
+
7
+ **Minimal CLI for Asana task management. Built to power the `asana` skill for [Claude Code](https://claude.com/claude-code), but works standalone in any terminal.**
8
+
9
+ [![npm version](https://img.shields.io/npm/v/asana-cli-skill)](https://www.npmjs.com/package/asana-cli-skill)
10
+ [![node](https://img.shields.io/node/v/asana-cli-skill)](https://nodejs.org)
11
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
12
+
13
+ `asana-cli` is a Node.js command-line tool for the [Asana API](https://developers.asana.com/reference): read tasks, list and create subtasks, add comments with attachments, and complete work — all from the terminal. It is the infrastructure layer behind the `asana` Claude Code skill, which lets an AI coding agent pick up an Asana task, build an implementation plan from its subtasks, and report progress back as the work advances.
4
14
 
5
15
  The CLI is a thin infrastructure layer over the Asana REST API. All orchestration logic (reading a task, generating a plan, executing steps, reporting progress) lives in the skill — see [The Claude Code skill](#the-claude-code-skill).
6
16
 
7
17
  ## Installation
8
18
 
19
+ Install from [npm](https://www.npmjs.com/package/asana-cli-skill):
20
+
9
21
  ```bash
10
22
  npm install -g asana-cli-skill
11
23
  ```
12
24
 
13
25
  This makes the `asana-cli` command available globally.
14
26
 
27
+ Or run it without installing — useful in scripts, CI, and AI agent environments:
28
+
29
+ ```bash
30
+ npx asana-cli-skill task <url-or-id>
31
+ ```
32
+
15
33
  Or from source:
16
34
 
17
35
  ```bash
@@ -119,12 +137,84 @@ asana-cli comment 5678 -m "Screenshots attached" -a screenshot1.png -a screensho
119
137
 
120
138
  ### `asana-cli create-subtask <parent-id> -n "name"`
121
139
 
122
- Creates a new subtask under a task.
140
+ Creates a new subtask under a task. Optional: `--notes`, `--assignee`, `--due`.
123
141
 
124
142
  ```bash
125
- asana-cli create-subtask 5678 -n "Implement toast component"
143
+ asana-cli create-subtask 5678 -n "Implement toast component" --assignee me --due 2026-10-20
126
144
  ```
127
145
 
146
+ New subtasks appear at the top of the parent's list; use `move` to reorder them.
147
+
148
+ ### `asana-cli me`
149
+
150
+ Shows the authenticated user and their workspaces (with IDs).
151
+
152
+ ### `asana-cli search <query>`
153
+
154
+ Searches by name in the workspace (Asana typeahead, available on every plan). Tasks by default; `--type project|portfolio|tag|user` for other resources, `--limit` up to 100.
155
+
156
+ ```bash
157
+ asana-cli search "payment gateway"
158
+ asana-cli search "Marketing" --type project
159
+ ```
160
+
161
+ ### `asana-cli projects`
162
+
163
+ Lists the active projects of the workspace. `--filter <text>` narrows by name, `--archived` lists archived ones.
164
+
165
+ ### `asana-cli project <url-or-id>`
166
+
167
+ Reads a project: its open tasks grouped by section, in board order. `--section <text>` keeps only matching sections, `--completed` includes completed tasks.
168
+
169
+ ```bash
170
+ asana-cli project https://app.asana.com/0/1234/list --section "Sprint"
171
+ ```
172
+
173
+ ### `asana-cli my-tasks`
174
+
175
+ Lists the open tasks assigned to you, earliest due date first. `--completed` includes completed ones.
176
+
177
+ ### `asana-cli comments <url-or-id>`
178
+
179
+ Lists the comments of a task (author, date, text). `--last <n>` keeps only the most recent ones.
180
+
181
+ ### `asana-cli create-task -n "name" --project <url-or-id>`
182
+
183
+ Creates a task in a project, optionally in a section (`--section`, by name or ID), with `--notes`, `--assignee` (`me`, a user ID or an email) and `--due` (`YYYY-MM-DD`). With `--parent <task>` instead of `--project`, creates a subtask.
184
+
185
+ ```bash
186
+ asana-cli create-task -n "Review contract" -p 1234 -s "Backlog" --assignee me --notes "Line 1
187
+ Line 2"
188
+ ```
189
+
190
+ ### `asana-cli update <url-or-id>`
191
+
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
+
194
+ ```bash
195
+ asana-cli update 5678 --due 2026-10-31 --append-notes "Blocked by the vendor"
196
+ ```
197
+
198
+ ### `asana-cli move <url-or-id>`
199
+
200
+ Moves a task under another parent (`--parent`) or reorders it among its sibling subtasks (`--before <id>` or `--after <id>`).
201
+
202
+ ```bash
203
+ asana-cli move 9013 --after 9012
204
+ ```
205
+
206
+ ### `asana-cli delete-comment <comment-id>`
207
+
208
+ Deletes a comment. The ID is shown by `comments`. Only comments can be deleted, not system events.
209
+
210
+ ### `asana-cli edit-comment <comment-id> -m "text"`
211
+
212
+ Replaces the text of a comment and keeps its inline images.
213
+
214
+ ## Workspace
215
+
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).
217
+
128
218
  ## Accepted URL formats
129
219
 
130
220
  The CLI accepts any of these:
@@ -134,6 +224,8 @@ The CLI accepts any of these:
134
224
  - `https://app.asana.com/1/<workspace-id>/project/<project-id>/task/<task-id>`
135
225
  - Plain numeric ID (e.g. `1234567890`)
136
226
 
227
+ Project commands (`project`, `create-task --project`) accept `https://app.asana.com/0/<project-id>/list`, `https://app.asana.com/1/<workspace-id>/project/<project-id>[/...]` or a plain project ID.
228
+
137
229
  ## The Claude Code skill
138
230
 
139
231
  The `skills/asana/` directory contains a Claude Code skill that orchestrates a full development workflow on top of this CLI: it reads an Asana task, builds an implementation plan from its subtasks (creating them if missing), executes step by step, and updates Asana with comments, commit hashes, and status as the work progresses.
@@ -154,6 +246,10 @@ cp -r "$(npm root -g)/asana-cli-skill/skills/asana" ~/.claude/skills/asana
154
246
 
155
247
  Then share any Asana task link in a Claude Code conversation, or use `/asana`.
156
248
 
249
+ ### Other AI coding agents
250
+
251
+ The CLI itself is agent-agnostic: any coding agent (Codex, Cursor, Copilot, Gemini CLI, opencode) that can run shell commands can use it to read Asana tasks and report progress — `npx asana-cli-skill task <url>` works with zero setup beyond the token. The repository ships [llms.txt](llms.txt) with an LLM-friendly overview and [AGENTS.md](AGENTS.md) with conventions for agents working on the codebase. Ports of the skill to other agent platforms are a welcome [contribution](CONTRIBUTING.md).
252
+
157
253
  ## Project structure
158
254
 
159
255
  ```
@@ -186,6 +282,10 @@ asana-cli-skill/
186
282
  - `dotenv` for configuration
187
283
  - Native `fetch` (Node 18+)
188
284
 
285
+ ## Contributing
286
+
287
+ Contributions are very welcome — bug reports, feature ideas, docs improvements, and pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md) for the guidelines, project conventions, and ideas for a first contribution. Questions and workflow feedback are welcome in [Discussions](https://github.com/giacomeli/asana-cli-skill/discussions).
288
+
189
289
  ## License
190
290
 
191
291
  [MIT](LICENSE)
package/bin/asana-cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { config } from 'dotenv';
4
- import { join } from 'path';
4
+ import { join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
- import { existsSync } from 'fs';
6
+ import { existsSync, readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
7
8
  import { Command } from 'commander';
8
9
  import { AsanaClient } from '../src/client.js';
9
10
  import { initCommand } from '../src/commands/init.js';
@@ -12,6 +13,17 @@ import { subtasksCommand } from '../src/commands/subtasks.js';
12
13
  import { completeCommand } from '../src/commands/complete.js';
13
14
  import { commentCommand } from '../src/commands/comment.js';
14
15
  import { createSubtaskCommand } from '../src/commands/create-subtask.js';
16
+ import { meCommand } from '../src/commands/me.js';
17
+ import { searchCommand } from '../src/commands/search.js';
18
+ import { projectsCommand } from '../src/commands/projects.js';
19
+ import { projectCommand } from '../src/commands/project.js';
20
+ import { myTasksCommand } from '../src/commands/my-tasks.js';
21
+ import { commentsCommand } from '../src/commands/comments.js';
22
+ import { createTaskCommand } from '../src/commands/create-task.js';
23
+ import { updateCommand } from '../src/commands/update.js';
24
+ import { moveCommand } from '../src/commands/move.js';
25
+ import { deleteCommentCommand } from '../src/commands/delete-comment.js';
26
+ import { editCommentCommand } from '../src/commands/edit-comment.js';
15
27
  import { formatError } from '../src/formatter.js';
16
28
 
17
29
  // Load .env from the current directory first, then from ~/.asana-cli/
@@ -30,12 +42,16 @@ function collectMultiple(value, prev) {
30
42
  return prev.concat([value]);
31
43
  }
32
44
 
45
+ const pkg = JSON.parse(
46
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
47
+ );
48
+
33
49
  const program = new Command();
34
50
 
35
51
  program
36
52
  .name('asana-cli')
37
53
  .description('Minimal CLI for Asana task management')
38
- .version('1.0.0');
54
+ .version(pkg.version);
39
55
 
40
56
  program
41
57
  .command('init')
@@ -83,10 +99,128 @@ program
83
99
  .description('Create a subtask under a task')
84
100
  .argument('<parent-id>', 'Parent task ID')
85
101
  .requiredOption('-n, --name <name>', 'Subtask name')
102
+ .option('--notes <text>', 'Description')
103
+ .option('--assignee <who>', '"me", a user ID or an email')
104
+ .option('--due <date>', 'Due date (YYYY-MM-DD)')
86
105
  .action(async (parentId, options) => {
87
106
  await createSubtaskCommand(parentId, options, getClient());
88
107
  });
89
108
 
109
+ program
110
+ .command('me')
111
+ .description('Show the authenticated user and their workspaces')
112
+ .action(async () => {
113
+ await meCommand(getClient());
114
+ });
115
+
116
+ program
117
+ .command('search')
118
+ .description('Search tasks (or projects, tags, users) by name')
119
+ .argument('<query>', 'Text to search for')
120
+ .option('-t, --type <type>', 'task, project, portfolio, tag or user', 'task')
121
+ .option('-l, --limit <n>', 'Maximum number of results (1-100)', '20')
122
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
123
+ .action(async (query, options) => {
124
+ await searchCommand(query, options, getClient());
125
+ });
126
+
127
+ program
128
+ .command('projects')
129
+ .description('List the projects of a workspace')
130
+ .option('-f, --filter <text>', 'Only projects whose name contains this text')
131
+ .option('--archived', 'List archived projects instead of active ones')
132
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
133
+ .action(async (options) => {
134
+ await projectsCommand(options, getClient());
135
+ });
136
+
137
+ program
138
+ .command('project')
139
+ .description('Read a project: its open tasks grouped by section')
140
+ .argument('<url-or-id>', 'Asana project URL or ID')
141
+ .option('-s, --section <name>', 'Only sections whose name contains this text')
142
+ .option('--completed', 'Include completed tasks')
143
+ .action(async (urlOrId, options) => {
144
+ await projectCommand(urlOrId, options, getClient());
145
+ });
146
+
147
+ program
148
+ .command('my-tasks')
149
+ .description('List the open tasks assigned to you, earliest due date first')
150
+ .option('--completed', 'Include completed tasks')
151
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
152
+ .action(async (options) => {
153
+ await myTasksCommand(options, getClient());
154
+ });
155
+
156
+ program
157
+ .command('comments')
158
+ .description('List the comments of a task')
159
+ .argument('<url-or-id>', 'Asana URL or task ID')
160
+ .option('--last <n>', 'Only the last N comments')
161
+ .action(async (urlOrId, options) => {
162
+ await commentsCommand(urlOrId, options, getClient());
163
+ });
164
+
165
+ program
166
+ .command('create-task')
167
+ .description('Create a task in a project (or a subtask with --parent)')
168
+ .requiredOption('-n, --name <name>', 'Task name')
169
+ .option('-p, --project <url-or-id>', 'Project URL or ID')
170
+ .option('-s, --section <name-or-id>', 'Section of the project')
171
+ .option('--parent <url-or-id>', 'Create as a subtask of this task')
172
+ .option('--notes <text>', 'Description')
173
+ .option('--assignee <who>', '"me", a user ID or an email')
174
+ .option('--due <date>', 'Due date (YYYY-MM-DD)')
175
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
176
+ .action(async (options) => {
177
+ await createTaskCommand(options, getClient());
178
+ });
179
+
180
+ program
181
+ .command('update')
182
+ .description('Update a task: name, description, assignee, due date, completion')
183
+ .argument('<url-or-id>', 'Asana URL or task ID')
184
+ .option('-n, --name <name>', 'New name')
185
+ .option('--notes <text>', 'Replace the description')
186
+ .option('--append-notes <text>', 'Append to the description')
187
+ .option('--assignee <who>', '"me", a user ID, an email, or "none" to unassign')
188
+ .option('--due <date>', 'Due date (YYYY-MM-DD), or "none" to clear')
189
+ .option('--complete', 'Mark as completed (without a comment)')
190
+ .option('--reopen', 'Mark as not completed')
191
+ .option('--field <name=value>', 'Set a custom field, e.g. "Priority=High" (repeatable)', collectMultiple, [])
192
+ .action(async (urlOrId, options) => {
193
+ await updateCommand(urlOrId, options, getClient());
194
+ });
195
+
196
+ program
197
+ .command('move')
198
+ .description('Make a task a subtask of another, or reorder it among its sibling subtasks')
199
+ .argument('<url-or-id>', 'Asana URL or task ID')
200
+ .option('--parent <url-or-id>', 'New parent task (defaults to the current parent)')
201
+ .option('--before <id>', 'Place it right before this sibling subtask')
202
+ .option('--after <id>', 'Place it right after this sibling subtask')
203
+ .action(async (urlOrId, options) => {
204
+ await moveCommand(urlOrId, options, getClient());
205
+ });
206
+
207
+ program
208
+ .command('delete-comment')
209
+ .description('Delete a comment (story ID from the comments command)')
210
+ .argument('<comment-id>', 'Comment ID')
211
+ .action(async (commentId) => {
212
+ await deleteCommentCommand(commentId, getClient());
213
+ });
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
+
90
224
  program.parseAsync().catch((err) => {
91
225
  console.error(formatError(err.message));
92
226
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asana-cli-skill",
3
- "version": "1.0.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",
@@ -22,17 +22,27 @@
22
22
  "skills"
23
23
  ],
24
24
  "scripts": {
25
- "start": "node bin/asana-cli.js"
25
+ "start": "node bin/asana-cli.js",
26
+ "test": "node --test"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=18"
29
30
  },
30
31
  "keywords": [
31
32
  "asana",
33
+ "asana-api",
32
34
  "cli",
35
+ "command-line",
33
36
  "claude-code",
37
+ "claude-code-skill",
38
+ "agent-skill",
39
+ "ai-agents",
40
+ "coding-agent",
34
41
  "skill",
35
- "task-management"
42
+ "task-management",
43
+ "automation",
44
+ "productivity",
45
+ "developer-tools"
36
46
  ],
37
47
  "dependencies": {
38
48
  "chalk": "^5.4.1",
@@ -137,7 +137,18 @@ Quick reference for `asana-cli`:
137
137
  | `asana-cli complete <id> -m "msg"` | Comment + mark as done |
138
138
  | `asana-cli comment <id> -m "msg"` | Comment only |
139
139
  | `asana-cli comment <id> -m "msg" -a <path>` | Comment with attachment (image, file) |
140
- | `asana-cli create-subtask <parent-id> -n "name"` | Create a subtask |
140
+ | `asana-cli create-subtask <parent-id> -n "name" [--notes/--assignee/--due]` | Create a subtask (appears at the top; reorder with `move`) |
141
+ | `asana-cli search "<text>" [-t project]` | Find a task or project by name |
142
+ | `asana-cli projects [-f text]` | List projects |
143
+ | `asana-cli project <url-or-id> [-s section]` | Read a project's open tasks by section |
144
+ | `asana-cli my-tasks` | Open tasks assigned to the user |
145
+ | `asana-cli comments <url-or-id> [--last n]` | Read a task's comments |
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/--field "Name=Value"]` | Update a task, including custom fields |
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 |
150
+ | `asana-cli delete-comment <comment-id>` | Delete a comment (ID from `comments`) |
151
+ | `asana-cli me` | Authenticated user and workspaces |
141
152
 
142
153
  ## Important Rules
143
154
 
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 {
@@ -34,12 +36,139 @@ export class AsanaClient {
34
36
  return json.data;
35
37
  }
36
38
 
39
+ // Follows Asana's offset pagination and returns every item of a collection.
40
+ async requestAll(path) {
41
+ const items = [];
42
+ const separator = path.includes('?') ? '&' : '?';
43
+ let offset = null;
44
+ do {
45
+ const pagePath = `${path}${separator}limit=100${offset ? `&offset=${offset}` : ''}`;
46
+ const response = await fetch(`${BASE_URL}${pagePath}`, {
47
+ headers: { 'Authorization': `Bearer ${this.token}` },
48
+ });
49
+ if (!response.ok) {
50
+ const body = await response.json().catch(() => ({}));
51
+ const msg = body?.errors?.[0]?.message || response.statusText;
52
+ throw new Error(`Asana API error (${response.status}): ${msg}`);
53
+ }
54
+ const json = await response.json();
55
+ items.push(...json.data);
56
+ offset = json.next_page?.offset || null;
57
+ } while (offset);
58
+ return items;
59
+ }
60
+
61
+ async getMe() {
62
+ return this.request('/users/me?opt_fields=name,email,workspaces.name');
63
+ }
64
+
65
+ // Workspace used by search, projects and my-tasks: --workspace, then ASANA_WORKSPACE,
66
+ // then the only workspace of the user.
67
+ async resolveWorkspace(explicit) {
68
+ const chosen = explicit || process.env.ASANA_WORKSPACE;
69
+ if (chosen) return chosen;
70
+
71
+ const me = await this.getMe();
72
+ const workspaces = me.workspaces || [];
73
+ if (workspaces.length === 1) return workspaces[0].gid;
74
+
75
+ const list = workspaces.map((w) => `${w.name} (${w.gid})`).join(', ');
76
+ throw new Error(
77
+ `You belong to ${workspaces.length} workspaces: ${list}.\n` +
78
+ 'Pass --workspace <id> or set ASANA_WORKSPACE.'
79
+ );
80
+ }
81
+
37
82
  async getTask(taskId) {
38
83
  return this.request(
39
- `/tasks/${taskId}?opt_fields=name,notes,completed,assignee.name,memberships.project.name,custom_fields.name,custom_fields.enum_value,custom_fields.enum_options`
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`
85
+ );
86
+ }
87
+
88
+ async getComments(taskId) {
89
+ const stories = await this.requestAll(
90
+ `/tasks/${taskId}/stories?opt_fields=type,resource_subtype,text,created_at,created_by.name`
91
+ );
92
+ return stories.filter((s) => s.type === 'comment');
93
+ }
94
+
95
+ async search(workspaceId, query, { type = 'task', count = 20 } = {}) {
96
+ const fields = {
97
+ task: 'name,completed,assignee.name,memberships.project.name,parent.name',
98
+ project: 'name,archived,owner.name',
99
+ }[type] || 'name';
100
+ const params = new URLSearchParams({
101
+ resource_type: type,
102
+ query,
103
+ count: String(count),
104
+ opt_fields: fields,
105
+ });
106
+ return this.request(`/workspaces/${workspaceId}/typeahead?${params}`);
107
+ }
108
+
109
+ async getProjects(workspaceId, { archived = false } = {}) {
110
+ return this.requestAll(
111
+ `/projects?workspace=${workspaceId}&archived=${archived}&opt_fields=name,archived,owner.name`
112
+ );
113
+ }
114
+
115
+ async getProject(projectId) {
116
+ return this.request(
117
+ `/projects/${projectId}?opt_fields=name,notes,archived,owner.name,permalink_url,workspace.name`
118
+ );
119
+ }
120
+
121
+ async getSections(projectId) {
122
+ return this.requestAll(`/projects/${projectId}/sections?opt_fields=name`);
123
+ }
124
+
125
+ // Without completed, only open tasks (completed_since=now is Asana's way to say so).
126
+ async getProjectTasks(projectId, { completed = false } = {}) {
127
+ const since = completed ? '' : '&completed_since=now';
128
+ return this.requestAll(
129
+ `/projects/${projectId}/tasks?opt_fields=name,completed,due_on,assignee.name,memberships.project.gid,memberships.section.name${since}`
130
+ );
131
+ }
132
+
133
+ async getMyTasks(workspaceId, { completed = false } = {}) {
134
+ const since = completed ? '' : '&completed_since=now';
135
+ return this.requestAll(
136
+ `/tasks?assignee=me&workspace=${workspaceId}&opt_fields=name,completed,due_on,memberships.project.name${since}`
40
137
  );
41
138
  }
42
139
 
140
+ async createTask(data) {
141
+ return this.request('/tasks', {
142
+ method: 'POST',
143
+ body: JSON.stringify({ data }),
144
+ });
145
+ }
146
+
147
+ async updateTask(taskId, data) {
148
+ return this.request(`/tasks/${taskId}`, {
149
+ method: 'PUT',
150
+ body: JSON.stringify({ data }),
151
+ });
152
+ }
153
+
154
+ // Moves a task under a parent (or reorders it among its siblings with insert_before/insert_after).
155
+ async setParent(taskId, parentId, { insertBefore, insertAfter } = {}) {
156
+ const data = { parent: parentId };
157
+ if (insertBefore) data.insert_before = insertBefore;
158
+ if (insertAfter) data.insert_after = insertAfter;
159
+ return this.request(`/tasks/${taskId}/setParent`, {
160
+ method: 'POST',
161
+ body: JSON.stringify({ data }),
162
+ });
163
+ }
164
+
165
+ async addTaskToSection(sectionId, taskId) {
166
+ return this.request(`/sections/${sectionId}/addTask`, {
167
+ method: 'POST',
168
+ body: JSON.stringify({ data: { task: taskId } }),
169
+ });
170
+ }
171
+
43
172
  async getSubtasks(taskId) {
44
173
  return this.request(
45
174
  `/tasks/${taskId}/subtasks?opt_fields=name,completed`
@@ -61,6 +190,21 @@ export class AsanaClient {
61
190
  });
62
191
  }
63
192
 
193
+ async getStory(storyId) {
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
+ });
202
+ }
203
+
204
+ async deleteStory(storyId) {
205
+ return this.request(`/stories/${storyId}`, { method: 'DELETE' });
206
+ }
207
+
64
208
  async addHtmlComment(taskId, htmlText) {
65
209
  return this.request(`/tasks/${taskId}/stories`, {
66
210
  method: 'POST',
@@ -69,9 +213,16 @@ export class AsanaClient {
69
213
  }
70
214
 
71
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) {
72
223
  const task = await this.getTask(taskId);
73
224
  const field = task.custom_fields?.find(
74
- (f) => f.name.toLowerCase() === fieldName.toLowerCase()
225
+ (f) => normalizeLabel(f.name) === normalizeLabel(fieldName)
75
226
  );
76
227
  if (!field) {
77
228
  const available = task.custom_fields?.map((f) => f.name).join(', ') || 'none';
@@ -80,20 +231,31 @@ export class AsanaClient {
80
231
  );
81
232
  }
82
233
 
83
- const option = field.enum_options?.find(
84
- (o) => o.name.toLowerCase() === optionName.toLowerCase()
85
- );
86
- if (!option) {
87
- const available = field.enum_options?.map((o) => o.name).join(', ') || 'none';
88
- throw new Error(
89
- `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)
90
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;
91
253
  }
92
254
 
93
255
  return this.request(`/tasks/${taskId}`, {
94
256
  method: 'PUT',
95
257
  body: JSON.stringify({
96
- data: { custom_fields: { [field.gid]: option.gid } },
258
+ data: { custom_fields: { [field.gid]: fieldValue } },
97
259
  }),
98
260
  });
99
261
  }
@@ -139,10 +301,10 @@ export class AsanaClient {
139
301
  return json.data;
140
302
  }
141
303
 
142
- async createSubtask(parentId, name) {
304
+ async createSubtask(parentId, name, extra = {}) {
143
305
  return this.request(`/tasks/${parentId}/subtasks`, {
144
306
  method: 'POST',
145
- body: JSON.stringify({ data: { name } }),
307
+ body: JSON.stringify({ data: { name, ...extra } }),
146
308
  });
147
309
  }
148
310
  }
@@ -0,0 +1,17 @@
1
+ import { parseTaskId } from '../utils.js';
2
+ import { formatComments } from '../formatter.js';
3
+
4
+ export async function commentsCommand(urlOrId, options, client) {
5
+ const taskId = parseTaskId(urlOrId);
6
+ let comments = await client.getComments(taskId);
7
+
8
+ if (options.last) {
9
+ const last = Number(options.last);
10
+ if (!Number.isInteger(last) || last < 1) {
11
+ throw new Error('--last must be a positive integer');
12
+ }
13
+ comments = comments.slice(-last);
14
+ }
15
+
16
+ console.log(formatComments(comments));
17
+ }
@@ -1,3 +1,4 @@
1
+ import { buildTaskFields } from '../utils.js';
1
2
  import { formatSuccess } from '../formatter.js';
2
3
 
3
4
  export async function createSubtaskCommand(parentId, options, client) {
@@ -7,7 +8,8 @@ export async function createSubtaskCommand(parentId, options, client) {
7
8
  process.exit(1);
8
9
  }
9
10
 
10
- const subtask = await client.createSubtask(parentId, name);
11
+ const { name: _name, ...extra } = buildTaskFields(options);
12
+ const subtask = await client.createSubtask(parentId, name, extra);
11
13
  const parent = await client.getTask(parentId);
12
14
 
13
15
  console.log(formatSuccess(`Subtask created: "${name}" (id: ${subtask.gid})\n Parent: ${parent.name}`));
@@ -0,0 +1,41 @@
1
+ import { parseProjectId, parseTaskId, buildTaskFields } from '../utils.js';
2
+ import { formatSuccess } from '../formatter.js';
3
+
4
+ export async function createTaskCommand(options, client) {
5
+ if (!options.project && !options.parent) {
6
+ throw new Error('Pass --project (or --parent to create a subtask)');
7
+ }
8
+ if (options.section && !options.project) {
9
+ throw new Error('--section requires --project');
10
+ }
11
+
12
+ const data = buildTaskFields(options);
13
+ let task;
14
+
15
+ if (options.parent) {
16
+ task = await client.createSubtask(parseTaskId(options.parent), data.name, data);
17
+ } else {
18
+ const projectId = parseProjectId(options.project);
19
+ const workspace = await client.resolveWorkspace(options.workspace);
20
+ task = await client.createTask({ ...data, projects: [projectId], workspace });
21
+
22
+ if (options.section) {
23
+ const sections = await client.getSections(projectId);
24
+ const needle = options.section.toLowerCase();
25
+ const section = sections.find((s) => s.gid === options.section)
26
+ || sections.find((s) => s.name.toLowerCase() === needle)
27
+ || sections.find((s) => s.name.toLowerCase().includes(needle));
28
+ if (!section) {
29
+ const available = sections.map((s) => s.name).join(', ') || 'none';
30
+ throw new Error(`Task created (id: ${task.gid}), but no section matches "${options.section}". Sections: ${available}`);
31
+ }
32
+ await client.addTaskToSection(section.gid, task.gid);
33
+ }
34
+ }
35
+
36
+ const created = await client.getTask(task.gid);
37
+ const where = created.parent?.name
38
+ ? `Parent: ${created.parent.name}`
39
+ : `Project: ${created.memberships?.[0]?.project?.name || '-'}${created.memberships?.[0]?.section?.name ? ` / ${created.memberships[0].section.name}` : ''}`;
40
+ console.log(formatSuccess(`Task created: "${created.name}" (id: ${created.gid})\n ${where}\n URL: ${created.permalink_url}`));
41
+ }
@@ -0,0 +1,16 @@
1
+ import { formatSuccess } from '../formatter.js';
2
+
3
+ export async function deleteCommentCommand(commentId, client) {
4
+ if (!/^\d+$/.test(commentId)) {
5
+ throw new Error(`Invalid comment ID: ${commentId}`);
6
+ }
7
+
8
+ // Only comments: other stories (system events such as "completed this task") are not deletable.
9
+ const story = await client.getStory(commentId);
10
+ if (story.type !== 'comment') {
11
+ throw new Error(`Story ${commentId} is not a comment (type: ${story.type})`);
12
+ }
13
+
14
+ await client.deleteStory(commentId);
15
+ console.log(formatSuccess(`Comment deleted from "${story.target?.name || '-'}"\n Text was: "${story.text}"`));
16
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { formatMe } from '../formatter.js';
2
+
3
+ export async function meCommand(client) {
4
+ const me = await client.getMe();
5
+ console.log(formatMe(me));
6
+ }
@@ -0,0 +1,28 @@
1
+ import { parseTaskId } from '../utils.js';
2
+ import { formatSuccess } from '../formatter.js';
3
+
4
+ export async function moveCommand(urlOrId, options, client) {
5
+ const taskId = parseTaskId(urlOrId);
6
+
7
+ if (options.before && options.after) {
8
+ throw new Error('Use either --before or --after, not both');
9
+ }
10
+
11
+ let parentId = options.parent ? parseTaskId(options.parent) : null;
12
+ if (!parentId) {
13
+ const task = await client.getTask(taskId);
14
+ if (!task.parent) {
15
+ throw new Error('The task has no parent. Pass --parent to make it a subtask');
16
+ }
17
+ parentId = task.parent.gid;
18
+ }
19
+
20
+ await client.setParent(taskId, parentId, {
21
+ insertBefore: options.before && parseTaskId(options.before),
22
+ insertAfter: options.after && parseTaskId(options.after),
23
+ });
24
+
25
+ const [task, parent] = await Promise.all([client.getTask(taskId), client.getTask(parentId)]);
26
+ const position = options.before ? ` before ${options.before}` : options.after ? ` after ${options.after}` : '';
27
+ console.log(formatSuccess(`Task "${task.name}" moved under "${parent.name}"${position}`));
28
+ }
@@ -0,0 +1,7 @@
1
+ import { formatMyTasks } from '../formatter.js';
2
+
3
+ export async function myTasksCommand(options, client) {
4
+ const workspace = await client.resolveWorkspace(options.workspace);
5
+ const tasks = await client.getMyTasks(workspace, { completed: Boolean(options.completed) });
6
+ console.log(formatMyTasks(tasks));
7
+ }
@@ -0,0 +1,28 @@
1
+ import { parseProjectId } from '../utils.js';
2
+ import { formatProject } from '../formatter.js';
3
+
4
+ export async function projectCommand(urlOrId, options, client) {
5
+ const projectId = parseProjectId(urlOrId);
6
+ const [project, sections, tasks] = await Promise.all([
7
+ client.getProject(projectId),
8
+ client.getSections(projectId),
9
+ client.getProjectTasks(projectId, { completed: Boolean(options.completed) }),
10
+ ]);
11
+
12
+ let shownSections = sections;
13
+ if (options.section) {
14
+ const needle = options.section.toLowerCase();
15
+ shownSections = sections.filter((s) => s.name.toLowerCase().includes(needle));
16
+ if (shownSections.length === 0) {
17
+ const available = sections.map((s) => s.name).join(', ') || 'none';
18
+ throw new Error(`No section matches "${options.section}". Sections: ${available}`);
19
+ }
20
+ }
21
+
22
+ const names = new Set(shownSections.map((s) => s.name));
23
+ const shownTasks = options.section
24
+ ? tasks.filter((t) => t.memberships?.some((m) => m.project?.gid === projectId && names.has(m.section?.name)))
25
+ : tasks;
26
+
27
+ console.log(formatProject(project, shownSections, shownTasks));
28
+ }
@@ -0,0 +1,13 @@
1
+ import { formatProjects } from '../formatter.js';
2
+
3
+ export async function projectsCommand(options, client) {
4
+ const workspace = await client.resolveWorkspace(options.workspace);
5
+ let projects = await client.getProjects(workspace, { archived: Boolean(options.archived) });
6
+
7
+ if (options.filter) {
8
+ const needle = options.filter.toLowerCase();
9
+ projects = projects.filter((p) => p.name.toLowerCase().includes(needle));
10
+ }
11
+
12
+ console.log(formatProjects(projects));
13
+ }
@@ -0,0 +1,19 @@
1
+ import { formatSearchResults } from '../formatter.js';
2
+
3
+ const TYPES = ['task', 'project', 'portfolio', 'tag', 'user'];
4
+
5
+ export async function searchCommand(query, options, client) {
6
+ const type = options.type || 'task';
7
+ if (!TYPES.includes(type)) {
8
+ throw new Error(`Invalid --type "${type}". Use one of: ${TYPES.join(', ')}`);
9
+ }
10
+
11
+ const count = Number(options.limit || 20);
12
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
13
+ throw new Error('--limit must be an integer between 1 and 100');
14
+ }
15
+
16
+ const workspace = await client.resolveWorkspace(options.workspace);
17
+ const results = await client.search(workspace, query, { type, count });
18
+ console.log(formatSearchResults(results, type));
19
+ }
@@ -0,0 +1,40 @@
1
+ import { parseTaskId, buildTaskFields, unescapeNewlines, parseFieldAssignments } from '../utils.js';
2
+ import { formatSuccess } from '../formatter.js';
3
+
4
+ export async function updateCommand(urlOrId, options, client) {
5
+ const taskId = parseTaskId(urlOrId);
6
+
7
+ if (options.notes !== undefined && options.appendNotes !== undefined) {
8
+ throw new Error('Use either --notes or --append-notes, not both');
9
+ }
10
+ if (options.complete && options.reopen) {
11
+ throw new Error('Use either --complete or --reopen, not both');
12
+ }
13
+
14
+ const data = buildTaskFields(options);
15
+
16
+ if (options.appendNotes !== undefined) {
17
+ const current = await client.getTask(taskId);
18
+ const addition = unescapeNewlines(options.appendNotes);
19
+ data.notes = current.notes ? `${current.notes}\n\n${addition}` : addition;
20
+ }
21
+ if (options.complete) data.completed = true;
22
+ if (options.reopen) data.completed = false;
23
+
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);
35
+ }
36
+
37
+ const task = await client.getTask(taskId);
38
+ const changed = [...Object.keys(data), ...fields.map((f) => f.name)];
39
+ console.log(formatSuccess(`Task "${task.name}" updated: ${changed.join(', ')}`));
40
+ }
package/src/formatter.js CHANGED
@@ -16,8 +16,30 @@ export function formatTask(task, subtasks) {
16
16
  lines.push(` Assignee: ${task.assignee.name}`);
17
17
  }
18
18
 
19
+ const section = task.memberships?.[0]?.section?.name;
20
+ if (section) {
21
+ lines.push(` Section: ${section}`);
22
+ }
23
+
24
+ if (task.parent?.name) {
25
+ lines.push(` Parent: ${task.parent.name} (id: ${task.parent.gid})`);
26
+ }
27
+
28
+ if (task.due_on) {
29
+ lines.push(` Due: ${task.due_on}`);
30
+ }
31
+
32
+ const filledFields = (task.custom_fields || []).filter((f) => f.display_value);
33
+ for (const field of filledFields) {
34
+ lines.push(` ${field.name}: ${field.display_value}`);
35
+ }
36
+
19
37
  lines.push(` ID: ${task.gid}`);
20
38
 
39
+ if (task.permalink_url) {
40
+ lines.push(` URL: ${task.permalink_url}`);
41
+ }
42
+
21
43
  if (task.notes) {
22
44
  lines.push('');
23
45
  lines.push(' Description:');
@@ -62,6 +84,136 @@ export function formatSubtasks(subtasks) {
62
84
  return lines.join('\n');
63
85
  }
64
86
 
87
+ function taskLine(task, extra = []) {
88
+ const check = task.completed ? chalk.green('x') : ' ';
89
+ const name = task.completed ? chalk.dim(task.name) : task.name;
90
+ const details = extra.filter(Boolean);
91
+ const suffix = details.length > 0 ? chalk.dim(` - ${details.join(' - ')}`) : '';
92
+ return ` [${check}] ${name} (id: ${task.gid})${suffix}`;
93
+ }
94
+
95
+ export function formatMe(me) {
96
+ const lines = ['', chalk.bold(`User: ${me.name}`), ` Email: ${me.email}`, ` ID: ${me.gid}`, '', ' Workspaces:'];
97
+ for (const ws of me.workspaces || []) {
98
+ lines.push(` - ${ws.name} (id: ${ws.gid})`);
99
+ }
100
+ lines.push('');
101
+ return lines.join('\n');
102
+ }
103
+
104
+ export function formatSearchResults(results, type) {
105
+ const lines = [''];
106
+ if (results.length === 0) {
107
+ lines.push(chalk.dim(' No results.'), '');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ lines.push(` Results (${results.length}):`);
112
+ for (const item of results) {
113
+ if (type === 'task') {
114
+ lines.push(taskLine(item, [
115
+ item.memberships?.[0]?.project?.name,
116
+ item.parent?.name && `subtask of: ${item.parent.name}`,
117
+ item.assignee?.name,
118
+ ]));
119
+ } else if (type === 'project') {
120
+ const archived = item.archived ? chalk.dim(' (archived)') : '';
121
+ lines.push(` - ${item.name} (id: ${item.gid})${archived}`);
122
+ } else {
123
+ lines.push(` - ${item.name} (id: ${item.gid})`);
124
+ }
125
+ }
126
+ lines.push('');
127
+ return lines.join('\n');
128
+ }
129
+
130
+ export function formatProjects(projects) {
131
+ const lines = [''];
132
+ if (projects.length === 0) {
133
+ lines.push(chalk.dim(' No projects found.'), '');
134
+ return lines.join('\n');
135
+ }
136
+ lines.push(` Projects (${projects.length}):`);
137
+ for (const p of projects) {
138
+ const owner = p.owner?.name ? chalk.dim(` - ${p.owner.name}`) : '';
139
+ const archived = p.archived ? chalk.dim(' (archived)') : '';
140
+ lines.push(` - ${p.name} (id: ${p.gid})${owner}${archived}`);
141
+ }
142
+ lines.push('');
143
+ return lines.join('\n');
144
+ }
145
+
146
+ // Tasks grouped by section, in the order the sections appear in the project.
147
+ export function formatProject(project, sections, tasks) {
148
+ const lines = ['', chalk.bold(`Project: ${project.name}`), ` ID: ${project.gid}`];
149
+ if (project.owner?.name) lines.push(` Owner: ${project.owner.name}`);
150
+ if (project.permalink_url) lines.push(` URL: ${project.permalink_url}`);
151
+
152
+ const bySection = new Map(sections.map((s) => [s.name, []]));
153
+ const noSection = [];
154
+ for (const task of tasks) {
155
+ const membership = task.memberships?.find((m) => m.project?.gid === project.gid) || task.memberships?.[0];
156
+ const sectionName = membership?.section?.name;
157
+ if (sectionName && bySection.has(sectionName)) {
158
+ bySection.get(sectionName).push(task);
159
+ } else {
160
+ noSection.push(task);
161
+ }
162
+ }
163
+ if (noSection.length > 0) bySection.set('(no section)', noSection);
164
+
165
+ for (const [name, sectionTasks] of bySection) {
166
+ if (sectionTasks.length === 0) continue;
167
+ lines.push('', ` ${chalk.bold(name)} (${sectionTasks.length})`);
168
+ for (const task of sectionTasks) {
169
+ lines.push(taskLine(task, [task.assignee?.name, task.due_on && `due ${task.due_on}`]));
170
+ }
171
+ }
172
+
173
+ if (tasks.length === 0) lines.push('', chalk.dim(' No tasks found.'));
174
+ lines.push('');
175
+ return lines.join('\n');
176
+ }
177
+
178
+ export function formatMyTasks(tasks) {
179
+ const lines = [''];
180
+ if (tasks.length === 0) {
181
+ lines.push(chalk.dim(' No tasks assigned to you.'), '');
182
+ return lines.join('\n');
183
+ }
184
+ // Tasks with a due date first, earliest first; then the rest in Asana's order.
185
+ const sorted = [...tasks].sort((a, b) => {
186
+ if (a.due_on && b.due_on) return a.due_on.localeCompare(b.due_on);
187
+ if (a.due_on) return -1;
188
+ if (b.due_on) return 1;
189
+ return 0;
190
+ });
191
+ lines.push(` My tasks (${tasks.length}):`);
192
+ for (const task of sorted) {
193
+ lines.push(taskLine(task, [task.memberships?.[0]?.project?.name, task.due_on && `due ${task.due_on}`]));
194
+ }
195
+ lines.push('');
196
+ return lines.join('\n');
197
+ }
198
+
199
+ export function formatComments(comments) {
200
+ const lines = [''];
201
+ if (comments.length === 0) {
202
+ lines.push(chalk.dim(' No comments.'), '');
203
+ return lines.join('\n');
204
+ }
205
+ lines.push(` Comments (${comments.length}):`);
206
+ for (const c of comments) {
207
+ const when = c.created_at ? c.created_at.slice(0, 16).replace('T', ' ') : '';
208
+ lines.push('', ` ${chalk.bold(c.created_by?.name || 'Unknown')} ${chalk.dim(`${when} UTC (id: ${c.gid})`)}`);
209
+ for (const line of (c.text || '').split('\n')) {
210
+ lines.push(` ${line}`);
211
+ }
212
+ }
213
+ lines.push('');
214
+ return lines.join('\n');
215
+ }
216
+
65
217
  export function formatSuccess(message) {
66
218
  return `\n${chalk.green('OK')} ${message}\n`;
67
219
  }
package/src/utils.js CHANGED
@@ -39,3 +39,100 @@ export function parseTaskId(input) {
39
39
  throw err;
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * Extracts the project ID from an Asana URL, or returns the ID as-is.
45
+ *
46
+ * Supported formats:
47
+ * - https://app.asana.com/0/<project-id>/list (or /board, /<task-id>, ...)
48
+ * - https://app.asana.com/1/<workspace-id>/project/<project-id>[/...]
49
+ * - Plain numeric ID
50
+ */
51
+ export function parseProjectId(input) {
52
+ if (/^\d+$/.test(input)) {
53
+ return input;
54
+ }
55
+
56
+ let url;
57
+ try {
58
+ url = new URL(input);
59
+ } catch {
60
+ throw new Error(`Invalid input (not a URL or numeric ID): ${input}`);
61
+ }
62
+ if (!url.hostname.includes('asana.com')) {
63
+ throw new Error(`URL is not an Asana URL: ${input}`);
64
+ }
65
+
66
+ const parts = url.pathname.split('/').filter(Boolean);
67
+
68
+ const projectIndex = parts.indexOf('project');
69
+ if (projectIndex !== -1 && /^\d+$/.test(parts[projectIndex + 1] || '')) {
70
+ return parts[projectIndex + 1];
71
+ }
72
+
73
+ if (parts[0] === '0' && /^\d+$/.test(parts[1] || '')) {
74
+ return parts[1];
75
+ }
76
+
77
+ throw new Error(`Could not extract a project ID from: ${input}`);
78
+ }
79
+
80
+ /**
81
+ * Converts the literal "\n" typed in a shell argument into a real line break.
82
+ */
83
+ export function unescapeNewlines(text) {
84
+ return text.replace(/\\n/g, '\n');
85
+ }
86
+
87
+ /**
88
+ * Builds the Asana task fields shared by create-task, create-subtask and update
89
+ * from the command options. Only the options that were passed are included.
90
+ */
91
+ export function buildTaskFields(options) {
92
+ const data = {};
93
+
94
+ if (options.name !== undefined) data.name = options.name;
95
+ if (options.notes !== undefined) data.notes = unescapeNewlines(options.notes);
96
+
97
+ if (options.assignee !== undefined) {
98
+ data.assignee = options.assignee === 'none' ? null : options.assignee;
99
+ }
100
+
101
+ if (options.due !== undefined) {
102
+ if (options.due === 'none') {
103
+ data.due_on = null;
104
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(options.due)) {
105
+ data.due_on = options.due;
106
+ } else {
107
+ throw new Error(`Invalid --due "${options.due}": use YYYY-MM-DD or "none"`);
108
+ }
109
+ }
110
+
111
+ return data;
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
+ }