Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/checklist-item-crud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trello-cli": minor
---

Add checklist item CRUD commands: card:add-checklist-item, card:delete-checklist-item, card:update-checklist-item, card:delete-checklist. Includes resolveChecklistId helper on BaseCommand.
14 changes: 14 additions & 0 deletions packages/trello-cli/src/BaseCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,20 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
await run([this.id!, "--help"]);
}

// Resolves a checklist name or ID to its Trello ID.
// Skips the API call when the value is already a 24-char hex ID.
protected async resolveChecklistId(cardId: string, checklist: string): Promise<string> {
if (/^[a-f0-9]{24}$/.test(checklist)) {
return checklist;
}
const checklists = (await this.client.cards.getCardChecklists({ id: cardId })) as any[];
const found = checklists.find((cl: any) => cl.name === checklist || cl.id === checklist);
if (!found) {
this.error(`No checklist found matching "${checklist}"`);
}
return found.id;
}

protected async catch(err: Error & { exitCode?: number }): Promise<any> {
// add any custom logic to handle errors from the command
// or simply return the parent class error handling
Expand Down
48 changes: 48 additions & 0 deletions packages/trello-cli/src/commands/card/add-checklist-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { BaseCommand } from "../../BaseCommand";
import { Flags } from "@oclif/core";

export default class CardAddChecklistItem extends BaseCommand<typeof CardAddChecklistItem> {
static description = "Add an item to a checklist on a card";

static flags = {
board: Flags.string({ required: true }),
list: Flags.string({ required: true }),
card: Flags.string({ required: true }),
checklist: Flags.string({ required: true, description: "Checklist name or ID" }),
item: Flags.string({ required: true, description: "Name for the new checklist item" }),
pos: Flags.string({
description: 'Position of the new item: "top", "bottom", or a positive number',
}),
};

async run(): Promise<void> {
const checklistId = await this.resolveChecklistId(this.lookups.card, this.flags.checklist);

// Build the API payload. pos is optional — omitting it lets Trello default to bottom.
const payload: Record<string, any> = { id: checklistId, name: this.flags.item };

if (this.flags.pos !== undefined) {
payload.pos = this.parsePos(this.flags.pos);
}

const item = await (this.client.checklists as any).createChecklistCheckItems(payload);
this.output(item);
}

// Trello accepts "top"/"bottom" as strings and any positive float as a number.
// The CLI always receives strings, so numeric strings must be coerced.
private parsePos(pos: string): string | number {
if (pos === "top" || pos === "bottom") return pos;
const n = parseFloat(pos);
if (isNaN(n)) this.error(`Invalid --pos value "${pos}". Use "top", "bottom", or a number.`);
return n;
}

protected toData(data: any) {
return {
id: data.id,
name: data.name,
pos: data.pos,
};
}
}
55 changes: 55 additions & 0 deletions packages/trello-cli/src/commands/card/delete-checklist-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { BaseCommand } from "../../BaseCommand";
import { Flags } from "@oclif/core";

export default class CardDeleteChecklistItem extends BaseCommand<typeof CardDeleteChecklistItem> {
static description = "Delete an item from a checklist on a card";

static flags = {
board: Flags.string({ required: true }),
list: Flags.string({ required: true }),
card: Flags.string({ required: true }),
checklist: Flags.string({ required: true, description: "Checklist name or ID" }),
item: Flags.string({ required: true, description: "Item name or ID to delete" }),
};

async run(): Promise<void> {
const itemId = await this.resolveItemId(this.lookups.card, this.flags.checklist, this.flags.item);

await this.client.cards.deleteCardChecklistItem({
id: this.lookups.card,
idCheckItem: itemId,
});
}

// Resolves an item name to its ID within the scoped checklist.
// When the item value is already a 24-char hex ID, skips all API calls entirely.
// The checklist flag scopes the search so identically-named items in other
// checklists on the same card don't cause false matches.
private async resolveItemId(cardId: string, checklist: string, item: string): Promise<string> {
if (/^[a-f0-9]{24}$/.test(item) && /^[a-f0-9]{24}$/.test(checklist)) {
return item;
}

const checklists = (await this.client.cards.getCardChecklists({ id: cardId })) as any[];

// Scope the search to the specified checklist
const targetChecklist = checklists.find(
(cl: any) => cl.name === checklist || cl.id === checklist,
);
if (!targetChecklist) {
this.error(`No checklist found matching "${checklist}"`);
}

// Item may already be an ID even if checklist was a name
if (/^[a-f0-9]{24}$/.test(item)) {
return item;
}

const found = targetChecklist.checkItems.find((ci: any) => ci.name === item);
if (!found) {
this.error(`No checklist item found with name "${item}"`);
}

return found.id;
}
}
23 changes: 23 additions & 0 deletions packages/trello-cli/src/commands/card/delete-checklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BaseCommand } from "../../BaseCommand";
import { Flags } from "@oclif/core";

export default class CardDeleteChecklist extends BaseCommand<typeof CardDeleteChecklist> {
static description = "Delete a checklist from a card";

static flags = {
board: Flags.string({ required: true }),
list: Flags.string({ required: true }),
card: Flags.string({ required: true }),
checklist: Flags.string({ required: true, description: "Checklist name or ID" }),
};

async run(): Promise<void> {
// resolveChecklistId handles both names and raw IDs (see BaseCommand)
const checklistId = await this.resolveChecklistId(this.lookups.card, this.flags.checklist);

await this.client.cards.deleteCardChecklist({
id: this.lookups.card,
idChecklist: checklistId,
});
}
}
136 changes: 136 additions & 0 deletions packages/trello-cli/src/commands/card/update-checklist-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { BaseCommand } from "../../BaseCommand";
import { Flags } from "@oclif/core";

export default class CardUpdateChecklistItem extends BaseCommand<typeof CardUpdateChecklistItem> {
static description = "Rename or reposition a checklist item on a card";

static flags = {
board: Flags.string({ required: true }),
list: Flags.string({ required: true }),
card: Flags.string({ required: true }),
checklist: Flags.string({ required: true, description: "Checklist name or ID" }),
item: Flags.string({ required: true, description: "Item name or ID to update" }),
name: Flags.string({ description: "New name for the item" }),
pos: Flags.string({
description: 'New position: "top", "bottom", "up", "down", or a positive number',
}),
};

async run(): Promise<void> {
if (!this.flags.name && !this.flags.pos) {
this.error("Provide at least one of --name or --pos");
}

const isRelativeMove = this.flags.pos === "up" || this.flags.pos === "down";

// Always fetch checklists when doing a relative move (need positions to compute midpoint).
// Also fetch when either flag is a name rather than a raw ID.
const needsFetch =
isRelativeMove ||
!/^[a-f0-9]{24}$/.test(this.flags.checklist) ||
!/^[a-f0-9]{24}$/.test(this.flags.item);

let itemId: string;
let computedPos: string | number | undefined;

if (needsFetch) {
const checklists = (await this.client.cards.getCardChecklists({ id: this.lookups.card })) as any[];

// Resolve the checklist
const targetChecklist = checklists.find(
(cl: any) => cl.name === this.flags.checklist || cl.id === this.flags.checklist,
);
if (!targetChecklist) {
this.error(`No checklist found matching "${this.flags.checklist}"`);
}

// Resolve the item within that checklist
let targetItem: any;
if (/^[a-f0-9]{24}$/.test(this.flags.item)) {
targetItem = targetChecklist.checkItems.find((ci: any) => ci.id === this.flags.item);
} else {
targetItem = targetChecklist.checkItems.find((ci: any) => ci.name === this.flags.item);
}
if (!targetItem) {
this.error(`No checklist item found with name "${this.flags.item}"`);
}
itemId = targetItem.id;

// Compute position for relative moves
if (isRelativeMove) {
computedPos = this.computeRelativePos(
targetChecklist.checkItems,
targetItem,
this.flags.pos as "up" | "down",
);
}
} else {
// Both flags were raw IDs and pos is not relative — skip all fetching
itemId = this.flags.item;
}

// Build the update payload — only include fields the caller actually specified
const payload: Record<string, any> = {
id: this.lookups.card,
idCheckItem: itemId,
};

if (this.flags.name) {
payload.name = this.flags.name;
}

if (this.flags.pos !== undefined) {
payload.pos = computedPos !== undefined ? computedPos : this.parsePos(this.flags.pos);
}

const result = await this.client.cards.updateCardCheckItem(payload as any);
this.output(result);
}

// Computes a new fractional position so the item slots between its neighbours
// after a relative move. Trello uses floating-point positions, so inserting a
// midpoint value avoids collisions without reordering other items.
private computeRelativePos(
items: any[],
target: any,
direction: "up" | "down",
): number {
// Sort ascending so index reflects visual order in the checklist
const sorted = [...items].sort((a, b) => a.pos - b.pos);
const idx = sorted.findIndex((ci) => ci.id === target.id);

if (direction === "up") {
if (idx === 0) this.error("Item is already at the top of the checklist");
const prev = sorted[idx - 1];
// No item before prev: halve prev's position to land before it
if (idx === 1) return prev.pos / 2;
// Otherwise: midpoint between the two items that bracket the target slot
return (sorted[idx - 2].pos + prev.pos) / 2;
} else {
if (idx === sorted.length - 1) this.error("Item is already at the bottom of the checklist");
const next = sorted[idx + 1];
// No item after next: add a fixed increment to land after it
if (idx === sorted.length - 2) return next.pos + 1000;
// Otherwise: midpoint between next and the item after it
return (next.pos + sorted[idx + 2].pos) / 2;
}
}

// Trello accepts "top"/"bottom" as strings and any positive float as a number.
// Relative moves ("up"/"down") are resolved before this is called.
private parsePos(pos: string): string | number {
if (pos === "top" || pos === "bottom") return pos;
const n = parseFloat(pos);
if (isNaN(n)) this.error(`Invalid --pos value "${pos}". Use "top", "bottom", "up", "down", or a number.`);
return n;
}

protected toData(data: any) {
return {
id: data.id,
name: data.name,
state: data.state,
pos: data.pos,
};
}
}
Loading