diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ca7ce1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4bbb1ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Taskade + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a83222c..70fe2cb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,104 @@ # @taskade/cli -Thin CLI for Taskade MCP plug-and-play. +Thin CLI for Taskade MCP plug-and-play. One command to connect Cursor or Claude to your Taskade workspace. + +## Quick Start + +```bash +# 1. Get your token at https://taskade.com/settings/api (looks like tskdp_...) + +# 2. Connect Cursor +npx @taskade/cli plug cursor + +# 3. Connect Claude +npx @taskade/cli plug claude + +# 4. Verify your token works +npx @taskade/cli whoami +``` + +## What This Does + +This CLI prints the MCP server config block you need to paste into your AI agent client (Cursor, Claude Desktop, Claude Code). It also verifies your Personal Access Token (PAT) works against the Taskade API. + +**The token is the product.** You do not need this CLI to use Taskade MCP - you can copy the config block manually from [https://taskade.com/settings/api](https://taskade.com/settings/api). This CLI just makes it one command instead of five clicks. + +## Commands + +### `taskade plug [cursor|claude]` + +Prints the MCP server config block. + +```bash +npx @taskade/cli plug cursor # Cursor format with instructions +npx @taskade/cli plug claude # Claude Desktop/Code format with instructions +npx @taskade/cli plug # Raw JSON, no client wrapper +``` + +Output (raw JSON): + +```json +{ + "mcpServers": { + "taskade": { + "url": "https://taskade.com/mcp", + "headers": { + "Authorization": "Bearer tskdp_..." + } + } + } +} +``` + +### `taskade whoami` + +Verifies your PAT works against the Taskade API. + +```bash +npx @taskade/cli whoami +``` + +Output: + +``` +Token works. + User: John Doe + Email: john@example.com + Plan: pro + +MCP server URL: https://taskade.com/mcp +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `TASKADE_TOKEN` | (prompt) | Your PAT (`tskdp_...`). Get one at [https://taskade.com/settings/api](https://taskade.com/settings/api) | +| `TASKADE_API_URL` | `https://www.taskade.com` | API base URL (for self-hosted) | + +## Requirements + +- Node.js 18+ (uses built-in `fetch`) + +## Hosted MCP vs stdio + +| | Hosted MCP | stdio (npm) | +|---|-----------|-------------| +| URL | `https://taskade.com/mcp` | `npx @taskade/mcp-server` | +| Auth | PAT (`tskdp_...`) | PAT (`tskdp_...`) | +| Tools | 48 (44 Phase A + 4 native) | 14 (read-only subset) | +| Plan | Starter+ | Any | +| Best for | Cursor, Claude Desktop, Claude Code | Local stdio wrapper | + +**Use hosted MCP.** It has 3x more tools (including writes) and is maintained. The stdio wrapper is a local fallback only. + +## Related + +- [Hosted MCP](https://taskade.com/mcp) - the server this CLI configures +- [API v2 docs](https://www.taskade.com/api/documentation/v2) - the REST API the MCP wraps +- [taskade/mcp](https://github.com/taskade/mcp) - the MCP server repo +- [taskade/docs](https://github.com/taskade/docs) - developer documentation + +## License + +MIT diff --git a/bin/cli.mjs b/bin/cli.mjs new file mode 100755 index 0000000..fa9eaab --- /dev/null +++ b/bin/cli.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +/** + * @taskade/cli - Thin CLI for Taskade MCP plug-and-play. + * + * Commands: + * taskade plug [cursor|claude] Print the MCP server config block for your client. + * taskade whoami Verify your PAT works against the Taskade API. + * taskade help Show this help. + * + * Usage: + * npx @taskade/cli plug cursor # copy-paste into Cursor's MCP config + * npx @taskade/cli whoami # paste your tskdp_ token, verify it works + * + * Environment: + * TASKADE_TOKEN Your Personal Access Token (tskdp_...). If unset, you will be + * prompted to paste it. Get one at https://taskade.com/settings/api + * TASKADE_API_URL API base URL (default: https://www.taskade.com) + */ + +const DEFAULT_API_URL = "https://www.taskade.com"; +const MCP_SERVER_URL = "https://taskade.com/mcp"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Read a line from stdin (used for token prompt). */ +function readLine() { + return new Promise((resolve) => { + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + if (process.stdin.isTTY) { + // Interactive: wait for a single line (Enter key) + process.stdin.once("data", (data) => { + process.stdin.pause(); + resolve(data.trim()); + }); + } else { + // Non-interactive (pipe): read all of stdin, resolve on end + let data = ""; + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => { + process.stdin.pause(); + resolve(data.trim()); + }); + } + }); +} + +/** Get the token from env or prompt. */ +async function getToken() { + if (process.env.TASKADE_TOKEN) return process.env.TASKADE_TOKEN; + process.stderr.write("Paste your Taskade PAT (tskdp_...): "); + return readLine(); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** `taskade plug [cursor|claude]` - print the MCP config block. */ +async function cmdPlug(client) { + const token = await getToken(); + if (!token) { + process.stderr.write("No token provided. Get one at https://taskade.com/settings/api\n"); + process.exit(1); + } + + const config = { + mcpServers: { + taskade: { + url: MCP_SERVER_URL, + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }, + }; + + const json = JSON.stringify(config, null, 2); + + if (client === "cursor") { + // Cursor reads ~/.cursor/mcp.json or project .cursor/mcp.json + process.stdout.write(`# Add this to your Cursor MCP config\n`); + process.stdout.write(`# ~/.cursor/mcp.json or .cursor/mcp.json in your project\n\n`); + process.stdout.write(json); + process.stdout.write("\n"); + } else if (client === "claude") { + // Claude Desktop / Claude Code reads claude_desktop_config.json + process.stdout.write(`# Add this to your Claude config\n`); + process.stdout.write(`# ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)\n`); + process.stdout.write(`# or .mcp.json in your project root (Claude Code)\n\n`); + process.stdout.write(json); + process.stdout.write("\n"); + } else { + // No client specified - just print the raw JSON + process.stdout.write(json); + process.stdout.write("\n"); + } +} + +/** `taskade whoami` - verify the PAT works. */ +async function cmdWhoami() { + const token = await getToken(); + if (!token) { + process.stderr.write("No token provided. Get one at https://taskade.com/settings/api\n"); + process.exit(1); + } + + const apiUrl = process.env.TASKADE_API_URL || DEFAULT_API_URL; + const url = `${apiUrl}/api/v2/user`; + + try { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }); + + if (res.status === 401) { + process.stderr.write("Token is invalid or expired. Get a new one at https://taskade.com/settings/api\n"); + process.exit(1); + } + + if (res.status === 403) { + process.stderr.write("Token is valid but lacks access. Check your plan at https://taskade.com/settings/billing\n"); + process.exit(1); + } + + if (!res.ok) { + process.stderr.write(`API returned ${res.status} ${res.statusText}\n`); + process.exit(1); + } + + const data = await res.json(); + const name = data.full_name || data.name || data.email || "unknown"; + const email = data.email || ""; + process.stdout.write(`Token works.\n`); + process.stdout.write(` User: ${name}\n`); + if (email) process.stdout.write(` Email: ${email}\n`); + process.stdout.write(` Plan: ${data.plan || "unknown"}\n`); + process.stdout.write(`\nMCP server URL: ${MCP_SERVER_URL}\n`); + } catch (err) { + process.stderr.write(`Request failed: ${err.message}\n`); + process.exit(1); + } +} + +/** `taskade help` - show usage. */ +function cmdHelp() { + process.stdout.write(` +@taskade/cli - Thin CLI for Taskade MCP plug-and-play + +USAGE + taskade [options] + +COMMANDS + plug [cursor|claude] Print the MCP server config block for your client + whoami Verify your PAT works against the Taskade API + help Show this help message + +EXAMPLES + npx @taskade/cli plug cursor # config block for Cursor + npx @taskade/cli plug claude # config block for Claude Desktop/Code + npx @taskade/cli plug # raw JSON, no client wrapper + npx @taskade/cli whoami # verify your token + +ENVIRONMENT + TASKADE_TOKEN Your PAT (tskdp_...). Get one at https://taskade.com/settings/api + TASKADE_API_URL API base URL (default: https://www.taskade.com) + +LEARN MORE + Hosted MCP: https://taskade.com/mcp + API docs: https://www.taskade.com/api/documentation/v2 + GitHub: https://github.com/taskade/cli +`); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main() { + const [cmd, ...args] = process.argv.slice(2); + + switch (cmd) { + case "plug": + await cmdPlug(args[0]); + break; + case "whoami": + await cmdWhoami(); + break; + case "help": + case "--help": + case "-h": + cmdHelp(); + break; + case undefined: + cmdHelp(); + break; + default: + process.stderr.write(`Unknown command: ${cmd}\n\n`); + cmdHelp(); + process.exit(1); + } +} + +main().catch((err) => { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8bd49e --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@taskade/cli", + "version": "0.1.0", + "description": "Thin CLI for Taskade MCP plug-and-play. One command to connect Cursor/Claude to your Taskade workspace.", + "type": "module", + "bin": { + "taskade": "./bin/cli.mjs" + }, + "files": [ + "bin/" + ], + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "keywords": [ + "taskade", + "mcp", + "cli", + "cursor", + "claude", + "ai-agent" + ], + "license": "MIT", + "homepage": "https://github.com/taskade/cli", + "repository": { + "type": "git", + "url": "git+https://github.com/taskade/cli.git" + }, + "engines": { + "node": ">=18" + } +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..5919392 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,92 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, "..", "bin", "cli.mjs"); + +function runCli(args, env = {}, input = "") { + try { + const output = execFileSync("node", [CLI, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + timeout: 5000, + input, + }); + return { stdout: output, stderr: "", exitCode: 0 }; + } catch (err) { + return { + stdout: err.stdout || "", + stderr: err.stderr || "", + exitCode: err.status || 1, + }; + } +} + +test("help command prints usage", () => { + const { stdout, exitCode } = runCli(["help"]); + assert.equal(exitCode, 0); + assert.match(stdout, /@taskade\/cli/); + assert.match(stdout, /USAGE/); + assert.match(stdout, /plug/); + assert.match(stdout, /whoami/); +}); + +test("no args prints help", () => { + const { stdout, exitCode } = runCli([]); + assert.equal(exitCode, 0); + assert.match(stdout, /USAGE/); +}); + +test("--help flag prints help", () => { + const { stdout, exitCode } = runCli(["--help"]); + assert.equal(exitCode, 0); + assert.match(stdout, /USAGE/); +}); + +test("unknown command exits 1", () => { + const { exitCode } = runCli(["nonexistent"]); + assert.equal(exitCode, 1); +}); + +test("plug with token outputs valid JSON", () => { + const { stdout, exitCode } = runCli(["plug"], { + TASKADE_TOKEN: "tskdp_test123", + }); + assert.equal(exitCode, 0); + const parsed = JSON.parse(stdout); + assert.ok(parsed.mcpServers); + assert.ok(parsed.mcpServers.taskade); + assert.equal(parsed.mcpServers.taskade.url, "https://taskade.com/mcp"); + assert.equal(parsed.mcpServers.taskade.headers.Authorization, "Bearer tskdp_test123"); +}); + +test("plug cursor includes Cursor instructions", () => { + const { stdout, exitCode } = runCli(["plug", "cursor"], { + TASKADE_TOKEN: "tskdp_test123", + }); + assert.equal(exitCode, 0); + assert.match(stdout, /Cursor/); + assert.match(stdout, /\.cursor\/mcp\.json/); +}); + +test("plug claude includes Claude instructions", () => { + const { stdout, exitCode } = runCli(["plug", "claude"], { + TASKADE_TOKEN: "tskdp_test123", + }); + assert.equal(exitCode, 0); + assert.match(stdout, /Claude/); + assert.match(stdout, /claude_desktop_config\.json/); +}); + +test("plug without token exits 1", () => { + const { exitCode } = runCli(["plug"], { TASKADE_TOKEN: "" }); + assert.equal(exitCode, 1); +}); + +test("whoami without token exits 1", () => { + const { exitCode } = runCli(["whoami"], { TASKADE_TOKEN: "" }); + assert.equal(exitCode, 1); +});