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 +21 -0
- package/README.md +191 -0
- package/bin/asana-cli.js +93 -0
- package/package.json +42 -0
- package/skills/asana/SKILL.md +148 -0
- package/src/client.js +148 -0
- package/src/commands/comment.js +51 -0
- package/src/commands/complete.js +43 -0
- package/src/commands/create-subtask.js +14 -0
- package/src/commands/init.js +42 -0
- package/src/commands/subtasks.js +8 -0
- package/src/commands/task.js +12 -0
- package/src/formatter.js +71 -0
- package/src/utils.js +41 -0
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)
|
package/bin/asana-cli.js
ADDED
|
@@ -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
|
+
}
|
package/src/formatter.js
ADDED
|
@@ -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
|
+
}
|