asana-cli-skill 1.0.0 → 1.1.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,80 @@ 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`.
141
+
142
+ ```bash
143
+ asana-cli create-subtask 5678 -n "Implement toast component" --assignee me --due 2026-10-20
144
+ ```
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.
123
155
 
124
156
  ```bash
125
- asana-cli create-subtask 5678 -n "Implement toast component"
157
+ asana-cli search "payment gateway"
158
+ asana-cli search "Marketing" --type project
126
159
  ```
127
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`.
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
+ ## Workspace
211
+
212
+ `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).
213
+
128
214
  ## Accepted URL formats
129
215
 
130
216
  The CLI accepts any of these:
@@ -134,6 +220,8 @@ The CLI accepts any of these:
134
220
  - `https://app.asana.com/1/<workspace-id>/project/<project-id>/task/<task-id>`
135
221
  - Plain numeric ID (e.g. `1234567890`)
136
222
 
223
+ 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.
224
+
137
225
  ## The Claude Code skill
138
226
 
139
227
  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 +242,10 @@ cp -r "$(npm root -g)/asana-cli-skill/skills/asana" ~/.claude/skills/asana
154
242
 
155
243
  Then share any Asana task link in a Claude Code conversation, or use `/asana`.
156
244
 
245
+ ### Other AI coding agents
246
+
247
+ 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).
248
+
157
249
  ## Project structure
158
250
 
159
251
  ```
@@ -186,6 +278,10 @@ asana-cli-skill/
186
278
  - `dotenv` for configuration
187
279
  - Native `fetch` (Node 18+)
188
280
 
281
+ ## Contributing
282
+
283
+ 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).
284
+
189
285
  ## License
190
286
 
191
287
  [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,16 @@ 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';
15
26
  import { formatError } from '../src/formatter.js';
16
27
 
17
28
  // Load .env from the current directory first, then from ~/.asana-cli/
@@ -30,12 +41,16 @@ function collectMultiple(value, prev) {
30
41
  return prev.concat([value]);
31
42
  }
32
43
 
44
+ const pkg = JSON.parse(
45
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
46
+ );
47
+
33
48
  const program = new Command();
34
49
 
35
50
  program
36
51
  .name('asana-cli')
37
52
  .description('Minimal CLI for Asana task management')
38
- .version('1.0.0');
53
+ .version(pkg.version);
39
54
 
40
55
  program
41
56
  .command('init')
@@ -83,10 +98,118 @@ program
83
98
  .description('Create a subtask under a task')
84
99
  .argument('<parent-id>', 'Parent task ID')
85
100
  .requiredOption('-n, --name <name>', 'Subtask name')
101
+ .option('--notes <text>', 'Description')
102
+ .option('--assignee <who>', '"me", a user ID or an email')
103
+ .option('--due <date>', 'Due date (YYYY-MM-DD)')
86
104
  .action(async (parentId, options) => {
87
105
  await createSubtaskCommand(parentId, options, getClient());
88
106
  });
89
107
 
108
+ program
109
+ .command('me')
110
+ .description('Show the authenticated user and their workspaces')
111
+ .action(async () => {
112
+ await meCommand(getClient());
113
+ });
114
+
115
+ program
116
+ .command('search')
117
+ .description('Search tasks (or projects, tags, users) by name')
118
+ .argument('<query>', 'Text to search for')
119
+ .option('-t, --type <type>', 'task, project, portfolio, tag or user', 'task')
120
+ .option('-l, --limit <n>', 'Maximum number of results (1-100)', '20')
121
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
122
+ .action(async (query, options) => {
123
+ await searchCommand(query, options, getClient());
124
+ });
125
+
126
+ program
127
+ .command('projects')
128
+ .description('List the projects of a workspace')
129
+ .option('-f, --filter <text>', 'Only projects whose name contains this text')
130
+ .option('--archived', 'List archived projects instead of active ones')
131
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
132
+ .action(async (options) => {
133
+ await projectsCommand(options, getClient());
134
+ });
135
+
136
+ program
137
+ .command('project')
138
+ .description('Read a project: its open tasks grouped by section')
139
+ .argument('<url-or-id>', 'Asana project URL or ID')
140
+ .option('-s, --section <name>', 'Only sections whose name contains this text')
141
+ .option('--completed', 'Include completed tasks')
142
+ .action(async (urlOrId, options) => {
143
+ await projectCommand(urlOrId, options, getClient());
144
+ });
145
+
146
+ program
147
+ .command('my-tasks')
148
+ .description('List the open tasks assigned to you, earliest due date first')
149
+ .option('--completed', 'Include completed tasks')
150
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
151
+ .action(async (options) => {
152
+ await myTasksCommand(options, getClient());
153
+ });
154
+
155
+ program
156
+ .command('comments')
157
+ .description('List the comments of a task')
158
+ .argument('<url-or-id>', 'Asana URL or task ID')
159
+ .option('--last <n>', 'Only the last N comments')
160
+ .action(async (urlOrId, options) => {
161
+ await commentsCommand(urlOrId, options, getClient());
162
+ });
163
+
164
+ program
165
+ .command('create-task')
166
+ .description('Create a task in a project (or a subtask with --parent)')
167
+ .requiredOption('-n, --name <name>', 'Task name')
168
+ .option('-p, --project <url-or-id>', 'Project URL or ID')
169
+ .option('-s, --section <name-or-id>', 'Section of the project')
170
+ .option('--parent <url-or-id>', 'Create as a subtask of this task')
171
+ .option('--notes <text>', 'Description')
172
+ .option('--assignee <who>', '"me", a user ID or an email')
173
+ .option('--due <date>', 'Due date (YYYY-MM-DD)')
174
+ .option('-w, --workspace <id>', 'Workspace ID (defaults to ASANA_WORKSPACE or your only workspace)')
175
+ .action(async (options) => {
176
+ await createTaskCommand(options, getClient());
177
+ });
178
+
179
+ program
180
+ .command('update')
181
+ .description('Update a task: name, description, assignee, due date, completion')
182
+ .argument('<url-or-id>', 'Asana URL or task ID')
183
+ .option('-n, --name <name>', 'New name')
184
+ .option('--notes <text>', 'Replace the description')
185
+ .option('--append-notes <text>', 'Append to the description')
186
+ .option('--assignee <who>', '"me", a user ID, an email, or "none" to unassign')
187
+ .option('--due <date>', 'Due date (YYYY-MM-DD), or "none" to clear')
188
+ .option('--complete', 'Mark as completed (without a comment)')
189
+ .option('--reopen', 'Mark as not completed')
190
+ .action(async (urlOrId, options) => {
191
+ await updateCommand(urlOrId, options, getClient());
192
+ });
193
+
194
+ program
195
+ .command('move')
196
+ .description('Make a task a subtask of another, or reorder it among its sibling subtasks')
197
+ .argument('<url-or-id>', 'Asana URL or task ID')
198
+ .option('--parent <url-or-id>', 'New parent task (defaults to the current parent)')
199
+ .option('--before <id>', 'Place it right before this sibling subtask')
200
+ .option('--after <id>', 'Place it right after this sibling subtask')
201
+ .action(async (urlOrId, options) => {
202
+ await moveCommand(urlOrId, options, getClient());
203
+ });
204
+
205
+ program
206
+ .command('delete-comment')
207
+ .description('Delete a comment (story ID from the comments command)')
208
+ .argument('<comment-id>', 'Comment ID')
209
+ .action(async (commentId) => {
210
+ await deleteCommentCommand(commentId, getClient());
211
+ });
212
+
90
213
  program.parseAsync().catch((err) => {
91
214
  console.error(formatError(err.message));
92
215
  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.1.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,17 @@ 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]` | Update a task |
148
+ | `asana-cli move <id> --after <sibling-id>` | Reorder subtasks or change the parent |
149
+ | `asana-cli delete-comment <comment-id>` | Delete a comment (ID from `comments`) |
150
+ | `asana-cli me` | Authenticated user and workspaces |
141
151
 
142
152
  ## Important Rules
143
153
 
package/src/client.js CHANGED
@@ -34,12 +34,139 @@ export class AsanaClient {
34
34
  return json.data;
35
35
  }
36
36
 
37
+ // Follows Asana's offset pagination and returns every item of a collection.
38
+ async requestAll(path) {
39
+ const items = [];
40
+ const separator = path.includes('?') ? '&' : '?';
41
+ let offset = null;
42
+ do {
43
+ const pagePath = `${path}${separator}limit=100${offset ? `&offset=${offset}` : ''}`;
44
+ const response = await fetch(`${BASE_URL}${pagePath}`, {
45
+ headers: { 'Authorization': `Bearer ${this.token}` },
46
+ });
47
+ if (!response.ok) {
48
+ const body = await response.json().catch(() => ({}));
49
+ const msg = body?.errors?.[0]?.message || response.statusText;
50
+ throw new Error(`Asana API error (${response.status}): ${msg}`);
51
+ }
52
+ const json = await response.json();
53
+ items.push(...json.data);
54
+ offset = json.next_page?.offset || null;
55
+ } while (offset);
56
+ return items;
57
+ }
58
+
59
+ async getMe() {
60
+ return this.request('/users/me?opt_fields=name,email,workspaces.name');
61
+ }
62
+
63
+ // Workspace used by search, projects and my-tasks: --workspace, then ASANA_WORKSPACE,
64
+ // then the only workspace of the user.
65
+ async resolveWorkspace(explicit) {
66
+ const chosen = explicit || process.env.ASANA_WORKSPACE;
67
+ if (chosen) return chosen;
68
+
69
+ const me = await this.getMe();
70
+ const workspaces = me.workspaces || [];
71
+ if (workspaces.length === 1) return workspaces[0].gid;
72
+
73
+ const list = workspaces.map((w) => `${w.name} (${w.gid})`).join(', ');
74
+ throw new Error(
75
+ `You belong to ${workspaces.length} workspaces: ${list}.\n` +
76
+ 'Pass --workspace <id> or set ASANA_WORKSPACE.'
77
+ );
78
+ }
79
+
37
80
  async getTask(taskId) {
38
81
  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`
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`
83
+ );
84
+ }
85
+
86
+ async getComments(taskId) {
87
+ const stories = await this.requestAll(
88
+ `/tasks/${taskId}/stories?opt_fields=type,resource_subtype,text,created_at,created_by.name`
89
+ );
90
+ return stories.filter((s) => s.type === 'comment');
91
+ }
92
+
93
+ async search(workspaceId, query, { type = 'task', count = 20 } = {}) {
94
+ const fields = {
95
+ task: 'name,completed,assignee.name,memberships.project.name,parent.name',
96
+ project: 'name,archived,owner.name',
97
+ }[type] || 'name';
98
+ const params = new URLSearchParams({
99
+ resource_type: type,
100
+ query,
101
+ count: String(count),
102
+ opt_fields: fields,
103
+ });
104
+ return this.request(`/workspaces/${workspaceId}/typeahead?${params}`);
105
+ }
106
+
107
+ async getProjects(workspaceId, { archived = false } = {}) {
108
+ return this.requestAll(
109
+ `/projects?workspace=${workspaceId}&archived=${archived}&opt_fields=name,archived,owner.name`
110
+ );
111
+ }
112
+
113
+ async getProject(projectId) {
114
+ return this.request(
115
+ `/projects/${projectId}?opt_fields=name,notes,archived,owner.name,permalink_url,workspace.name`
40
116
  );
41
117
  }
42
118
 
119
+ async getSections(projectId) {
120
+ return this.requestAll(`/projects/${projectId}/sections?opt_fields=name`);
121
+ }
122
+
123
+ // Without completed, only open tasks (completed_since=now is Asana's way to say so).
124
+ async getProjectTasks(projectId, { completed = false } = {}) {
125
+ const since = completed ? '' : '&completed_since=now';
126
+ return this.requestAll(
127
+ `/projects/${projectId}/tasks?opt_fields=name,completed,due_on,assignee.name,memberships.project.gid,memberships.section.name${since}`
128
+ );
129
+ }
130
+
131
+ async getMyTasks(workspaceId, { completed = false } = {}) {
132
+ const since = completed ? '' : '&completed_since=now';
133
+ return this.requestAll(
134
+ `/tasks?assignee=me&workspace=${workspaceId}&opt_fields=name,completed,due_on,memberships.project.name${since}`
135
+ );
136
+ }
137
+
138
+ async createTask(data) {
139
+ return this.request('/tasks', {
140
+ method: 'POST',
141
+ body: JSON.stringify({ data }),
142
+ });
143
+ }
144
+
145
+ async updateTask(taskId, data) {
146
+ return this.request(`/tasks/${taskId}`, {
147
+ method: 'PUT',
148
+ body: JSON.stringify({ data }),
149
+ });
150
+ }
151
+
152
+ // Moves a task under a parent (or reorders it among its siblings with insert_before/insert_after).
153
+ async setParent(taskId, parentId, { insertBefore, insertAfter } = {}) {
154
+ const data = { parent: parentId };
155
+ if (insertBefore) data.insert_before = insertBefore;
156
+ if (insertAfter) data.insert_after = insertAfter;
157
+ return this.request(`/tasks/${taskId}/setParent`, {
158
+ method: 'POST',
159
+ body: JSON.stringify({ data }),
160
+ });
161
+ }
162
+
163
+ async addTaskToSection(sectionId, taskId) {
164
+ return this.request(`/sections/${sectionId}/addTask`, {
165
+ method: 'POST',
166
+ body: JSON.stringify({ data: { task: taskId } }),
167
+ });
168
+ }
169
+
43
170
  async getSubtasks(taskId) {
44
171
  return this.request(
45
172
  `/tasks/${taskId}/subtasks?opt_fields=name,completed`
@@ -61,6 +188,14 @@ export class AsanaClient {
61
188
  });
62
189
  }
63
190
 
191
+ async getStory(storyId) {
192
+ return this.request(`/stories/${storyId}?opt_fields=type,text,target.name,created_by.name`);
193
+ }
194
+
195
+ async deleteStory(storyId) {
196
+ return this.request(`/stories/${storyId}`, { method: 'DELETE' });
197
+ }
198
+
64
199
  async addHtmlComment(taskId, htmlText) {
65
200
  return this.request(`/tasks/${taskId}/stories`, {
66
201
  method: 'POST',
@@ -139,10 +274,10 @@ export class AsanaClient {
139
274
  return json.data;
140
275
  }
141
276
 
142
- async createSubtask(parentId, name) {
277
+ async createSubtask(parentId, name, extra = {}) {
143
278
  return this.request(`/tasks/${parentId}/subtasks`, {
144
279
  method: 'POST',
145
- body: JSON.stringify({ data: { name } }),
280
+ body: JSON.stringify({ data: { name, ...extra } }),
146
281
  });
147
282
  }
148
283
  }
@@ -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,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,31 @@
1
+ import { parseTaskId, buildTaskFields, unescapeNewlines } 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
+ 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');
26
+ }
27
+
28
+ await client.updateTask(taskId, data);
29
+ const task = await client.getTask(taskId);
30
+ console.log(formatSuccess(`Task "${task.name}" updated: ${Object.keys(data).join(', ')}`));
31
+ }
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,74 @@ 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
+ }