
Asana
- 15 installs
- 638 repo stars
- Updated March 7, 2026
- sundial-org/awesome-openclaw-skills
Helps with ai & agent building tasks during AI-assisted development.
About
asana is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- asana
- AI & Agent Building
- AI-coding skill
Asana by the numbers
- 15 all-time installs (skills.sh)
- Ranked #11,187 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sundial-org/awesome-openclaw-skills --skill asanaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 638 |
| Last updated | March 7, 2026 |
| Repository | sundial-org/awesome-openclaw-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Asana (Clawdbot skill)
This skill is designed for a personal local-only Asana integration using OAuth with an out-of-band/manual code paste flow.
What this skill provides
- A small Node CLI to:
- generate the Asana authorize URL
- exchange an authorization code for access/refresh tokens
- auto-refresh the access token
- make basic API calls (e.g.
/users/me,/workspaces, tasks)
Setup (OAuth, OOB/manual code)
0) Create an Asana app
In Asana Developer Console (My apps):
- Create app
- Enable scopes you will need (typical:
tasks:read,tasks:write,projects:read) - Set redirect URI to the OOB value (manual code):
urn:ietf:wg:oauth:2.0:oob
1) Provide credentials (two options)
Option A (recommended for Clawdbot): save to a local credentials file:
node scripts/configure.mjs --client-id "..." --client-secret "..."This writes ~/.clawdbot/asana/credentials.json.
Option B: set environment variables (shell/session):
ASANA_CLIENT_IDASANA_CLIENT_SECRET
2) Run OAuth
From the repo root:
1) Print the authorize URL:
node scripts/oauth_oob.mjs authorize2) Open the printed URL, click Allow, copy the code. 3) Exchange code and save tokens locally:
node scripts/oauth_oob.mjs token --code "PASTE_CODE_HERE"Tokens are stored at:
~/.clawdbot/asana/token.json
Chat usage (support both explicit + natural language)
You can use either:
- Explicit commands: start the message with
/asana ... - Natural language: e.g. “list tasks assigned to me”
For Clawdbot, implement the mapping by translating the user request into the appropriate asana_api.mjs command.
Examples:
/asana tasks-assigned→tasks-assigned --assignee me- “list tasks assigned to me” →
tasks-assigned --assignee me - “list all tasks in <project>” → resolve
<project>to a project gid, thentasks-in-project --project <gid> - “list tasks due date from 2026-01-01 to 2026-01-15” →
search-tasks --assignee me --due_on.after 2026-01-01 --due_on.before 2026-01-15
(Optional helper) scripts/asana_chat.mjs can map common phrases to a command skeleton.
Using the API helper
Sanity check (who am I):
node scripts/asana_api.mjs meList workspaces:
node scripts/asana_api.mjs workspacesSet a default workspace (optional):
node scripts/asana_api.mjs set-default-workspace --workspace <workspace_gid>After that, you can omit --workspace for commands that support it.
List projects in a workspace (explicit):
node scripts/asana_api.mjs projects --workspace <workspace_gid>List projects using the default workspace:
node scripts/asana_api.mjs projectsList tasks in a project:
node scripts/asana_api.mjs tasks-in-project --project <project_gid>List tasks assigned to me (workspace required by Asana):
node scripts/asana_api.mjs tasks-assigned --workspace <workspace_gid> --assignee meOr using the default workspace:
node scripts/asana_api.mjs tasks-assigned --assignee meSearch tasks (advanced search):
node scripts/asana_api.mjs search-tasks --workspace <workspace_gid> --text "release" --assignee me
# also supports convenience: --project <project_gid>View a task:
node scripts/asana_api.mjs task <task_gid>Mark a task complete:
node scripts/asana_api.mjs complete-task <task_gid>Update a task:
node scripts/asana_api.mjs update-task <task_gid> --name "New title" --due_on 2026-02-01Comment on a task:
node scripts/asana_api.mjs comment <task_gid> --text "Update: shipped"Create a task:
node scripts/asana_api.mjs create-task --workspace <workspace_gid> --name "Test task" --notes "from clawdbot" --projects <project_gid>Notes / gotchas
- OAuth access tokens expire; refresh tokens are used to obtain new access tokens.
- If you later want multi-user support, replace OOB with a real redirect/callback.
- Don’t log tokens.
clawdbot-asana-skill
Asana OAuth (local-only) + task/project commands for Clawdbot.
This repo contains a small Asana skill (an AgentSkill folder) that you can:
- use locally on your Clawdbot host
- publish/share so other Clawdbot users can install and run it
It uses Asana OAuth 2.0 Authorization Code Grant with an out-of-band (OOB) / manual code paste redirect URI:
urn:ietf:wg:oauth:2.0:oob
No public callback server is required.
---
What you get
Commands (via Node scripts):
- Auth helpers: generate authorize URL, exchange code, refresh access token
- List workspaces, list projects, list tasks
- View task, update task, mark complete, comment
Tokens and config are stored locally under:
~/.clawdbot/asana/credentials.json(client id + secret)~/.clawdbot/asana/token.json(OAuth tokens)~/.clawdbot/asana/config.json(default workspace)
---
Prerequisites
- Node.js 22+
- An Asana account with access to the workspace(s) you want
---
1) Create an Asana “Custom App” (Developer Console)
1. Open the Asana Developer Console:
- https://app.asana.com/0/my-apps
2. Create a new app. 3. Distribution (important):
- Your OAuth app must be available in the workspace the user is authorizing from.
- In the developer console, configure Manage distribution so the app is available to the target workspace(s).
- If distribution/workspace availability is misconfigured, authorization can fail even if the URL is correct.
4. OAuth settings:
- Redirect URI:
urn:ietf:wg:oauth:2.0:oob- Copy the Client ID and Client Secret.
Scopes (must match what you request)
In the Asana Developer Console, go to OAuth → Permission scopes and enable the scopes you plan to request.
Important rules:
- The scopes you request in the authorize URL must be a subset of the scopes enabled in the console.
- If you request a scope that is not enabled, you’ll get a
forbidden_scopeserror.
Recommended “full task management” scope set:
tasks:readtasks:writetasks:deleteprojects:readprojects:writeattachments:readattachments:writecustom_fields:readcustom_fields:writetags:readtags:writetask_custom_types:readteams:readusers:readworkspaces:read
---
2) Configure credentials locally
Save your Asana OAuth client id/secret to a local file:
node scripts/configure.mjs \
--client-id "YOUR_CLIENT_ID" \
--client-secret "YOUR_CLIENT_SECRET"This writes:
~/.clawdbot/asana/credentials.json
(Alternative: you can set ASANA_CLIENT_ID and ASANA_CLIENT_SECRET as environment variables, but the credentials file is recommended for Clawdbot.)
---
3) Authorize (OOB) and save tokens
3.1 Generate the authorize URL
node scripts/oauth_oob.mjs authorize \
--scope "tasks:read tasks:write projects:read"Open the printed URL in your browser, click Allow, then copy the code.
3.2 Exchange the code for tokens
node scripts/oauth_oob.mjs token --code "PASTE_CODE_HERE"This writes:
~/.clawdbot/asana/token.json
---
4) (Optional) set a default workspace
List workspaces:
node scripts/asana_api.mjs workspacesSet default workspace:
node scripts/asana_api.mjs set-default-workspace --workspace <workspace_gid>This writes:
~/.clawdbot/asana/config.json
Commands that require a workspace will use the default if --workspace is omitted.
---
5) Common commands
Who am I:
node scripts/asana_api.mjs meList projects (default workspace):
node scripts/asana_api.mjs projectsList tasks assigned to me:
node scripts/asana_api.mjs tasks-assigned --assignee meList all tasks in a project:
node scripts/asana_api.mjs tasks-in-project --project <project_gid>View a task:
node scripts/asana_api.mjs task <task_gid>Mark complete:
node scripts/asana_api.mjs complete-task <task_gid>Comment:
node scripts/asana_api.mjs comment <task_gid> --text "Update: shipped"Advanced search (workspace required; default is used if set):
node scripts/asana_api.mjs search-tasks --text "release" --assignee me---
Install into Clawdbot (local)
If your Clawdbot workspace is /Users/tony/clawd, copy the skill folder into:
/Users/tony/clawd/skills/asana/
and restart Clawdbot if needed.
---
Notes
- Access tokens expire; the scripts will refresh using the refresh token.
- Keep
credentials.jsonandtoken.jsonsecret. - If you publish this, document that users must configure distribution + scopes in the Asana console to match what they request in
authorize.
Asana endpoints (quick reference)
Auth:
- Authorize:
GET https://app.asana.com/-/oauth_authorize - Token exchange / refresh:
POST https://app.asana.com/-/oauth_token - Revoke:
POST https://app.asana.com/-/oauth_revoke
API:
- Base:
https://app.asana.com/api/1.0
Common:
GET /users/meGET /workspacesGET /projects?workspace=<gid>GET /tasks(requires filters)GET /workspaces/{workspace_gid}/tasks/search(advanced search; can be premium-only)POST /tasks
Notes:
- OAuth scopes are required with OAuth apps (examples:
tasks:read,tasks:write,projects:read).
#!/usr/bin/env node
/**
* Minimal Asana API CLI with OAuth refresh.
*
* Reads token from ~/.clawdbot/asana/token.json
* Requires ASANA_CLIENT_ID + ASANA_CLIENT_SECRET for refresh.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const API_BASE = 'https://app.asana.com/api/1.0';
const TOKEN_URL = 'https://app.asana.com/-/oauth_token';
function die(msg) {
console.error(msg);
process.exit(1);
}
function tokenPath() {
return path.join(os.homedir(), '.clawdbot', 'asana', 'token.json');
}
function configPath() {
return path.join(os.homedir(), '.clawdbot', 'asana', 'config.json');
}
function loadConfig() {
const p = configPath();
if (!fs.existsSync(p)) return {};
try {
return JSON.parse(fs.readFileSync(p, 'utf-8')) || {};
} catch {
return {};
}
}
function loadToken() {
const p = tokenPath();
if (!fs.existsSync(p)) die(`Token file not found: ${p}. Run oauth_oob.mjs token first.`);
return JSON.parse(fs.readFileSync(p, 'utf-8'));
}
function saveToken(tok) {
const p = tokenPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(tok, null, 2));
}
function urlEncode(params) {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
u.set(k, String(v));
}
return u.toString();
}
async function postForm(url, params) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: urlEncode(params),
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 300)}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)}`);
return data;
}
async function ensureAccessToken(token) {
const now = Date.now();
const expiresAt = token.expires_at_ms;
if (typeof token.access_token !== 'string') die('Token missing access_token');
// Refresh if expiring within 2 minutes
if (expiresAt && now < expiresAt - 120_000) return token;
if (!token.refresh_token) {
// Some flows may not return refresh_token; in that case user must re-auth.
return token;
}
let clientId = process.env.ASANA_CLIENT_ID;
let clientSecret = process.env.ASANA_CLIENT_SECRET;
if (!clientId || !clientSecret) {
// Fallback to ~/.clawdbot/asana/credentials.json
try {
const credPath = path.join(os.homedir(), '.clawdbot', 'asana', 'credentials.json');
const creds = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
clientId = clientId || creds.client_id;
clientSecret = clientSecret || creds.client_secret;
} catch {
// ignore
}
}
if (!clientId || !clientSecret) {
die(
'Token needs refresh but ASANA_CLIENT_ID/ASANA_CLIENT_SECRET are not set, and ~/.clawdbot/asana/credentials.json is missing. Run: node skills/asana/scripts/configure.mjs --client-id ... --client-secret ...',
);
}
const data = await postForm(TOKEN_URL, {
grant_type: 'refresh_token',
client_id: clientId,
client_secret: clientSecret,
refresh_token: token.refresh_token,
});
const refreshed = {
...token,
...data,
obtained_at_ms: now,
expires_at_ms: typeof data.expires_in === 'number' ? now + data.expires_in * 1000 : null,
};
saveToken(refreshed);
return refreshed;
}
async function asanaGet(pathname, token, query) {
const url = new URL(API_BASE + pathname);
if (query) {
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 300)}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)}`);
return data;
}
async function asanaJson(method, pathname, token, body) {
const url = API_BASE + pathname;
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 300)}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)}`);
return data;
}
const asanaPost = (pathname, token, body) => asanaJson('POST', pathname, token, body);
const asanaPut = (pathname, token, body) => asanaJson('PUT', pathname, token, body);
function parseArgs(argv) {
const [cmd, ...rest] = argv;
const flags = {};
const positionals = [];
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true;
flags[k] = v;
} else {
positionals.push(a);
}
}
return { cmd, flags, positionals };
}
function csvList(v) {
if (!v) return [];
return String(v)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
async function resolveMeGid(accessToken) {
const me = await asanaGet('/users/me', accessToken);
const gid = me?.data?.gid;
if (!gid) throw new Error('Could not resolve /users/me gid');
return String(gid);
}
function printJson(x) {
console.log(JSON.stringify(x, null, 2));
}
async function main() {
const { cmd, flags, positionals } = parseArgs(process.argv.slice(2));
if (!cmd) {
die(
'Command required: me | workspaces | list-workspaces | set-default-workspace | projects | tasks-in-project | tasks-assigned | search-tasks | task | update-task | complete-task | comment | create-task',
);
}
let tok = loadToken();
tok = await ensureAccessToken(tok);
const accessToken = tok.access_token;
const cfg = loadConfig();
const getWorkspaceOrDefault = () => {
const w = flags.workspace || cfg.default_workspace_gid;
return w ? String(w) : null;
};
if (cmd === 'me') {
printJson(await asanaGet('/users/me', accessToken));
return;
}
if (cmd === 'workspaces' || cmd === 'list-workspaces') {
printJson(await asanaGet('/workspaces', accessToken));
return;
}
if (cmd === 'set-default-workspace') {
const workspace = flags.workspace || positionals[0];
if (!workspace) die('Usage: set-default-workspace --workspace <workspace_gid>');
const outPath = configPath();
const next = { ...cfg, default_workspace_gid: String(workspace) };
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(next, null, 2));
console.log(`Saved default workspace to: ${outPath}`);
console.log(`default_workspace_gid = ${next.default_workspace_gid}`);
return;
}
if (cmd === 'projects') {
const workspace = getWorkspaceOrDefault();
if (!workspace) die('Missing --workspace <workspace_gid> (or set default via set-default-workspace)');
const optFields = flags.opt_fields || 'gid,name,resource_type,archived,public';
const r = await asanaGet('/projects', accessToken, {
workspace,
opt_fields: optFields,
limit: flags.limit || 100,
});
printJson(r);
return;
}
if (cmd === 'tasks-in-project') {
const project = flags.project;
if (!project) die('Missing --project <project_gid>');
const optFields =
flags.opt_fields ||
'gid,name,completed,completed_at,assignee.name,assignee.gid,due_on,permalink_url,modified_at';
const r = await asanaGet('/tasks', accessToken, {
project,
opt_fields: optFields,
limit: flags.limit || 100,
});
printJson(r);
return;
}
if (cmd === 'tasks-assigned') {
const workspace = getWorkspaceOrDefault();
if (!workspace) die('Missing --workspace <workspace_gid> (or set default via set-default-workspace)');
const assignee = flags.assignee || 'me';
const assigneeGid = assignee === 'me' ? await resolveMeGid(accessToken) : String(assignee);
const optFields =
flags.opt_fields ||
'gid,name,completed,assignee.name,due_on,projects.name,permalink_url,modified_at';
const r = await asanaGet('/tasks', accessToken, {
workspace,
assignee: assigneeGid,
// common filter to exclude completed; Asana accepts special values like "now"
completed_since: flags.completed_since || 'now',
opt_fields: optFields,
limit: flags.limit || 100,
});
printJson(r);
return;
}
if (cmd === 'search-tasks') {
const workspace = getWorkspaceOrDefault();
if (!workspace) die('Missing --workspace <workspace_gid> (or set default via set-default-workspace)');
// Asana search endpoint uses query params.
// Common params: text, assignee.any, projects.any, sections.any, completed, completed_on.after/before, due_on.after/before, etc.
const query = { ...flags };
delete query._;
// normalize a couple convenience flags
if (query.assignee === 'me') query['assignee.any'] = await resolveMeGid(accessToken);
if (query.project) query['projects.any'] = String(query.project);
delete query.assignee;
delete query.project;
const optFields = query.opt_fields || 'gid,name,completed,assignee.name,due_on,permalink_url';
delete query.opt_fields;
const r = await asanaGet(`/workspaces/${workspace}/tasks/search`, accessToken, {
...query,
opt_fields: optFields,
limit: query.limit || 100,
});
printJson(r);
return;
}
if (cmd === 'task') {
const gid = flags.gid || positionals[0];
if (!gid) die('Usage: task <task_gid>');
const optFields =
flags.opt_fields ||
'gid,name,completed,assignee.name,assignee.gid,due_on,notes,permalink_url,projects.name,memberships.section.name,modified_at';
const r = await asanaGet(`/tasks/${gid}`, accessToken, { opt_fields: optFields });
printJson(r);
return;
}
if (cmd === 'update-task') {
const gid = flags.gid || positionals[0];
if (!gid) die('Usage: update-task <task_gid> [--name ...] [--notes ...] [--due_on YYYY-MM-DD] [--completed true|false]');
const data = {};
if (flags.name) data.name = String(flags.name);
if (flags.notes) data.notes = String(flags.notes);
if (flags.due_on) data.due_on = String(flags.due_on);
if (flags.completed !== undefined) data.completed = String(flags.completed) === 'true' || flags.completed === true;
if (Object.keys(data).length === 0) die('No fields provided to update.');
const r = await asanaPut(`/tasks/${gid}`, accessToken, { data });
printJson(r);
return;
}
if (cmd === 'complete-task') {
const gid = flags.gid || positionals[0];
if (!gid) die('Usage: complete-task <task_gid>');
const r = await asanaPut(`/tasks/${gid}`, accessToken, { data: { completed: true } });
printJson(r);
return;
}
if (cmd === 'comment') {
const gid = flags.gid || flags.task || positionals[0];
const text = flags.text;
if (!gid) die('Usage: comment <task_gid> --text "..."');
if (!text) die('Missing --text');
// Stories API: POST /tasks/{task_gid}/stories
const r = await asanaPost(`/tasks/${gid}/stories`, accessToken, { data: { text: String(text) } });
printJson(r);
return;
}
if (cmd === 'create-task') {
const workspace = flags.workspace;
const name = flags.name;
const notes = flags.notes || '';
const projects = flags.projects;
if (!name) die('Missing --name');
const data = { name, notes };
if (workspace) data.workspace = String(workspace);
if (projects) data.projects = csvList(projects);
if (flags.due_on) data.due_on = String(flags.due_on);
const r = await asanaPost('/tasks', accessToken, { data });
printJson(r);
return;
}
die(`Unknown command: ${cmd}`);
}
main().catch((e) => die(String(e?.stack || e)));
#!/usr/bin/env node
/**
* Very small helper to map chat-ish input into an asana_api.mjs command.
*
* This is NOT required for Clawdbot, but makes it easy to support both:
* - explicit commands: "/asana projects"
* - natural language: "list tasks assigned to me"
*
* Usage:
* node asana/scripts/asana_chat.mjs --text "list tasks assigned to me"
*
* Output: a JSON object { cmd, args: [] }
*/
function die(msg) {
console.error(msg);
process.exit(1);
}
function parseArgs(argv) {
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
flags[k] = v;
}
}
return flags;
}
function norm(s) {
return String(s || '')
.trim()
.replace(/\s+/g, ' ');
}
function parseDateRange(text) {
// very simple: from YYYY-MM-DD to YYYY-MM-DD
const m = text.match(/from\s+(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})/i);
if (!m) return null;
return { from: m[1], to: m[2] };
}
function main() {
const flags = parseArgs(process.argv.slice(2));
const text = norm(flags.text);
if (!text) die('Missing --text');
// Slash style
if (text.toLowerCase().startsWith('/asana ')) {
const rest = text.slice(7).trim();
const parts = rest.split(' ');
return console.log(
JSON.stringify(
{
cmd: parts[0],
args: parts.slice(1),
},
null,
2,
),
);
}
const t = text.toLowerCase();
// Natural language patterns
if (t.includes('list') && t.includes('workspace')) {
return console.log(JSON.stringify({ cmd: 'workspaces', args: [] }, null, 2));
}
if ((t.includes('list') || t.includes('show')) && t.includes('project')) {
return console.log(JSON.stringify({ cmd: 'projects', args: [] }, null, 2));
}
if (t.includes('assigned to me') || t.includes('my tasks')) {
// Optional: "in <project>" requires project gid/name resolution at a higher layer.
return console.log(JSON.stringify({ cmd: 'tasks-assigned', args: ['--assignee', 'me'] }, null, 2));
}
if (t.includes('all tasks in') || t.includes('tasks in project')) {
// Expect the caller to provide --project elsewhere.
return console.log(JSON.stringify({ cmd: 'tasks-in-project', args: [] }, null, 2));
}
const dr = parseDateRange(text);
if (dr) {
// Asana search uses query params; due_on.after / due_on.before are common.
return console.log(
JSON.stringify(
{
cmd: 'search-tasks',
args: ['--assignee', 'me', '--due_on.after', dr.from, '--due_on.before', dr.to],
},
null,
2,
),
);
}
return console.log(JSON.stringify({ cmd: 'unknown', args: [], note: 'No rule matched.' }, null, 2));
}
main();
#!/usr/bin/env node
/**
* Configure local-only Asana OAuth credentials for the Clawdbot Asana skill.
*
* Writes to: ~/.clawdbot/asana/credentials.json
*
* Usage:
* node skills/asana/scripts/configure.mjs --client-id "..." --client-secret "..."
*
* Notes:
* - Keep this file secret.
* - You can also set ASANA_CLIENT_ID / ASANA_CLIENT_SECRET as environment variables instead.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
function die(msg) {
console.error(msg);
process.exit(1);
}
function parseArgs(argv) {
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
flags[k] = v;
}
}
return flags;
}
const flags = parseArgs(process.argv.slice(2));
const clientId = flags['client-id'] || process.env.ASANA_CLIENT_ID;
const clientSecret = flags['client-secret'] || process.env.ASANA_CLIENT_SECRET;
if (!clientId) die('Missing --client-id (or ASANA_CLIENT_ID)');
if (!clientSecret) die('Missing --client-secret (or ASANA_CLIENT_SECRET)');
const outPath = path.join(os.homedir(), '.clawdbot', 'asana', 'credentials.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify({ client_id: String(clientId), client_secret: String(clientSecret) }, null, 2));
console.log(`Saved Asana OAuth credentials to: ${outPath}`);
#!/usr/bin/env node
/**
* Asana OAuth (OOB/manual code paste) helper.
*
* Usage:
* ASANA_CLIENT_ID=... ASANA_CLIENT_SECRET=... node asana/scripts/oauth_oob.mjs authorize
* ASANA_CLIENT_ID=... ASANA_CLIENT_SECRET=... node asana/scripts/oauth_oob.mjs token --code "..."
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const AUTH_URL = 'https://app.asana.com/-/oauth_authorize';
const TOKEN_URL = 'https://app.asana.com/-/oauth_token';
const REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob';
function die(msg) {
console.error(msg);
process.exit(1);
}
function parseArgs(argv) {
const [cmd, ...rest] = argv;
const flags = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true;
flags[k] = v;
}
}
return { cmd, flags };
}
function tokenPath() {
return path.join(os.homedir(), '.clawdbot', 'asana', 'token.json');
}
function ensureDir(p) {
fs.mkdirSync(path.dirname(p), { recursive: true });
}
function saveToken(json) {
const p = tokenPath();
ensureDir(p);
fs.writeFileSync(p, JSON.stringify(json, null, 2));
console.log(`Saved token to: ${p}`);
}
function urlEncode(params) {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
u.set(k, String(v));
}
return u.toString();
}
async function postForm(url, params) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: urlEncode(params),
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 300)}`);
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function main() {
const { cmd, flags } = parseArgs(process.argv.slice(2));
let clientId = process.env.ASANA_CLIENT_ID;
let clientSecret = process.env.ASANA_CLIENT_SECRET;
// Optional fallback to ~/.clawdbot/asana/credentials.json
try {
const credPath = path.join(os.homedir(), '.clawdbot', 'asana', 'credentials.json');
const creds = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
clientId = clientId || creds.client_id;
clientSecret = clientSecret || creds.client_secret;
} catch {
// ignore
}
if (!cmd) die('Command required: authorize | token');
if (!clientId) die('Missing ASANA_CLIENT_ID (or ~/.clawdbot/asana/credentials.json)');
if (cmd === 'authorize') {
const scope = flags.scope || 'default';
const state = flags.state || crypto.randomUUID();
// Asana supports code challenge / PKCE; OOB is typically used for simple flows.
const url = `${AUTH_URL}?${urlEncode({
client_id: clientId,
redirect_uri: REDIRECT_URI,
response_type: 'code',
state,
// If you want explicit scopes, pass --scope "tasks:read tasks:write"
...(scope !== 'default' ? { scope } : {}),
})}`;
console.log('Open this URL in your browser, click Allow, then copy the code:');
console.log(url);
console.log('\nThen run:');
console.log(`node asana/scripts/oauth_oob.mjs token --code "PASTE_CODE"`);
return;
}
if (cmd === 'token') {
if (!clientSecret) die('Missing ASANA_CLIENT_SECRET');
const code = flags.code;
if (!code || typeof code !== 'string') die('Missing --code');
const data = await postForm(TOKEN_URL, {
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: REDIRECT_URI,
code,
});
// Normalize with a timestamp; Asana returns expires_in (seconds)
const now = Date.now();
const token = {
...data,
obtained_at_ms: now,
expires_at_ms: typeof data.expires_in === 'number' ? now + data.expires_in * 1000 : null,
};
saveToken(token);
return;
}
die(`Unknown command: ${cmd}`);
}
main().catch((e) => die(String(e?.stack || e)));