From d01916f005e13a403a9b2d875e59f2bbaa099283 Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Mon, 16 Mar 2026 22:12:16 +0000 Subject: [PATCH 1/9] feat(atlas): add AtlasProvider implementing TaskProvider interface --- src/providers/atlas.ts | 280 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 src/providers/atlas.ts diff --git a/src/providers/atlas.ts b/src/providers/atlas.ts new file mode 100644 index 0000000..3bb5f23 --- /dev/null +++ b/src/providers/atlas.ts @@ -0,0 +1,280 @@ +import axios, { AxiosInstance } from "axios"; +import { + Task, + TaskProvider, + CreateTaskInput, + UpdateTaskInput, + TaskFilter, + TaskStatus, + TaskPriority, + Project, +} from "../interfaces.js"; + +// ─── Atlas raw shapes ────────────────────────────────────────────────────── + +interface AtlasTicket { + id: string; + title: string; + description?: string; + status: string; + priority?: string; + projectId: string; + dueDate?: string | null; + createdAt: string; + updatedAt: string; + labels?: { id: string; name: string }[]; +} + +interface AtlasProject { + id: string; + name: string; + description?: string; +} + +// ─── Config ─────────────────────────────────────────────────────────────── + +export interface AtlasConfig { + apiUrl: string; + email: string; + password: string; + defaultProjectId?: string; +} + +// ─── Status mapping ─────────────────────────────────────────────────────── + +function atlasStatusToTask(status: string): TaskStatus { + switch (status) { + case "DONE": + return "done"; + case "IN_PROGRESS": + return "in_progress"; + case "CANCELLED": + return "cancelled"; + default: + return "todo"; + } +} + +function taskStatusToAtlas(status: TaskStatus): string { + switch (status) { + case "done": + return "DONE"; + case "in_progress": + return "IN_PROGRESS"; + case "cancelled": + return "CANCELLED"; + default: + return "TODO"; + } +} + +// ─── Priority mapping ───────────────────────────────────────────────────── + +function atlasPriorityToTask(priority?: string): TaskPriority { + switch (priority) { + case "URGENT": + return "urgent"; + case "HIGH": + return "high"; + case "MEDIUM": + return "medium"; + default: + return "low"; + } +} + +function taskPriorityToAtlas(priority?: TaskPriority): string { + switch (priority) { + case "urgent": + return "URGENT"; + case "high": + return "HIGH"; + case "medium": + return "MEDIUM"; + default: + return "LOW"; + } +} + +// ─── Mapper ─────────────────────────────────────────────────────────────── + +function mapAtlasTicketToTask(t: AtlasTicket): Task { + return { + id: t.id, + title: t.title, + description: t.description ?? undefined, + status: atlasStatusToTask(t.status), + priority: atlasPriorityToTask(t.priority), + dueDate: t.dueDate ? new Date(t.dueDate) : undefined, + labels: t.labels?.map((l) => l.name) ?? [], + projectId: t.projectId, + createdAt: new Date(t.createdAt), + updatedAt: new Date(t.updatedAt), + }; +} + +// ─── Provider ───────────────────────────────────────────────────────────── + +export class AtlasProvider implements TaskProvider { + readonly name = "atlas"; + private client: AxiosInstance; + private defaultProjectId?: string; + private accessToken: string | null = null; + private loginCredentials: { email: string; password: string }; + + constructor(config: AtlasConfig) { + this.defaultProjectId = config.defaultProjectId; + this.loginCredentials = { email: config.email, password: config.password }; + + this.client = axios.create({ + baseURL: config.apiUrl.replace(/\/$/, ""), + headers: { "Content-Type": "application/json" }, + }); + + // Lazily inject token before each request + this.client.interceptors.request.use(async (req) => { + if (!this.accessToken) { + await this.authenticate(); + } + req.headers["Authorization"] = `Bearer ${this.accessToken}`; + return req; + }); + + // Re-authenticate on 401 and retry once + this.client.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + this.accessToken = null; + await this.authenticate(); + original.headers["Authorization"] = `Bearer ${this.accessToken}`; + return this.client.request(original); + } + return Promise.reject(error); + }, + ); + } + + private async authenticate(): Promise { + const baseURL = this.client.defaults.baseURL as string; + const response = await axios.post<{ accessToken: string }>( + `${baseURL}/api/v1/auth/login`, + this.loginCredentials, + { headers: { "Content-Type": "application/json" } }, + ); + this.accessToken = response.data.accessToken; + } + + async listTasks(filter?: TaskFilter): Promise { + const params: Record = {}; + + if (filter?.projectId) { + params.projectId = filter.projectId; + } else if (this.defaultProjectId) { + params.projectId = this.defaultProjectId; + } + + if (filter?.status) { + params.status = taskStatusToAtlas(filter.status); + } + + const response = await this.client.get<{ tickets: AtlasTicket[] }>( + "/api/v1/tickets", + { params }, + ); + let tasks = response.data.tickets.map(mapAtlasTicketToTask); + + // Atlas API does not support filtering by label name; do it client-side + if (filter?.labels && filter.labels.length > 0) { + tasks = tasks.filter((t) => + filter.labels!.some((l) => t.labels?.includes(l)), + ); + } + + return tasks; + } + + async getTask(id: string): Promise { + try { + const response = await this.client.get<{ ticket: AtlasTicket }>( + `/api/v1/tickets/${id}`, + ); + return mapAtlasTicketToTask(response.data.ticket); + } catch { + return null; + } + } + + async createTask(input: CreateTaskInput): Promise { + const projectId = input.projectId ?? this.defaultProjectId; + if (!projectId) { + throw new Error( + "Project ID is required. Set ATLAS_DEFAULT_PROJECT_ID or pass --project .", + ); + } + + const payload: Record = { title: input.title }; + if (input.description) payload.description = input.description; + if (input.priority) payload.priority = taskPriorityToAtlas(input.priority); + if (input.dueDate) payload.dueDate = input.dueDate.toISOString(); + + const response = await this.client.post<{ ticket: AtlasTicket }>( + `/api/v1/projects/${projectId}/tickets`, + payload, + ); + return mapAtlasTicketToTask(response.data.ticket); + } + + async updateTask(id: string, input: UpdateTaskInput): Promise { + const payload: Record = {}; + + if (input.title !== undefined) payload.title = input.title; + if (input.description !== undefined) + payload.description = input.description; + if (input.status !== undefined) + payload.status = taskStatusToAtlas(input.status); + if (input.priority !== undefined) + payload.priority = taskPriorityToAtlas(input.priority); + if (input.dueDate !== undefined) + payload.dueDate = input.dueDate?.toISOString() ?? null; + + const response = await this.client.patch<{ ticket: AtlasTicket }>( + `/api/v1/tickets/${id}`, + payload, + ); + return mapAtlasTicketToTask(response.data.ticket); + } + + async deleteTask(id: string): Promise { + await this.client.delete(`/api/v1/tickets/${id}`); + } + + async listProjects(): Promise { + const response = await this.client.get<{ projects: AtlasProject[] }>( + "/api/v1/projects", + ); + return response.data.projects.map((p) => ({ + id: p.id, + name: p.name, + description: p.description ?? undefined, + })); + } + + async getProject(id: string): Promise { + try { + const response = await this.client.get<{ project: AtlasProject }>( + `/api/v1/projects/${id}`, + ); + const p = (response.data as any).project ?? response.data; + return { + id: p.id, + name: p.name, + description: p.description ?? undefined, + }; + } catch { + return null; + } + } +} From aacb0f744c1f34cf600bc10e73f5f11a5513378f Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Mon, 16 Mar 2026 22:12:47 +0000 Subject: [PATCH 2/9] feat(config): add Atlas config block and ATLAS_* env-var support --- src/config.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8b9cb2a..d2ca8d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; export interface TaskManagerConfig { provider: string; @@ -14,10 +14,16 @@ export interface TaskManagerConfig { owner: string; repo: string; }; + atlas?: { + apiUrl: string; + email: string; + password: string; + defaultProjectId?: string; + }; } -const CONFIG_DIR = join(homedir(), '.config', 'task-manager'); -const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); +const CONFIG_DIR = join(homedir(), ".config", "task-manager"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); export function getConfigPath(): string { return CONFIG_FILE; @@ -29,7 +35,7 @@ export function loadConfig(): TaskManagerConfig | null { } try { - const content = readFileSync(CONFIG_FILE, 'utf-8'); + const content = readFileSync(CONFIG_FILE, "utf-8"); return JSON.parse(content) as TaskManagerConfig; } catch { return null; @@ -52,7 +58,7 @@ export function getEffectiveConfig(): TaskManagerConfig | null { // Check if we have env vars for vikunja if (process.env.VIKUNJA_API_URL && process.env.VIKUNJA_TOKEN) { return { - provider: process.env.TM_PROVIDER || 'vikunja', + provider: process.env.TM_PROVIDER || "vikunja", vikunja: { apiUrl: process.env.VIKUNJA_API_URL, token: process.env.VIKUNJA_TOKEN, @@ -63,5 +69,24 @@ export function getEffectiveConfig(): TaskManagerConfig | null { }; } + // Check if we have env vars for Atlas + if ( + process.env.ATLAS_API_URL && + process.env.ATLAS_EMAIL && + process.env.ATLAS_PASSWORD + ) { + return { + provider: process.env.TM_PROVIDER || "atlas", + atlas: { + apiUrl: process.env.ATLAS_API_URL, + email: process.env.ATLAS_EMAIL, + password: process.env.ATLAS_PASSWORD, + defaultProjectId: + process.env.ATLAS_DEFAULT_PROJECT_ID ?? + fileConfig?.atlas?.defaultProjectId, + }, + }; + } + return fileConfig; } From 7bba01ecb46b8a341f1e65d0798c480b39b5ceb6 Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Mon, 16 Mar 2026 22:14:34 +0000 Subject: [PATCH 3/9] feat(cli): wire AtlasProvider into getProvider and config command --- src/index.ts | 231 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 146 insertions(+), 85 deletions(-) diff --git a/src/index.ts b/src/index.ts index 85eddbf..9434488 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ #!/usr/bin/env node -import { Command } from 'commander'; -import chalk from 'chalk'; -import { config } from 'dotenv'; -import { VikunjaProvider, VikunjaConfig } from './providers/vikunja.js'; +import { Command } from "commander"; +import chalk from "chalk"; +import { config } from "dotenv"; +import { VikunjaProvider, VikunjaConfig } from "./providers/vikunja.js"; +import { AtlasProvider, AtlasConfig } from "./providers/atlas.js"; import { loadConfig, saveConfig, getConfigPath, getEffectiveConfig, TaskManagerConfig, -} from './config.js'; +} from "./config.js"; import type { TaskProvider, Task, @@ -17,7 +18,7 @@ import type { TaskFilter, TaskStatus, TaskPriority, -} from './interfaces.js'; +} from "./interfaces.js"; // Load environment variables (lower priority than saved config for some things) config(); @@ -29,23 +30,23 @@ function getProvider(): TaskProvider { if (!effectiveConfig) { console.error( - chalk.red('Error: No configuration found.'), - '\nRun', - chalk.cyan('tm config --provider vikunja --url --token '), - 'to configure.', + chalk.red("Error: No configuration found."), + "\nRun", + chalk.cyan("tm config --provider vikunja --url --token "), + "to configure.", ); process.exit(1); } - const providerName = effectiveConfig.provider || 'vikunja'; + const providerName = effectiveConfig.provider || "vikunja"; - if (providerName === 'vikunja') { + if (providerName === "vikunja") { if (!effectiveConfig.vikunja?.apiUrl || !effectiveConfig.vikunja?.token) { console.error( - chalk.red('Error: Vikunja configuration incomplete.'), - '\nRun', - chalk.cyan('tm config --provider vikunja --url --token '), - 'to configure.', + chalk.red("Error: Vikunja configuration incomplete."), + "\nRun", + chalk.cyan("tm config --provider vikunja --url --token "), + "to configure.", ); process.exit(1); } @@ -59,23 +60,52 @@ function getProvider(): TaskProvider { return new VikunjaProvider(vikunjaConfig); } + if (providerName === "atlas") { + if ( + !effectiveConfig.atlas?.apiUrl || + !effectiveConfig.atlas?.email || + !effectiveConfig.atlas?.password + ) { + console.error( + chalk.red("Error: Atlas configuration incomplete."), + "\nRun", + chalk.cyan( + "tm config --provider atlas --url --email --password ", + ), + "to configure.", + ); + process.exit(1); + } + + const atlasConfig: AtlasConfig = { + apiUrl: effectiveConfig.atlas.apiUrl, + email: effectiveConfig.atlas.email, + password: effectiveConfig.atlas.password, + defaultProjectId: effectiveConfig.atlas.defaultProjectId, + }; + + return new AtlasProvider(atlasConfig); + } + console.error( - chalk.red(`Error: Unknown provider "${providerName}". Supported: vikunja`), + chalk.red( + `Error: Unknown provider "${providerName}". Supported: vikunja, atlas`, + ), ); process.exit(1); } function formatTask(task: Task): string { const statusIcon = - task.status === 'done' - ? chalk.green('✓') - : task.status === 'in_progress' - ? chalk.yellow('●') - : chalk.gray('○'); + task.status === "done" + ? chalk.green("✓") + : task.status === "in_progress" + ? chalk.yellow("●") + : chalk.gray("○"); const priorityColor = - task.priority === 'urgent' + task.priority === "urgent" ? chalk.red - : task.priority === 'high' + : task.priority === "high" ? chalk.yellow : chalk.white; return `${statusIcon} ${chalk.bold(task.id)} ${priorityColor(task.title)}`; @@ -86,25 +116,29 @@ function outputJson(data: unknown): void { } program - .name('tm') - .description('Agnostic Task Manager CLI') - .version('0.1.0') - .option('--json', 'Output in JSON format (for AI agents)'); + .name("tm") + .description("Agnostic Task Manager CLI") + .version("0.1.0") + .option("--json", "Output in JSON format (for AI agents)"); // Config command program - .command('config') - .description('Configure the task manager') - .option('--provider ', 'Set provider (vikunja, github)') - .option('--url ', 'API URL for the provider') - .option('--token ', 'API token') - .option('--project ', 'Default project ID') - .option('--show', 'Show current configuration') + .command("config") + .description("Configure the task manager") + .option("--provider ", "Set provider (vikunja, atlas)") + .option("--url ", "API URL for the provider") + .option("--token ", "API token") + .option("--email ", "Atlas email address") + .option("--password ", "Atlas password") + .option("--project ", "Default project ID") + .option("--show", "Show current configuration") .action( (options: { provider?: string; url?: string; token?: string; + email?: string; + password?: string; project?: string; show?: boolean; }) => { @@ -114,33 +148,47 @@ program outputJson(currentConfig || {}); } else { if (currentConfig) { - console.log(chalk.bold('\n⚙️ Current Configuration:\n')); - console.log(` ${chalk.gray('File:')} ${getConfigPath()}`); + console.log(chalk.bold("\n⚙️ Current Configuration:\n")); + console.log(` ${chalk.gray("File:")} ${getConfigPath()}`); console.log( - ` ${chalk.gray('Provider:')} ${currentConfig.provider}`, + ` ${chalk.gray("Provider:")} ${currentConfig.provider}`, ); if (currentConfig.vikunja) { console.log( - ` ${chalk.gray('Vikunja URL:')} ${currentConfig.vikunja.apiUrl}`, + ` ${chalk.gray("Vikunja URL:")} ${currentConfig.vikunja.apiUrl}`, ); console.log( - ` ${chalk.gray('Token:')} ${currentConfig.vikunja.token.slice(0, 10)}...`, + ` ${chalk.gray("Token:")} ${currentConfig.vikunja.token.slice(0, 10)}...`, ); if (currentConfig.vikunja.defaultProjectId) { console.log( - ` ${chalk.gray('Default Project:')} ${currentConfig.vikunja.defaultProjectId}`, + ` ${chalk.gray("Default Project:")} ${currentConfig.vikunja.defaultProjectId}`, + ); + } + } + if (currentConfig.atlas) { + console.log( + ` ${chalk.gray("Atlas URL:")} ${currentConfig.atlas.apiUrl}`, + ); + console.log( + ` ${chalk.gray("Email:")} ${currentConfig.atlas.email}`, + ); + console.log(` ${chalk.gray("Password:")} ${"*".repeat(8)}`); + if (currentConfig.atlas.defaultProjectId) { + console.log( + ` ${chalk.gray("Default Project:")} ${currentConfig.atlas.defaultProjectId}`, ); } } console.log(); } else { - console.log(chalk.gray('No configuration found.')); + console.log(chalk.gray("No configuration found.")); console.log( - 'Run', + "Run", chalk.cyan( - 'tm config --provider vikunja --url --token ', + "tm config --provider vikunja --url --token ", ), - 'to configure.', + "to configure.", ); } } @@ -148,7 +196,7 @@ program } // Update config - const currentConfig = loadConfig() || { provider: 'vikunja' }; + const currentConfig = loadConfig() || { provider: "vikunja" }; const newConfig: TaskManagerConfig = { ...currentConfig }; if (options.provider) { @@ -156,11 +204,11 @@ program } if ( - options.provider === 'vikunja' || - (!options.provider && newConfig.provider === 'vikunja') + options.provider === "vikunja" || + (!options.provider && newConfig.provider === "vikunja") ) { if (!newConfig.vikunja) { - newConfig.vikunja = { apiUrl: '', token: '' }; + newConfig.vikunja = { apiUrl: "", token: "" }; } if (options.url) { newConfig.vikunja.apiUrl = options.url; @@ -173,24 +221,37 @@ program } } + if ( + options.provider === "atlas" || + (!options.provider && newConfig.provider === "atlas") + ) { + if (!newConfig.atlas) { + newConfig.atlas = { apiUrl: "", email: "", password: "" }; + } + if (options.url) newConfig.atlas.apiUrl = options.url; + if (options.email) newConfig.atlas.email = options.email; + if (options.password) newConfig.atlas.password = options.password; + if (options.project) newConfig.atlas.defaultProjectId = options.project; + } + saveConfig(newConfig); if (program.opts().json) { outputJson({ success: true, config: newConfig }); } else { - console.log(chalk.green('✓ Configuration saved to'), getConfigPath()); + console.log(chalk.green("✓ Configuration saved to"), getConfigPath()); } }, ); // List tasks program - .command('list') - .alias('ls') - .description('List all tasks') - .option('-s, --status ', 'Filter by status (todo, in_progress, done)') - .option('-p, --project ', 'Filter by project ID') - .option('--all', 'Show all tasks including completed') + .command("list") + .alias("ls") + .description("List all tasks") + .option("-s, --status ", "Filter by status (todo, in_progress, done)") + .option("-p, --project ", "Filter by project ID") + .option("--all", "Show all tasks including completed") .action( async (options: { status?: string; project?: string; all?: boolean }) => { try { @@ -201,7 +262,7 @@ program filter.status = options.status as TaskStatus; } else if (!options.all) { // By default, hide completed tasks - filter.status = 'todo'; + filter.status = "todo"; } if (options.project) { @@ -214,16 +275,16 @@ program outputJson(tasks); } else { if (tasks.length === 0) { - console.log(chalk.gray('No tasks found.')); + console.log(chalk.gray("No tasks found.")); } else { console.log(chalk.bold(`\n📋 Tasks (${tasks.length}):\n`)); - tasks.forEach((task) => console.log(' ' + formatTask(task))); + tasks.forEach((task) => console.log(" " + formatTask(task))); console.log(); } } } catch (error) { console.error( - chalk.red('Error:'), + chalk.red("Error:"), error instanceof Error ? error.message : error, ); process.exit(1); @@ -233,15 +294,15 @@ program // Add a task program - .command('add ') - .description('Create a new task') - .option('-d, --description <text>', 'Task description') + .command("add <title>") + .description("Create a new task") + .option("-d, --description <text>", "Task description") .option( - '-p, --priority <priority>', - 'Priority (low, medium, high, urgent)', - 'medium', + "-p, --priority <priority>", + "Priority (low, medium, high, urgent)", + "medium", ) - .option('--project <id>', 'Project ID') + .option("--project <id>", "Project ID") .action( async ( title: string, @@ -252,7 +313,7 @@ program const input: CreateTaskInput = { title, description: options.description, - priority: (options.priority as TaskPriority) || 'medium', + priority: (options.priority as TaskPriority) || "medium", projectId: options.project, }; @@ -261,11 +322,11 @@ program if (program.opts().json) { outputJson(task); } else { - console.log(chalk.green('✓ Task created:'), formatTask(task)); + console.log(chalk.green("✓ Task created:"), formatTask(task)); } } catch (error) { console.error( - chalk.red('Error:'), + chalk.red("Error:"), error instanceof Error ? error.message : error, ); process.exit(1); @@ -275,21 +336,21 @@ program // Complete a task program - .command('done <id>') - .description('Mark a task as done') + .command("done <id>") + .description("Mark a task as done") .action(async (id: string) => { try { const provider = getProvider(); - const task = await provider.updateTask(id, { status: 'done' }); + const task = await provider.updateTask(id, { status: "done" }); if (program.opts().json) { outputJson(task); } else { - console.log(chalk.green('✓ Task completed:'), formatTask(task)); + console.log(chalk.green("✓ Task completed:"), formatTask(task)); } } catch (error) { console.error( - chalk.red('Error:'), + chalk.red("Error:"), error instanceof Error ? error.message : error, ); process.exit(1); @@ -298,9 +359,9 @@ program // Delete a task program - .command('delete <id>') - .alias('rm') - .description('Delete a task') + .command("delete <id>") + .alias("rm") + .description("Delete a task") .action(async (id: string) => { try { const provider = getProvider(); @@ -309,11 +370,11 @@ program if (program.opts().json) { outputJson({ success: true, id }); } else { - console.log(chalk.green('✓ Task deleted')); + console.log(chalk.green("✓ Task deleted")); } } catch (error) { console.error( - chalk.red('Error:'), + chalk.red("Error:"), error instanceof Error ? error.message : error, ); process.exit(1); @@ -322,15 +383,15 @@ program // List projects program - .command('projects') - .description('List all projects') + .command("projects") + .description("List all projects") .action(async () => { try { const provider = getProvider(); if (!provider.listProjects) { console.error( - chalk.red('Error: This provider does not support projects.'), + chalk.red("Error: This provider does not support projects."), ); process.exit(1); } @@ -341,7 +402,7 @@ program outputJson(projects); } else { if (projects.length === 0) { - console.log(chalk.gray('No projects found.')); + console.log(chalk.gray("No projects found.")); } else { console.log(chalk.bold(`\n📁 Projects (${projects.length}):\n`)); projects.forEach((p) => @@ -352,7 +413,7 @@ program } } catch (error) { console.error( - chalk.red('Error:'), + chalk.red("Error:"), error instanceof Error ? error.message : error, ); process.exit(1); From 4400121960658c5a988e4c4d78438b5d4bb74360 Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Mon, 16 Mar 2026 22:15:05 +0000 Subject: [PATCH 4/9] docs: add Atlas provider setup instructions and env vars --- .env.example | 6 ++++++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/.env.example b/.env.example index 9bdb097..2dae72b 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ TM_PROVIDER=vikunja VIKUNJA_API_URL=https://tasks.excelsi.dev VIKUNJA_TOKEN=your_api_token_here VIKUNJA_DEFAULT_PROJECT_ID=1 + +# Atlas Configuration +ATLAS_API_URL=http://localhost:3000 +ATLAS_EMAIL=user@example.com +ATLAS_PASSWORD=your_password_here +ATLAS_DEFAULT_PROJECT_ID= diff --git a/README.md b/README.md index 0e5d189..efa4b2e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,30 @@ tm list tm add "My new task" ``` +## Atlas Provider + +```bash +# Configure Atlas +tm config --provider atlas --url http://localhost:3000 --email user@example.com --password yourpassword +tm config --project <atlas-project-uuid> # Set default project + +# Use it +tm projects # List Atlas projects +tm list # List tickets (todo status) +tm list --all # All tickets +tm add "New ticket" # Create ticket +tm done <ticket-uuid> # Mark as done +``` + +Or set environment variables instead of saved config: + +```env +ATLAS_API_URL=http://localhost:3000 +ATLAS_EMAIL=user@example.com +ATLAS_PASSWORD=yourpassword +ATLAS_DEFAULT_PROJECT_ID=<uuid> +``` + ## Usage ```bash @@ -72,5 +96,6 @@ tm config --show --json ## Supported Providers - [x] Vikunja +- [x] Atlas - [ ] GitHub Projects (coming soon) - [ ] Linear (coming soon) From 72e94135bd0e47a1f72344536404b997ec40e1a6 Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Tue, 17 Mar 2026 10:57:41 +0000 Subject: [PATCH 5/9] chore: update bun lockfile --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index bf0f3bf..197a3f9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@excelsi/task-manager", From ce6bcbd2d3f4e902799bee6dea176cd38bbdb93d Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Tue, 17 Mar 2026 11:00:14 +0000 Subject: [PATCH 6/9] chore: bump version to 0.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aaf6d53..7027fb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@excelsi-innovations/task-manager", - "version": "0.1.0", + "version": "0.1.1", "description": "Agnostic task management CLI with support for Vikunja, GitHub Projects, and more.", "author": "Excelsi Innovations", "license": "private", From 8ab26acc30f9cfb9b96d42a040ecab492ecf8bf1 Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Tue, 17 Mar 2026 11:03:16 +0000 Subject: [PATCH 7/9] ci: publish only on git tags, derive version from tag --- .github/workflows/publish.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 820ecf0..aafd38d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,8 @@ name: Publish on: - release: - types: [published] push: - branches: [main, master] - tags: ['v*'] + tags: ["v*"] env: REGISTRY: ghcr.io @@ -29,9 +26,9 @@ jobs: - name: Setup Node (for npm publish) uses: actions/setup-node@v4 with: - node-version: '22' - registry-url: 'https://npm.pkg.github.com' - scope: '@excelsi-innovations' + node-version: "22" + registry-url: "https://npm.pkg.github.com" + scope: "@excelsi-innovations" - name: Install dependencies run: bun install @@ -39,6 +36,11 @@ jobs: - name: Build run: bun run build + - name: Set version from tag + run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + - name: Publish to GitHub Packages run: npm publish env: From 441822226b1fd1a5a3623ee08086c5dffa2bd83a Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Tue, 17 Mar 2026 11:13:14 +0000 Subject: [PATCH 8/9] docs: add providers API reference --- docs/providers.md | 211 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/providers.md diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 0000000..288e387 --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,211 @@ +# Providers + +Providers are adapters between the `tm` CLI and a task management backend. Each provider implements the `TaskProvider` interface, translating the generic task model to and from the backend's own API and data types. + +--- + +## `TaskProvider` Interface + +Defined in `src/interfaces.ts`. All providers must implement this interface. + +```typescript +interface TaskProvider { + readonly name: string; + + listTasks(filter?: TaskFilter): Promise<Task[]>; + getTask(id: string): Promise<Task | null>; + createTask(input: CreateTaskInput): Promise<Task>; + updateTask(id: string, input: UpdateTaskInput): Promise<Task>; + deleteTask(id: string): Promise<void>; + + // Optional + listProjects?(): Promise<Project[]>; + getProject?(id: string): Promise<Project | null>; +} +``` + +### Methods + +| Method | Returns | Description | +| ----------------------- | ----------------- | ---------------------------------------------- | +| `listTasks(filter?)` | `Task[]` | List tasks, optionally filtered | +| `getTask(id)` | `Task \| null` | Fetch a single task by ID; `null` if not found | +| `createTask(input)` | `Task` | Create a new task | +| `updateTask(id, input)` | `Task` | Partially update a task | +| `deleteTask(id)` | `void` | Delete a task | +| `listProjects?()` | `Project[]` | List all projects (optional) | +| `getProject?(id)` | `Project \| null` | Fetch a single project (optional) | + +--- + +## Core Types + +### `Task` + +```typescript +interface Task { + id: string; + title: string; + description?: string; + status: TaskStatus; + priority?: TaskPriority; + dueDate?: Date; + labels?: string[]; + projectId?: string; + createdAt?: Date; + updatedAt?: Date; +} +``` + +### `TaskStatus` + +```typescript +type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; +``` + +### `TaskPriority` + +```typescript +type TaskPriority = "low" | "medium" | "high" | "urgent"; +``` + +### `CreateTaskInput` + +```typescript +interface CreateTaskInput { + title: string; + description?: string; + priority?: TaskPriority; + dueDate?: Date; + labels?: string[]; + projectId?: string; +} +``` + +### `UpdateTaskInput` + +All fields optional — only provided fields are updated. + +```typescript +interface UpdateTaskInput { + title?: string; + description?: string; + status?: TaskStatus; + priority?: TaskPriority; + dueDate?: Date; + labels?: string[]; +} +``` + +### `TaskFilter` + +```typescript +interface TaskFilter { + status?: TaskStatus; + priority?: TaskPriority; + projectId?: string; + labels?: string[]; +} +``` + +### `Project` + +```typescript +interface Project { + id: string; + name: string; + description?: string; +} +``` + +--- + +## `AtlasProvider` + +File: `src/providers/atlas.ts` + +### Config + +```typescript +interface AtlasConfig { + apiUrl: string; // Base URL, e.g. "http://localhost:3000" + email: string; // Atlas user email + password: string; // Atlas user password + defaultProjectId?: string; // UUID — used when no projectId is passed to createTask/listTasks +} +``` + +### Status Mapping + +| Atlas API value | `TaskStatus` | +| --------------- | ------------- | +| `BACKLOG` | `todo` | +| `TODO` | `todo` | +| `IN_REVIEW` | `todo` | +| `IN_PROGRESS` | `in_progress` | +| `DONE` | `done` | +| `CANCELLED` | `cancelled` | + +### Priority Mapping + +| Atlas API value | `TaskPriority` | +| --------------- | -------------- | +| `NONE` | `low` | +| `LOW` | `low` | +| `MEDIUM` | `medium` | +| `HIGH` | `high` | +| `URGENT` | `urgent` | + +### Notes + +- **Authentication** is lazy: the first API call triggers `POST /api/v1/auth/login` automatically. The token is stored in memory for the lifetime of the provider instance. +- **401 handling**: a single re-authentication retry is performed transparently. If it fails again, the error propagates. +- **Create ticket** uses a project-nested route (`POST /api/v1/projects/:projectId/tickets`). A `projectId` must be available — either from `CreateTaskInput.projectId` or `AtlasConfig.defaultProjectId`. +- **All other ticket operations** use the top-level route (`/api/v1/tickets/:id`). +- **Label filtering** is performed client-side — the Atlas API does not support filtering by label name. + +--- + +## `VikunjaProvider` + +File: `src/providers/vikunja.ts` + +### Config + +```typescript +interface VikunjaConfig { + apiUrl: string; // Base URL, e.g. "https://tasks.example.com" + token: string; // Vikunja API token + defaultProjectId?: number; // Numeric project ID (not a UUID) +} +``` + +### Status Mapping + +Vikunja uses a boolean `done` field rather than a status enum. + +| Vikunja field | `TaskStatus` | +| ------------- | ------------ | +| `done: false` | `todo` | +| `done: true` | `done` | + +> `in_progress` and `cancelled` are not supported by Vikunja and will not round-trip correctly. + +### Priority Mapping + +Vikunja uses a numeric `priority` field (0–4). + +| Vikunja `priority` | `TaskPriority` | +| ------------------ | -------------- | +| 0–1 | `low` | +| 2 | `medium` | +| 3 | `high` | +| 4 | `urgent` | + +### Notes + +- **`defaultProjectId` is a `number`**, not a string UUID. Passing a non-numeric string will result in a `NaN` project ID. +- **`listTasks`** fetches all tasks (`GET /api/v1/tasks/all`) and applies `projectId`, status, and label filters client-side, except `done` which is passed as a query param. +- **`createTask`** uses `PUT /api/v1/projects/:id/tasks` (Vikunja's creation endpoint). +- **`updateTask`** uses `POST /api/v1/tasks/:id` (not `PATCH`). +- Labels are not written on create — Vikunja requires a separate labels API call not currently implemented. From 4658c699e272923d27b7f2f52e6de8dfff43208f Mon Sep 17 00:00:00 2001 From: Thiago Santos <thiago.santos@doutorvida.pt> Date: Tue, 17 Mar 2026 23:15:39 +0000 Subject: [PATCH 9/9] fix(atlas): correct API response shapes for listTasks and getProject - listTasks: API returns { data: [] } not { tickets: [] } - getProject: API returns the project object directly, not wrapped in { project: ... } - Remove as-any cast now that the shape is properly typed --- src/providers/atlas.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/providers/atlas.ts b/src/providers/atlas.ts index 3bb5f23..c894778 100644 --- a/src/providers/atlas.ts +++ b/src/providers/atlas.ts @@ -180,11 +180,11 @@ export class AtlasProvider implements TaskProvider { params.status = taskStatusToAtlas(filter.status); } - const response = await this.client.get<{ tickets: AtlasTicket[] }>( + const response = await this.client.get<{ data: AtlasTicket[] }>( "/api/v1/tickets", { params }, ); - let tasks = response.data.tickets.map(mapAtlasTicketToTask); + let tasks = response.data.data.map(mapAtlasTicketToTask); // Atlas API does not support filtering by label name; do it client-side if (filter?.labels && filter.labels.length > 0) { @@ -264,10 +264,10 @@ export class AtlasProvider implements TaskProvider { async getProject(id: string): Promise<Project | null> { try { - const response = await this.client.get<{ project: AtlasProject }>( + const response = await this.client.get<AtlasProject>( `/api/v1/projects/${id}`, ); - const p = (response.data as any).project ?? response.data; + const p = response.data; return { id: p.id, name: p.name,