asana-cli-skill 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juliano Giacomeli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # asana-cli
2
+
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.
4
+
5
+ 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
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g asana-cli-skill
11
+ ```
12
+
13
+ This makes the `asana-cli` command available globally.
14
+
15
+ Or from source:
16
+
17
+ ```bash
18
+ git clone https://github.com/giacomeli/asana-cli-skill.git
19
+ cd asana-cli-skill
20
+ npm install
21
+ npm link
22
+ ```
23
+
24
+ Requires Node.js 18+ (native `fetch`).
25
+
26
+ ## Configuration
27
+
28
+ ### Option 1: Interactive command
29
+
30
+ ```bash
31
+ asana-cli init
32
+ ```
33
+
34
+ Prompts for your Personal Access Token and saves it to `~/.asana-cli/.env`.
35
+
36
+ ### Option 2: Manual
37
+
38
+ Create `~/.asana-cli/.env` (or a `.env` in your working directory):
39
+
40
+ ```
41
+ ASANA_TOKEN=your-token-here
42
+ ```
43
+
44
+ You can generate a token at: Asana > Settings > Apps > Developer > Personal Access Tokens.
45
+
46
+ ### Optional: progress custom field
47
+
48
+ By default, `asana-cli complete` marks the task as completed (the Asana checkbox). If your workspace tracks progress with an enum custom field instead (common on board-style projects), configure it:
49
+
50
+ ```
51
+ ASANA_PROGRESS_FIELD=Task Progress
52
+ ASANA_PROGRESS_VALUE=Done
53
+ ```
54
+
55
+ With these set, `complete` sets the custom field to that value instead of checking the task off. Use `--close` to also check it off.
56
+
57
+ ## Commands
58
+
59
+ ### `asana-cli task <url-or-id>`
60
+
61
+ Reads a full task: title, description, project, assignee, and subtasks.
62
+
63
+ ```bash
64
+ asana-cli task https://app.asana.com/0/1234/5678
65
+ ```
66
+
67
+ Output:
68
+
69
+ ```
70
+ Task: Implement notification system
71
+ Status: In progress
72
+ Project: Sprint 42
73
+ Assignee: Juliano
74
+ ID: 5678
75
+
76
+ Description:
77
+ Build a push notification system...
78
+
79
+ Subtasks (2):
80
+ [ ] Create schema (id: 9012)
81
+ [x] Define endpoints (id: 9013)
82
+ ```
83
+
84
+ ### `asana-cli subtasks <url-or-id>`
85
+
86
+ Lists only the subtasks of a task.
87
+
88
+ ```bash
89
+ asana-cli subtasks 5678
90
+ ```
91
+
92
+ ### `asana-cli complete <task-id> -m "message"`
93
+
94
+ Adds a comment and marks the task as done (completed checkbox, or the configured progress custom field).
95
+
96
+ ```bash
97
+ asana-cli complete 9012 -m "Done\n\nSchema created with Zod validation\n\nCommit: abc1234\nBranch: feature/notifications"
98
+ ```
99
+
100
+ Literal `\n` sequences are converted to real line breaks automatically.
101
+
102
+ Flags:
103
+
104
+ - `--close` — also mark the task as completed (checkbox) and complete any pending subtasks. Useful when a progress custom field is configured and you want both.
105
+
106
+ ### `asana-cli comment <task-id> -m "message"`
107
+
108
+ Adds a comment without changing the task status.
109
+
110
+ ```bash
111
+ asana-cli comment 5678 -m "Task completed\n\nCommit summary..."
112
+ ```
113
+
114
+ Attach files (repeatable, images are embedded inline):
115
+
116
+ ```bash
117
+ asana-cli comment 5678 -m "Screenshots attached" -a screenshot1.png -a screenshot2.png
118
+ ```
119
+
120
+ ### `asana-cli create-subtask <parent-id> -n "name"`
121
+
122
+ Creates a new subtask under a task.
123
+
124
+ ```bash
125
+ asana-cli create-subtask 5678 -n "Implement toast component"
126
+ ```
127
+
128
+ ## Accepted URL formats
129
+
130
+ The CLI accepts any of these:
131
+
132
+ - `https://app.asana.com/0/<project-id>/<task-id>`
133
+ - `https://app.asana.com/0/<project-id>/<task-id>/f`
134
+ - `https://app.asana.com/1/<workspace-id>/project/<project-id>/task/<task-id>`
135
+ - Plain numeric ID (e.g. `1234567890`)
136
+
137
+ ## The Claude Code skill
138
+
139
+ 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.
140
+
141
+ To install it, copy (or symlink) the skill into your Claude Code skills directory.
142
+
143
+ From a cloned repo:
144
+
145
+ ```bash
146
+ cp -r skills/asana ~/.claude/skills/asana
147
+ ```
148
+
149
+ From the npm global install (the package ships the skill):
150
+
151
+ ```bash
152
+ cp -r "$(npm root -g)/asana-cli-skill/skills/asana" ~/.claude/skills/asana
153
+ ```
154
+
155
+ Then share any Asana task link in a Claude Code conversation, or use `/asana`.
156
+
157
+ ## Project structure
158
+
159
+ ```
160
+ asana-cli-skill/
161
+ ├── bin/
162
+ │ └── asana-cli.js # Entry point, command registration
163
+ ├── src/
164
+ │ ├── client.js # HTTP client for the Asana API
165
+ │ ├── formatter.js # Human-readable terminal output
166
+ │ ├── utils.js # URL parsing, helpers
167
+ │ └── commands/
168
+ │ ├── init.js # Token setup
169
+ │ ├── task.js # Read a task
170
+ │ ├── subtasks.js # List subtasks
171
+ │ ├── complete.js # Complete + comment
172
+ │ ├── comment.js # Comment
173
+ │ └── create-subtask.js # Create a subtask
174
+ ├── skills/
175
+ │ └── asana/
176
+ │ └── SKILL.md # Claude Code skill
177
+ ├── package.json
178
+ └── .gitignore
179
+ ```
180
+
181
+ ## Stack
182
+
183
+ - Node.js (native ESM, no build step)
184
+ - `commander` for argument parsing
185
+ - `chalk` for terminal colors
186
+ - `dotenv` for configuration
187
+ - Native `fetch` (Node 18+)
188
+
189
+ ## License
190
+
191
+ [MIT](LICENSE)
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { existsSync } from 'fs';
7
+ import { Command } from 'commander';
8
+ import { AsanaClient } from '../src/client.js';
9
+ import { initCommand } from '../src/commands/init.js';
10
+ import { taskCommand } from '../src/commands/task.js';
11
+ import { subtasksCommand } from '../src/commands/subtasks.js';
12
+ import { completeCommand } from '../src/commands/complete.js';
13
+ import { commentCommand } from '../src/commands/comment.js';
14
+ import { createSubtaskCommand } from '../src/commands/create-subtask.js';
15
+ import { formatError } from '../src/formatter.js';
16
+
17
+ // Load .env from the current directory first, then from ~/.asana-cli/
18
+ config({ path: join(process.cwd(), '.env') });
19
+
20
+ const globalEnv = join(homedir(), '.asana-cli', '.env');
21
+ if (existsSync(globalEnv)) {
22
+ config({ path: globalEnv });
23
+ }
24
+
25
+ function getClient() {
26
+ return new AsanaClient(process.env.ASANA_TOKEN);
27
+ }
28
+
29
+ function collectMultiple(value, prev) {
30
+ return prev.concat([value]);
31
+ }
32
+
33
+ const program = new Command();
34
+
35
+ program
36
+ .name('asana-cli')
37
+ .description('Minimal CLI for Asana task management')
38
+ .version('1.0.0');
39
+
40
+ program
41
+ .command('init')
42
+ .description('Configure your Asana Personal Access Token')
43
+ .action(initCommand);
44
+
45
+ program
46
+ .command('task')
47
+ .description('Read a full task with its subtasks')
48
+ .argument('<url-or-id>', 'Asana URL or task ID')
49
+ .action(async (urlOrId) => {
50
+ await taskCommand(urlOrId, getClient());
51
+ });
52
+
53
+ program
54
+ .command('subtasks')
55
+ .description('List the subtasks of a task')
56
+ .argument('<url-or-id>', 'Asana URL or task ID')
57
+ .action(async (urlOrId) => {
58
+ await subtasksCommand(urlOrId, getClient());
59
+ });
60
+
61
+ program
62
+ .command('complete')
63
+ .description('Mark a task as done with a comment (custom progress field or completed checkbox)')
64
+ .argument('<task-id>', 'Task ID')
65
+ .requiredOption('-m, --message <text>', 'Comment message')
66
+ .option('--close', 'Also mark the task as completed (checkbox), including pending subtasks')
67
+ .action(async (taskId, options) => {
68
+ await completeCommand(taskId, options, getClient());
69
+ });
70
+
71
+ program
72
+ .command('comment')
73
+ .description('Add a comment and/or attachments to a task')
74
+ .argument('<task-id>', 'Task ID')
75
+ .option('-m, --message <text>', 'Comment message')
76
+ .option('-a, --attachment <path>', 'File to attach (repeatable)', collectMultiple, [])
77
+ .action(async (taskId, options) => {
78
+ await commentCommand(taskId, options, getClient());
79
+ });
80
+
81
+ program
82
+ .command('create-subtask')
83
+ .description('Create a subtask under a task')
84
+ .argument('<parent-id>', 'Parent task ID')
85
+ .requiredOption('-n, --name <name>', 'Subtask name')
86
+ .action(async (parentId, options) => {
87
+ await createSubtaskCommand(parentId, options, getClient());
88
+ });
89
+
90
+ program.parseAsync().catch((err) => {
91
+ console.error(formatError(err.message));
92
+ process.exit(1);
93
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "asana-cli-skill",
3
+ "version": "1.0.0",
4
+ "description": "Minimal CLI for Asana task management, built to power a Claude Code skill",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Juliano Giacomeli",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/giacomeli/asana-cli-skill.git"
11
+ },
12
+ "homepage": "https://github.com/giacomeli/asana-cli-skill#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/giacomeli/asana-cli-skill/issues"
15
+ },
16
+ "bin": {
17
+ "asana-cli": "./bin/asana-cli.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "skills"
23
+ ],
24
+ "scripts": {
25
+ "start": "node bin/asana-cli.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "asana",
32
+ "cli",
33
+ "claude-code",
34
+ "skill",
35
+ "task-management"
36
+ ],
37
+ "dependencies": {
38
+ "chalk": "^5.4.1",
39
+ "commander": "^13.1.0",
40
+ "dotenv": "^16.5.0"
41
+ }
42
+ }
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: asana
3
+ description: >
4
+ Asana integration for task management during development.
5
+ Use this skill whenever the user shares an Asana link (app.asana.com),
6
+ mentions "asana", "asana task", "pick up this task", or uses /asana.
7
+ The skill reads the task, creates an implementation plan based on its
8
+ subtasks, and updates Asana automatically as the work progresses
9
+ (comments + status). It MUST be used whenever an Asana link appears in
10
+ the conversation or the user mentions anything related to Asana tasks.
11
+ ---
12
+
13
+ # Skill: Asana Task Integration
14
+
15
+ Orchestrates the development workflow from an Asana task: reads the task, generates a plan, executes it, and updates Asana with progress and commits.
16
+
17
+ ## Prerequisite
18
+
19
+ The `asana-cli` command must be installed globally.
20
+ If `asana-cli` is not found, instruct the user:
21
+ ```
22
+ npm install -g asana-cli-skill
23
+ asana-cli init
24
+ ```
25
+
26
+ ## Full Workflow
27
+
28
+ ### 1. Read the Task
29
+
30
+ When you receive a link or a mention of an Asana task:
31
+
32
+ ```bash
33
+ asana-cli task <url>
34
+ ```
35
+
36
+ This returns the title, description, project, assignee, and subtasks. Use this information as context for the plan.
37
+
38
+ ### 2. Analyze and Plan
39
+
40
+ Based on the task you read:
41
+
42
+ - **If the task already has subtasks:** use them as the plan steps. Each subtask becomes a step.
43
+ - **If the task has no subtasks:** analyze the title and description to derive steps. Create the corresponding subtasks in Asana:
44
+ ```bash
45
+ asana-cli create-subtask <task-id> --name "Step name"
46
+ ```
47
+ - **If extra steps are needed** beyond the existing subtasks: create them in Asana as well.
48
+
49
+ Generate the plan in the project's standard format (if a `spec-plan` or `writing-plans` skill is available, use it). The plan must include an Asana tracking section:
50
+
51
+ ```markdown
52
+ ## Asana Tracking
53
+
54
+ Task: [Task Name](asana-url)
55
+
56
+ | Step | Subtask | Asana ID | Status | Commit |
57
+ |------|---------|----------|--------|--------|
58
+ | 1 | Step name | 1234567890 | Pending | - |
59
+ | 2 | Step name | 1234567891 | Pending | - |
60
+ ```
61
+
62
+ Save the plan in `specs/plans/` (or the project's standard location).
63
+
64
+ ### 3. Execute the Plan
65
+
66
+ Execute the plan step by step. When each step is finished:
67
+
68
+ #### a. Commit
69
+
70
+ Commit normally, following the project's conventions.
71
+
72
+ #### b. Capture the commit hash
73
+
74
+ ```bash
75
+ git log -1 --format="%h"
76
+ ```
77
+
78
+ #### c. Capture the current branch
79
+
80
+ ```bash
81
+ git branch --show-current
82
+ ```
83
+
84
+ #### d. Update Asana
85
+
86
+ Mark the subtask as done with a descriptive comment:
87
+
88
+ ```bash
89
+ asana-cli complete <subtask-id> --message "Done
90
+
91
+ <short description of what was implemented>
92
+
93
+ Commit: <hash>
94
+ Branch: <branch>"
95
+ ```
96
+
97
+ #### e. Update the plan
98
+
99
+ Update the tracking table in the plan with the status and commit:
100
+
101
+ ```markdown
102
+ | 1 | Step name | 1234567890 | Done | abc1234 |
103
+ ```
104
+
105
+ Also include the output of `asana-cli complete` in the plan for documentation.
106
+
107
+ ### 4. Finish
108
+
109
+ Once all steps are done:
110
+
111
+ #### a. Comment on the parent task
112
+
113
+ ```bash
114
+ asana-cli comment <task-id> --message "Task completed
115
+
116
+ Summary:
117
+ - Step 1: <name> -> commit <hash>
118
+ - Step 2: <name> -> commit <hash>
119
+ - Step 3: <name> -> commit <hash>
120
+
121
+ Branch: <branch>
122
+ Total commits: <N>"
123
+ ```
124
+
125
+ #### b. Do NOT mark the parent task as complete
126
+
127
+ The parent task must be closed manually by the user or tech lead. Only subtasks are completed automatically.
128
+
129
+ ## Available Commands
130
+
131
+ Quick reference for `asana-cli`:
132
+
133
+ | Command | Purpose |
134
+ |---------|---------|
135
+ | `asana-cli task <url>` | Read a task with its subtasks |
136
+ | `asana-cli subtasks <url-or-id>` | List subtasks only |
137
+ | `asana-cli complete <id> -m "msg"` | Comment + mark as done |
138
+ | `asana-cli comment <id> -m "msg"` | Comment only |
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 |
141
+
142
+ ## Important Rules
143
+
144
+ 1. **Always read the task before acting.** Never assume its content — run `asana-cli task`.
145
+ 2. **Never skip the Asana update.** After each completed step, Asana MUST be updated.
146
+ 3. **Never mark the parent task as complete.** Only subtasks are completed automatically.
147
+ 4. **CLI output goes into the plan.** The output of each `asana-cli complete` must be recorded in the plan.
148
+ 5. **IDs are required.** Always use the numeric Asana ID with `complete`, `comment`, and `create-subtask`. The ID is shown in the output of `asana-cli task`.
package/src/client.js ADDED
@@ -0,0 +1,148 @@
1
+ import { basename } from 'path';
2
+
3
+ const BASE_URL = 'https://app.asana.com/api/1.0';
4
+
5
+ export class AsanaClient {
6
+ constructor(token) {
7
+ if (!token) {
8
+ throw new Error(
9
+ 'ASANA_TOKEN is not set.\n' +
10
+ 'Run: asana-cli init'
11
+ );
12
+ }
13
+ this.token = token;
14
+ }
15
+
16
+ async request(path, options = {}) {
17
+ const url = `${BASE_URL}${path}`;
18
+ const response = await fetch(url, {
19
+ ...options,
20
+ headers: {
21
+ 'Authorization': `Bearer ${this.token}`,
22
+ 'Content-Type': 'application/json',
23
+ ...options.headers,
24
+ },
25
+ });
26
+
27
+ if (!response.ok) {
28
+ const body = await response.json().catch(() => ({}));
29
+ const msg = body?.errors?.[0]?.message || response.statusText;
30
+ throw new Error(`Asana API error (${response.status}): ${msg}`);
31
+ }
32
+
33
+ const json = await response.json();
34
+ return json.data;
35
+ }
36
+
37
+ async getTask(taskId) {
38
+ 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`
40
+ );
41
+ }
42
+
43
+ async getSubtasks(taskId) {
44
+ return this.request(
45
+ `/tasks/${taskId}/subtasks?opt_fields=name,completed`
46
+ );
47
+ }
48
+
49
+ async completeTask(taskId) {
50
+ return this.request(`/tasks/${taskId}`, {
51
+ method: 'PUT',
52
+ body: JSON.stringify({ data: { completed: true } }),
53
+ });
54
+ }
55
+
56
+ async addComment(taskId, text) {
57
+ const parsed = text.replace(/\\n/g, '\n');
58
+ return this.request(`/tasks/${taskId}/stories`, {
59
+ method: 'POST',
60
+ body: JSON.stringify({ data: { text: parsed } }),
61
+ });
62
+ }
63
+
64
+ async addHtmlComment(taskId, htmlText) {
65
+ return this.request(`/tasks/${taskId}/stories`, {
66
+ method: 'POST',
67
+ body: JSON.stringify({ data: { html_text: htmlText } }),
68
+ });
69
+ }
70
+
71
+ async setCustomFieldEnum(taskId, fieldName, optionName) {
72
+ const task = await this.getTask(taskId);
73
+ const field = task.custom_fields?.find(
74
+ (f) => f.name.toLowerCase() === fieldName.toLowerCase()
75
+ );
76
+ if (!field) {
77
+ const available = task.custom_fields?.map((f) => f.name).join(', ') || 'none';
78
+ throw new Error(
79
+ `Custom field "${fieldName}" not found. Available fields: ${available}`
80
+ );
81
+ }
82
+
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}`
90
+ );
91
+ }
92
+
93
+ return this.request(`/tasks/${taskId}`, {
94
+ method: 'PUT',
95
+ body: JSON.stringify({
96
+ data: { custom_fields: { [field.gid]: option.gid } },
97
+ }),
98
+ });
99
+ }
100
+
101
+ async uploadAttachment(taskId, filePath) {
102
+ const url = `${BASE_URL}/tasks/${taskId}/attachments`;
103
+ const fileName = basename(filePath);
104
+ const ext = fileName.toLowerCase().slice(fileName.lastIndexOf('.'));
105
+ const mimeTypes = {
106
+ '.png': 'image/png',
107
+ '.jpg': 'image/jpeg',
108
+ '.jpeg': 'image/jpeg',
109
+ '.gif': 'image/gif',
110
+ '.webp': 'image/webp',
111
+ '.bmp': 'image/bmp',
112
+ '.svg': 'image/svg+xml',
113
+ '.pdf': 'application/pdf',
114
+ };
115
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
116
+
117
+ const { readFile } = await import('fs/promises');
118
+ const fileBuffer = await readFile(filePath);
119
+ const blob = new Blob([fileBuffer], { type: mimeType });
120
+
121
+ const form = new FormData();
122
+ form.append('file', blob, fileName);
123
+
124
+ const response = await fetch(url, {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Authorization': `Bearer ${this.token}`,
128
+ },
129
+ body: form,
130
+ });
131
+
132
+ if (!response.ok) {
133
+ const body = await response.json().catch(() => ({}));
134
+ const msg = body?.errors?.[0]?.message || response.statusText;
135
+ throw new Error(`Asana API error uploading attachment (${response.status}): ${msg}`);
136
+ }
137
+
138
+ const json = await response.json();
139
+ return json.data;
140
+ }
141
+
142
+ async createSubtask(parentId, name) {
143
+ return this.request(`/tasks/${parentId}/subtasks`, {
144
+ method: 'POST',
145
+ body: JSON.stringify({ data: { name } }),
146
+ });
147
+ }
148
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync } from 'fs';
2
+ import { formatSuccess, formatError } from '../formatter.js';
3
+
4
+ export async function commentCommand(taskId, options, client) {
5
+ const message = options.message;
6
+ const attachments = options.attachment || [];
7
+
8
+ if (!message && attachments.length === 0) {
9
+ console.error(formatError('Provide --message and/or --attachment'));
10
+ process.exit(1);
11
+ }
12
+
13
+ // Validate files exist
14
+ for (const filePath of attachments) {
15
+ if (!existsSync(filePath)) {
16
+ console.error(formatError(`File not found: ${filePath}`));
17
+ process.exit(1);
18
+ }
19
+ }
20
+
21
+ // Upload attachments and collect GIDs
22
+ const uploaded = [];
23
+ for (const filePath of attachments) {
24
+ const result = await client.uploadAttachment(taskId, filePath);
25
+ uploaded.push({ name: result.name, gid: result.gid });
26
+ }
27
+
28
+ // Build comment
29
+ if (uploaded.length > 0) {
30
+ // Use html_text with inline images via data-asana-gid
31
+ const textPart = message
32
+ ? message.replace(/\\n/g, '\n')
33
+ : '';
34
+ const imgTags = uploaded
35
+ .map((att) => `<img data-asana-gid="${att.gid}"/>`)
36
+ .join('\n');
37
+ const htmlBody = textPart
38
+ ? `<body>${textPart}\n${imgTags}</body>`
39
+ : `<body>${imgTags}</body>`;
40
+ await client.addHtmlComment(taskId, htmlBody);
41
+ } else if (message) {
42
+ await client.addComment(taskId, message);
43
+ }
44
+
45
+ const task = await client.getTask(taskId);
46
+ const parts = [];
47
+ if (message) parts.push(`Comment: "${message}"`);
48
+ if (uploaded.length > 0) parts.push(`Inline attachments: ${uploaded.map((u) => u.name).join(', ')}`);
49
+
50
+ console.log(formatSuccess(`Task "${task.name}" updated\n ${parts.join('\n ')}`));
51
+ }
@@ -0,0 +1,43 @@
1
+ import { formatSuccess } from '../formatter.js';
2
+
3
+ export async function completeCommand(taskId, options, client) {
4
+ const message = options.message;
5
+ if (!message) {
6
+ console.error('Error: --message is required');
7
+ process.exit(1);
8
+ }
9
+
10
+ await client.addComment(taskId, message);
11
+
12
+ // When a progress custom field is configured, set it instead of the
13
+ // completed checkbox (useful for boards driven by an enum field).
14
+ const progressField = process.env.ASANA_PROGRESS_FIELD;
15
+ const progressValue = process.env.ASANA_PROGRESS_VALUE;
16
+ const useProgressField = Boolean(progressField && progressValue);
17
+
18
+ if (useProgressField) {
19
+ await client.setCustomFieldEnum(taskId, progressField, progressValue);
20
+ }
21
+
22
+ if (options.close || !useProgressField) {
23
+ await client.completeTask(taskId);
24
+ }
25
+
26
+ if (options.close) {
27
+ const subtasks = await client.getSubtasks(taskId);
28
+ const pending = subtasks.filter((s) => !s.completed);
29
+ for (const sub of pending) {
30
+ await client.completeTask(sub.gid);
31
+ }
32
+
33
+ if (pending.length > 0) {
34
+ console.log(formatSuccess(`${pending.length} pending subtask(s) also marked as completed`));
35
+ }
36
+ }
37
+
38
+ const task = await client.getTask(taskId);
39
+ const actions = [];
40
+ if (useProgressField) actions.push(`progress set to "${progressValue}"`);
41
+ if (options.close || !useProgressField) actions.push('marked as completed');
42
+ console.log(formatSuccess(`Task "${task.name}" - ${actions.join(' + ')}\n Comment added: "${message}"`));
43
+ }
@@ -0,0 +1,14 @@
1
+ import { formatSuccess } from '../formatter.js';
2
+
3
+ export async function createSubtaskCommand(parentId, options, client) {
4
+ const name = options.name;
5
+ if (!name) {
6
+ console.error('Error: --name is required');
7
+ process.exit(1);
8
+ }
9
+
10
+ const subtask = await client.createSubtask(parentId, name);
11
+ const parent = await client.getTask(parentId);
12
+
13
+ console.log(formatSuccess(`Subtask created: "${name}" (id: ${subtask.gid})\n Parent: ${parent.name}`));
14
+ }
@@ -0,0 +1,42 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { createInterface } from 'readline';
5
+ import { formatSuccess, formatError } from '../formatter.js';
6
+
7
+ const CONFIG_DIR = join(homedir(), '.asana-cli');
8
+ const ENV_PATH = join(CONFIG_DIR, '.env');
9
+
10
+ function prompt(question) {
11
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ return new Promise((resolve) => {
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolve(answer.trim());
16
+ });
17
+ });
18
+ }
19
+
20
+ export async function initCommand() {
21
+ console.log('\nAsana CLI setup\n');
22
+
23
+ if (existsSync(ENV_PATH)) {
24
+ const overwrite = await prompt(' A configuration already exists. Overwrite? (y/N): ');
25
+ if (overwrite.toLowerCase() !== 'y') {
26
+ console.log(formatSuccess('Existing configuration kept.'));
27
+ return;
28
+ }
29
+ }
30
+
31
+ const token = await prompt(' Paste your Asana Personal Access Token: ');
32
+
33
+ if (!token) {
34
+ console.log(formatError('Token cannot be empty.'));
35
+ process.exit(1);
36
+ }
37
+
38
+ mkdirSync(CONFIG_DIR, { recursive: true });
39
+ writeFileSync(ENV_PATH, `ASANA_TOKEN=${token}\n`, { mode: 0o600 });
40
+
41
+ console.log(formatSuccess(`Token saved to ${ENV_PATH}`));
42
+ }
@@ -0,0 +1,8 @@
1
+ import { parseTaskId } from '../utils.js';
2
+ import { formatSubtasks } from '../formatter.js';
3
+
4
+ export async function subtasksCommand(urlOrId, client) {
5
+ const taskId = parseTaskId(urlOrId);
6
+ const subtasks = await client.getSubtasks(taskId);
7
+ console.log(formatSubtasks(subtasks));
8
+ }
@@ -0,0 +1,12 @@
1
+ import { parseTaskId } from '../utils.js';
2
+ import { formatTask } from '../formatter.js';
3
+
4
+ export async function taskCommand(urlOrId, client) {
5
+ const taskId = parseTaskId(urlOrId);
6
+ const [task, subtasks] = await Promise.all([
7
+ client.getTask(taskId),
8
+ client.getSubtasks(taskId),
9
+ ]);
10
+
11
+ console.log(formatTask(task, subtasks));
12
+ }
@@ -0,0 +1,71 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function formatTask(task, subtasks) {
4
+ const lines = [];
5
+
6
+ lines.push('');
7
+ lines.push(chalk.bold(`Task: ${task.name}`));
8
+ lines.push(` Status: ${task.completed ? chalk.green('Completed') : chalk.yellow('In progress')}`);
9
+
10
+ const project = task.memberships?.[0]?.project?.name;
11
+ if (project) {
12
+ lines.push(` Project: ${project}`);
13
+ }
14
+
15
+ if (task.assignee?.name) {
16
+ lines.push(` Assignee: ${task.assignee.name}`);
17
+ }
18
+
19
+ lines.push(` ID: ${task.gid}`);
20
+
21
+ if (task.notes) {
22
+ lines.push('');
23
+ lines.push(' Description:');
24
+ const noteLines = task.notes.split('\n');
25
+ for (const line of noteLines) {
26
+ lines.push(` ${line}`);
27
+ }
28
+ }
29
+
30
+ if (subtasks && subtasks.length > 0) {
31
+ lines.push('');
32
+ lines.push(` Subtasks (${subtasks.length}):`);
33
+ for (const sub of subtasks) {
34
+ const check = sub.completed ? chalk.green('x') : ' ';
35
+ const name = sub.completed ? chalk.dim(sub.name) : sub.name;
36
+ lines.push(` [${check}] ${name} (id: ${sub.gid})`);
37
+ }
38
+ }
39
+
40
+ lines.push('');
41
+ return lines.join('\n');
42
+ }
43
+
44
+ export function formatSubtasks(subtasks) {
45
+ const lines = [];
46
+ lines.push('');
47
+
48
+ if (subtasks.length === 0) {
49
+ lines.push(chalk.dim(' No subtasks found.'));
50
+ lines.push('');
51
+ return lines.join('\n');
52
+ }
53
+
54
+ lines.push(` Subtasks (${subtasks.length}):`);
55
+ for (const sub of subtasks) {
56
+ const check = sub.completed ? chalk.green('x') : ' ';
57
+ const name = sub.completed ? chalk.dim(sub.name) : sub.name;
58
+ lines.push(` [${check}] ${name} (id: ${sub.gid})`);
59
+ }
60
+
61
+ lines.push('');
62
+ return lines.join('\n');
63
+ }
64
+
65
+ export function formatSuccess(message) {
66
+ return `\n${chalk.green('OK')} ${message}\n`;
67
+ }
68
+
69
+ export function formatError(message) {
70
+ return `\n${chalk.red('Error:')} ${message}\n`;
71
+ }
package/src/utils.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Extracts the task ID from an Asana URL, or returns the ID as-is.
3
+ *
4
+ * Supported formats:
5
+ * - https://app.asana.com/0/<project-id>/<task-id>
6
+ * - https://app.asana.com/0/<project-id>/<task-id>/f
7
+ * - https://app.asana.com/1/<workspace-id>/project/<project-id>/task/<task-id>
8
+ * - Plain numeric ID
9
+ */
10
+ export function parseTaskId(input) {
11
+ if (/^\d+$/.test(input)) {
12
+ return input;
13
+ }
14
+
15
+ try {
16
+ const url = new URL(input);
17
+ if (!url.hostname.includes('asana.com')) {
18
+ throw new Error(`URL is not an Asana URL: ${input}`);
19
+ }
20
+
21
+ const parts = url.pathname.split('/').filter(Boolean);
22
+
23
+ // Format: /1/<workspace>/project/<project>/task/<task-id>
24
+ const taskIndex = parts.indexOf('task');
25
+ if (taskIndex !== -1 && parts[taskIndex + 1]) {
26
+ return parts[taskIndex + 1];
27
+ }
28
+
29
+ // Format: /0/<project-id>/<task-id>[/f]
30
+ if (parts[0] === '0' && parts.length >= 3) {
31
+ return parts[2];
32
+ }
33
+
34
+ throw new Error(`Could not extract a task ID from: ${input}`);
35
+ } catch (err) {
36
+ if (err.code === 'ERR_INVALID_URL') {
37
+ throw new Error(`Invalid input (not a URL or numeric ID): ${input}`);
38
+ }
39
+ throw err;
40
+ }
41
+ }