trelly 0.1.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.
Files changed (46) hide show
  1. package/README.md +191 -0
  2. package/bin/run-ts +31 -0
  3. package/bin/trelly +2 -0
  4. package/bin/trelly-mcp +2 -0
  5. package/package.json +64 -0
  6. package/src/api/client.ts +332 -0
  7. package/src/api/http.ts +86 -0
  8. package/src/auth/browser-flow.ts +217 -0
  9. package/src/auth/profiles.ts +163 -0
  10. package/src/cli/commands/actions.ts +17 -0
  11. package/src/cli/commands/api.ts +46 -0
  12. package/src/cli/commands/auth.ts +248 -0
  13. package/src/cli/commands/boards.ts +152 -0
  14. package/src/cli/commands/cards.ts +194 -0
  15. package/src/cli/commands/checklists.ts +79 -0
  16. package/src/cli/commands/custom-fields.ts +75 -0
  17. package/src/cli/commands/labels.ts +47 -0
  18. package/src/cli/commands/lists.ts +54 -0
  19. package/src/cli/commands/members.ts +14 -0
  20. package/src/cli/commands/orgs.ts +23 -0
  21. package/src/cli/commands/run.ts +32 -0
  22. package/src/cli/commands/search.ts +23 -0
  23. package/src/cli/commands/ui.ts +48 -0
  24. package/src/cli/commands/webhooks.ts +44 -0
  25. package/src/cli/context.ts +70 -0
  26. package/src/cli/index.ts +47 -0
  27. package/src/cli/ui/app.tsx +753 -0
  28. package/src/cli/ui/custom-fields.ts +75 -0
  29. package/src/cli/ui/palette.ts +69 -0
  30. package/src/cli/ui/shapes.ts +68 -0
  31. package/src/cli/ui/static.tsx +382 -0
  32. package/src/index.test.ts +117 -0
  33. package/src/mcp/handlers.ts +61 -0
  34. package/src/mcp/server.ts +17 -0
  35. package/src/mcp/tools/api.ts +27 -0
  36. package/src/mcp/tools/boards.ts +116 -0
  37. package/src/mcp/tools/cards.ts +138 -0
  38. package/src/mcp/tools/checklists.ts +40 -0
  39. package/src/mcp/tools/index.ts +22 -0
  40. package/src/mcp/tools/labels.ts +40 -0
  41. package/src/mcp/tools/lists.ts +37 -0
  42. package/src/mcp/tools/profiles.ts +40 -0
  43. package/src/mcp/tools/search.ts +31 -0
  44. package/src/mcp/tools/webhooks.ts +52 -0
  45. package/src/util/runtime.ts +39 -0
  46. package/src/version.ts +15 -0
@@ -0,0 +1,86 @@
1
+ export type JsonValue =
2
+ | string
3
+ | number
4
+ | boolean
5
+ | null
6
+ | JsonValue[]
7
+ | { [key: string]: JsonValue };
8
+
9
+ export type Query = Record<string, string | number | boolean | undefined>;
10
+
11
+ export class TrelloError extends Error {
12
+ constructor(
13
+ message: string,
14
+ readonly status: number,
15
+ readonly body: unknown,
16
+ ) {
17
+ super(message);
18
+ this.name = "TrelloError";
19
+ }
20
+ }
21
+
22
+ export type RequestOptions = {
23
+ timeoutMs?: number;
24
+ maxRetries?: number;
25
+ };
26
+
27
+ const DEFAULT_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_MAX_RETRIES = 3;
29
+
30
+ function sleep(ms: number): Promise<void> {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
33
+
34
+ function retryAfterMs(res: Response, attempt: number): number {
35
+ const header = res.headers.get("retry-after");
36
+ if (header) {
37
+ const seconds = Number(header);
38
+ if (Number.isFinite(seconds)) return seconds * 1000;
39
+ }
40
+ return Math.min(1000 * 2 ** attempt, 8000);
41
+ }
42
+
43
+ export async function trelloFetch(
44
+ url: URL,
45
+ init: RequestInit,
46
+ opts: RequestOptions = {},
47
+ ): Promise<Response> {
48
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
49
+ const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
50
+
51
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
52
+ const res = await fetch(url, {
53
+ ...init,
54
+ signal: AbortSignal.timeout(timeoutMs),
55
+ });
56
+
57
+ if (res.status !== 429 || attempt === maxRetries) {
58
+ return res;
59
+ }
60
+
61
+ await sleep(retryAfterMs(res, attempt));
62
+ }
63
+
64
+ throw new TrelloError("Rate limit exceeded", 429, null);
65
+ }
66
+
67
+ export function parseTrelloResponse(text: string): unknown {
68
+ if (!text) return null;
69
+ try {
70
+ return JSON.parse(text);
71
+ } catch {
72
+ return text;
73
+ }
74
+ }
75
+
76
+ export function trelloErrorMessage(parsed: unknown, status: number): string {
77
+ if (
78
+ typeof parsed === "object" &&
79
+ parsed &&
80
+ "message" in parsed &&
81
+ typeof (parsed as { message: unknown }).message === "string"
82
+ ) {
83
+ return (parsed as { message: string }).message;
84
+ }
85
+ return `Trello API ${status}`;
86
+ }
@@ -0,0 +1,217 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer, type Server } from "node:http";
3
+ import { openInBrowser } from "../util/runtime.ts";
4
+ import { type AuthScope, authLoginUrl } from "./profiles.ts";
5
+
6
+ export type BrowserAuthResult = {
7
+ token: string;
8
+ port: number;
9
+ returnUrl: string;
10
+ };
11
+
12
+ export type BrowserAuthError = {
13
+ error: string;
14
+ port: number;
15
+ };
16
+
17
+ const DEFAULT_PORT = 14189;
18
+
19
+ function callbackCaptureHtml(state: string): string {
20
+ return `<!doctype html>
21
+ <html lang="en">
22
+ <head><meta charset="utf-8"><title>trelly</title></head>
23
+ <body style="font-family: system-ui; max-width: 32rem; margin: 4rem auto; text-align: center;">
24
+ <p id="status">Finishing authorization…</p>
25
+ <script>
26
+ (function () {
27
+ var state = ${JSON.stringify(state)};
28
+ var params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
29
+ var token = params.get("token");
30
+ var error = params.get("error");
31
+ var status = document.getElementById("status");
32
+
33
+ function post(path, body) {
34
+ return fetch(path, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify(body)
38
+ });
39
+ }
40
+
41
+ if (error) {
42
+ post("/callback/error", { error: error, state: state }).finally(function () {
43
+ status.textContent = "Authorization denied: " + error;
44
+ });
45
+ return;
46
+ }
47
+
48
+ if (!token) {
49
+ status.textContent = "No token in callback URL. Check allowed origins in trelly auth setup.";
50
+ post("/callback/error", {
51
+ error: "No token in callback fragment — add http://127.0.0.1:${DEFAULT_PORT} to allowed origins",
52
+ state: state
53
+ });
54
+ return;
55
+ }
56
+
57
+ post("/callback/token", { token: token, state: state })
58
+ .then(function (res) { return res.json(); })
59
+ .then(function () {
60
+ status.textContent = "Connected! Return to your terminal.";
61
+ })
62
+ .catch(function () {
63
+ status.textContent = "Could not send token to trelly.";
64
+ });
65
+ })();
66
+ </script>
67
+ </body>
68
+ </html>`;
69
+ }
70
+
71
+ export function browserAuthorizeUrl(
72
+ apiKey: string,
73
+ port: number,
74
+ scope: AuthScope[] = ["read", "write"],
75
+ ): string {
76
+ return authLoginUrl(apiKey, {
77
+ returnUrl: `http://127.0.0.1:${port}/callback`,
78
+ scope,
79
+ expiration: "30days",
80
+ });
81
+ }
82
+
83
+ async function readJsonBody(
84
+ req: import("node:http").IncomingMessage,
85
+ ): Promise<unknown> {
86
+ const chunks: Buffer[] = [];
87
+ for await (const chunk of req) {
88
+ chunks.push(Buffer.from(chunk));
89
+ }
90
+ const text = Buffer.concat(chunks).toString("utf8");
91
+ return text ? JSON.parse(text) : {};
92
+ }
93
+
94
+ export async function captureTokenViaBrowser(
95
+ apiKey: string,
96
+ opts: {
97
+ port?: number;
98
+ timeoutMs?: number;
99
+ openBrowser?: boolean;
100
+ scope?: AuthScope[];
101
+ } = {},
102
+ ): Promise<BrowserAuthResult | BrowserAuthError> {
103
+ const port = opts.port ?? DEFAULT_PORT;
104
+ const timeoutMs = opts.timeoutMs ?? 120_000;
105
+ const returnUrl = `http://127.0.0.1:${port}/callback`;
106
+ const state = randomBytes(16).toString("hex");
107
+ const authorizeUrl = browserAuthorizeUrl(apiKey, port, opts.scope);
108
+
109
+ return new Promise((resolve) => {
110
+ let settled = false;
111
+ let httpServer: Server | undefined;
112
+
113
+ const finish = (result: BrowserAuthResult | BrowserAuthError) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ httpServer?.close();
118
+ resolve(result);
119
+ };
120
+
121
+ const timer = setTimeout(() => {
122
+ finish({
123
+ error: "Timed out waiting for browser authorization",
124
+ port,
125
+ });
126
+ }, timeoutMs);
127
+
128
+ httpServer = createServer(async (req, res) => {
129
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
130
+
131
+ if (url.pathname === "/callback" && req.method === "GET") {
132
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
133
+ res.end(callbackCaptureHtml(state));
134
+ return;
135
+ }
136
+
137
+ if (url.pathname === "/callback/token" && req.method === "POST") {
138
+ try {
139
+ const body = (await readJsonBody(req)) as { token?: string; state?: string };
140
+ if (body.state !== state) {
141
+ res.writeHead(403, { "Content-Type": "application/json" });
142
+ res.end(JSON.stringify({ ok: false, error: "invalid state" }));
143
+ return;
144
+ }
145
+ if (!body.token) {
146
+ res.writeHead(400, { "Content-Type": "application/json" });
147
+ res.end(JSON.stringify({ ok: false }));
148
+ finish({ error: "Empty token in callback", port });
149
+ return;
150
+ }
151
+ res.writeHead(200, { "Content-Type": "application/json" });
152
+ res.end(JSON.stringify({ ok: true }));
153
+ finish({ token: body.token, port, returnUrl });
154
+ } catch {
155
+ res.writeHead(500, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify({ ok: false }));
157
+ finish({ error: "Failed to read callback token", port });
158
+ }
159
+ return;
160
+ }
161
+
162
+ if (url.pathname === "/callback/error" && req.method === "POST") {
163
+ try {
164
+ const body = (await readJsonBody(req)) as { error?: string; state?: string };
165
+ if (body.state !== state) {
166
+ res.writeHead(403, { "Content-Type": "application/json" });
167
+ res.end(JSON.stringify({ ok: false }));
168
+ return;
169
+ }
170
+ res.writeHead(200, { "Content-Type": "application/json" });
171
+ res.end(JSON.stringify({ ok: false }));
172
+ finish({ error: body.error ?? "Authorization denied", port });
173
+ } catch {
174
+ res.writeHead(500, { "Content-Type": "application/json" });
175
+ res.end(JSON.stringify({ ok: false }));
176
+ finish({ error: "Authorization failed", port });
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (url.pathname === "/" || url.pathname === "/start") {
182
+ res.writeHead(302, { Location: authorizeUrl });
183
+ res.end();
184
+ return;
185
+ }
186
+
187
+ res.writeHead(404, { "Content-Type": "text/plain" });
188
+ res.end("Not found");
189
+ });
190
+
191
+ httpServer.listen(port, "127.0.0.1", () => {
192
+ if (opts.openBrowser !== false) {
193
+ openInBrowser(`http://127.0.0.1:${port}/start`).catch(() => {
194
+ process.stderr.write(
195
+ `Open this URL in your browser:\nhttp://127.0.0.1:${port}/start\n`,
196
+ );
197
+ });
198
+ } else {
199
+ process.stderr.write(
200
+ `Open this URL in your browser:\nhttp://127.0.0.1:${port}/start\n`,
201
+ );
202
+ }
203
+ });
204
+
205
+ httpServer.on("error", (err) => {
206
+ finish({
207
+ error:
208
+ err instanceof Error
209
+ ? `Local callback server failed on port ${port}: ${err.message}`
210
+ : `Local callback server failed on port ${port}`,
211
+ port,
212
+ });
213
+ });
214
+ });
215
+ }
216
+
217
+ export const SETUP_ALLOWED_ORIGIN = `http://127.0.0.1:${DEFAULT_PORT}`;
@@ -0,0 +1,163 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ const profileSchema = z.object({
7
+ apiKey: z.string().min(1),
8
+ token: z.string().min(1),
9
+ label: z.string().optional(),
10
+ });
11
+
12
+ const configSchema = z.object({
13
+ version: z.literal(1),
14
+ appApiKey: z.string().min(1).optional(),
15
+ defaultProfile: z.string().min(1),
16
+ profiles: z.record(z.string(), profileSchema),
17
+ });
18
+
19
+ export type Profile = z.infer<typeof profileSchema>;
20
+ export type Config = z.infer<typeof configSchema>;
21
+
22
+ const LEGACY_CONFIG_DIR = join(homedir(), ".config", "trello-cli");
23
+ const CONFIG_DIR = join(homedir(), ".config", "trelly");
24
+ const LEGACY_CONFIG_PATH = join(LEGACY_CONFIG_DIR, "config.json");
25
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
26
+
27
+ function readConfigPath(): string {
28
+ if (existsSync(CONFIG_PATH)) return CONFIG_PATH;
29
+ if (existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH;
30
+ return CONFIG_PATH;
31
+ }
32
+
33
+ export function configPath(): string {
34
+ return CONFIG_PATH;
35
+ }
36
+
37
+ function emptyConfig(): Config {
38
+ return { version: 1, defaultProfile: "default", profiles: {} };
39
+ }
40
+
41
+ export function loadConfig(): Config {
42
+ const path = readConfigPath();
43
+ if (!existsSync(path)) {
44
+ return emptyConfig();
45
+ }
46
+
47
+ const raw = readFileSync(path, "utf8");
48
+ return configSchema.parse(JSON.parse(raw));
49
+ }
50
+
51
+ export function saveConfig(config: Config): void {
52
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
53
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, {
54
+ mode: 0o600,
55
+ });
56
+ chmodSync(CONFIG_PATH, 0o600);
57
+ }
58
+
59
+ export function resolveProfile(name?: string): {
60
+ name: string;
61
+ profile: Profile;
62
+ } {
63
+ const fromEnv = process.env.TRELLO_PROFILE;
64
+ const profileName = name ?? fromEnv ?? loadConfig().defaultProfile;
65
+
66
+ const envKey = process.env.TRELLO_API_KEY;
67
+ const envToken = process.env.TRELLO_TOKEN;
68
+ if (envKey && envToken && !name && !fromEnv) {
69
+ return {
70
+ name: "env",
71
+ profile: { apiKey: envKey, token: envToken, label: "environment" },
72
+ };
73
+ }
74
+
75
+ const config = loadConfig();
76
+ const profile = config.profiles[profileName];
77
+ if (!profile) {
78
+ const known = Object.keys(config.profiles);
79
+ throw new Error(
80
+ known.length === 0
81
+ ? `No Trello profile "${profileName}". Run: trelly auth login`
82
+ : `Unknown profile "${profileName}". Known: ${known.join(", ")}`,
83
+ );
84
+ }
85
+
86
+ return { name: profileName, profile };
87
+ }
88
+
89
+ export function upsertProfile(
90
+ name: string,
91
+ creds: Profile,
92
+ makeDefault = false,
93
+ ): Config {
94
+ const config = loadConfig();
95
+ config.profiles[name] = creds;
96
+ if (makeDefault || Object.keys(config.profiles).length === 1) {
97
+ config.defaultProfile = name;
98
+ }
99
+ saveConfig(config);
100
+ return config;
101
+ }
102
+
103
+ export function removeProfile(name: string): Config {
104
+ const config = loadConfig();
105
+ if (!config.profiles[name]) {
106
+ throw new Error(`Profile "${name}" does not exist`);
107
+ }
108
+ delete config.profiles[name];
109
+ const remaining = Object.keys(config.profiles);
110
+ if (remaining.length === 0) {
111
+ config.defaultProfile = "default";
112
+ } else if (config.defaultProfile === name) {
113
+ config.defaultProfile = remaining[0]!;
114
+ }
115
+ saveConfig(config);
116
+ return config;
117
+ }
118
+
119
+ export function setDefaultProfile(name: string): Config {
120
+ const config = loadConfig();
121
+ if (!config.profiles[name]) {
122
+ throw new Error(`Profile "${name}" does not exist`);
123
+ }
124
+ config.defaultProfile = name;
125
+ saveConfig(config);
126
+ return config;
127
+ }
128
+
129
+ export function setAppApiKey(apiKey: string): Config {
130
+ const config = loadConfig();
131
+ config.appApiKey = apiKey;
132
+ saveConfig(config);
133
+ return config;
134
+ }
135
+
136
+ export function getAppApiKey(): string | undefined {
137
+ return process.env.TRELLO_APP_API_KEY ?? loadConfig().appApiKey;
138
+ }
139
+
140
+ export type AuthScope = "read" | "write" | "account";
141
+
142
+ export type AuthUrlOptions = {
143
+ returnUrl?: string;
144
+ expiration?: "1hour" | "1day" | "30days" | "never";
145
+ scope?: AuthScope[];
146
+ appName?: string;
147
+ };
148
+
149
+ export function authLoginUrl(apiKey: string, opts: AuthUrlOptions = {}): string {
150
+ const scope = opts.scope ?? ["read", "write"];
151
+ const params = new URLSearchParams({
152
+ key: apiKey,
153
+ name: opts.appName ?? "trelly",
154
+ expiration: opts.expiration ?? "30days",
155
+ response_type: "token",
156
+ scope: scope.join(","),
157
+ });
158
+ if (opts.returnUrl) {
159
+ params.set("callback_method", "fragment");
160
+ params.set("return_url", opts.returnUrl);
161
+ }
162
+ return `https://trello.com/1/authorize?${params}`;
163
+ }
@@ -0,0 +1,17 @@
1
+ import type { Command } from "commander";
2
+ import { getClient } from "../context.ts";
3
+ import { rootOpts, run } from "./run.ts";
4
+
5
+ export function registerActionCommands(program: Command): void {
6
+ const actions = program.command("actions").description("Action (audit) operations");
7
+
8
+ actions
9
+ .command("get <id>")
10
+ .description("Get an action by id")
11
+ .action((id, _opts, cmd) =>
12
+ run(cmd, async () => {
13
+ const { client } = getClient(rootOpts(cmd).profile);
14
+ return client.actionGet(id);
15
+ }),
16
+ );
17
+ }
@@ -0,0 +1,46 @@
1
+ import type { Command } from "commander";
2
+ import type { JsonValue } from "../../api/client.ts";
3
+ import { TrelloError } from "../../api/client.ts";
4
+ import {
5
+ failure,
6
+ getClient,
7
+ parseJsonFlag,
8
+ parseKvPairs,
9
+ printResult,
10
+ success,
11
+ } from "../context.ts";
12
+ import { rootOpts } from "./run.ts";
13
+
14
+ export function registerApiCommand(program: Command): void {
15
+ program
16
+ .command("api")
17
+ .description("Raw Trello REST call (escape hatch)")
18
+ .requiredOption("-X, --method <method>", "HTTP method")
19
+ .requiredOption("--path <path>", "API path e.g. /boards/{id}")
20
+ .option("--query <kv...>", "Query key=value pairs")
21
+ .option("--body <json>", "JSON request body")
22
+ .action(async (opts, cmd) => {
23
+ const root = rootOpts(cmd);
24
+ try {
25
+ const { profileName, client } = getClient(root.profile);
26
+ const body = parseJsonFlag(opts.body, "--body");
27
+ const data = await client.request(
28
+ opts.method,
29
+ opts.path,
30
+ parseKvPairs(opts.query),
31
+ body as JsonValue | undefined,
32
+ );
33
+ printResult(success(profileName, data), root);
34
+ } catch (err) {
35
+ if (err instanceof TrelloError) {
36
+ printResult(
37
+ failure(err.message, { status: err.status, details: err.body }),
38
+ root,
39
+ );
40
+ } else {
41
+ printResult(failure(err instanceof Error ? err.message : String(err)), root);
42
+ }
43
+ process.exitCode = 1;
44
+ }
45
+ });
46
+ }