Skip to content
Merged
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
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,48 @@ Real-world wire format differences worth knowing when contributing:
- Position values are powers of 2 starting at `65536`.
- `GET /api/boards/{id}` returns a nested `included` object with lists, cards, tasks, labels, memberships, and users.

### Custom fields

All verified against a live server on 2026-08-10. Several contradict Planka's published
OpenAPI spec — believe this list, not the spec.

- **Values are addressed by a composite path segment**, not ordinary path params:
`/api/cards/{c}/custom-field-values/customFieldGroupId:{g}:customFieldId:{f}`
- **The published spec writes `customFieldId:${customFieldId}` with a literal `$`.** That is a
documentation bug. Sending the `$` returns `400 E_MISSING_OR_INVALID_PARAMS` — the server
reads it as part of the id and fails a length check.
- **The published spec spells the DELETE route singular (`custom-field-value`). The live route
is plural**, same as PATCH. The singular path returns a bare `{"code":"E_NOT_FOUND"}` with no
message — Sails for "no such route" — as opposed to the plural route's
`{"code":"E_NOT_FOUND","message":"Custom field value not found"}`.
- **`content` is capped at 512 characters.** 512 returns `200`, 513 returns `400`. Validate
client-side so callers get exit `2` instead of a server `400`.
- **An empty string is rejected.** `PATCH {"content": ""}` returns `400 … Cannot use ''
(empty string) for a required input`. Clearing a value must use DELETE, never a PATCH to
empty.
- **DELETE returns the deleted value** in an `item` envelope, not `204`.
- **The server may rewrite a card group's `position`.** A create sent `65536` came back as
`81920` because a sibling already held the slot. Never assert the echo.
- **There is no endpoint that filters cards by custom field value.** Filtering is client-side
over a snapshot. Do not invent a query parameter for it.
- **There is no `GET /api/base-custom-field-groups/{id}`.** That path falls through to the SPA
and returns HTML with `200` — worse than a 404, because a JSON client fails with a confusing
parse error. `PATCH` and `DELETE` on the same prefix *do* exist. Base groups are readable
only through `GET /api/projects` or `GET /api/projects/{id}`, under
`included.baseCustomFieldGroups`.
- **A base group's fields are not in the board snapshot.** `GET /api/boards/{id}` →
`included.customFields` carries only board- and card-level group fields. Base group fields
appear only under the projects endpoints.
- **A card group adopted from a base group has `name: null` and no fields of its own.**
The display name lives on the base group via `baseCustomFieldGroupId`, and
`GET /api/custom-field-groups/{adoptedId}` returns `included.customFields: []`. Any
name-based resolution must fall back through the base group for *both*, or it will match
nothing for every template-adopted group — which is the common case.
- **`POST /api/projects/{id}/base-custom-field-groups` accepts `position` but omits it from the
response.** The base-group model must not require the field.
- **`showOnFrontOfCard` is honoured on create** and is what makes a value visible on the card
face in the web UI. Planka defaults it to `false`.

## Project Management

Task tracking lives in a Planka instance on the `planka-cli` project. Board and list names are not fixed — inspect actual state before acting. Do not assume milestone boards or canonical columns (`Backlog`, `In Progress`, etc.) exist.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Custom field support across `plnk-core` and `plnk`, adding three resources:
- `plnk field-group` — base groups on a project (reusable templates), plus board- and card-level groups. Adopt a template onto a card with `field-group create --card <id> --base <baseGroupId>`.
- `plnk field` — the named slots inside a group, with `--show-on-front` controlling visibility on the card face in the Planka web UI.
- `plnk card field` — get, set, and clear the values a card stores, with ID-or-name resolution for `--group` and `--field` following the house three-tier match.
- `plnk-core` gains `CustomFieldGroupApi`, `CustomFieldApi` and `CardCustomFieldApi`, plus the `BaseCustomFieldGroup`, `CustomFieldGroup`, `CustomField` and `CustomFieldValue` models.
- Hidden plural aliases `field-groups` and `fields`, matching the existing `labels` / `boards` pattern.
- Values are validated client-side: empty values and values over Planka's 512-character cap exit `2` without issuing a request. Clearing is idempotent — clearing an already-unset value exits `0`.
- Name resolution reaches through the base group. A card group adopted from a template has `name: null` and carries no fields of its own, so both the group name and its field names are resolved via `baseCustomFieldGroupId`.
- Docs: [Custom fields](docs/cli/custom-fields.md), plus a worked example in `docs/cli/examples.md` and the verified wire quirks in `AGENTS.md`.

## [0.2.0] - 2026-05-02

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Reference docs, one per resource:
- [Projects](docs/cli/projects.md) · [Boards](docs/cli/boards.md) · [Lists](docs/cli/lists.md) · [Cards](docs/cli/cards.md)
- [Tasks](docs/cli/tasks.md) · [Comments](docs/cli/comments.md) · [Labels](docs/cli/labels.md)
- [Attachments](docs/cli/attachments.md) · [Memberships](docs/cli/memberships.md) · [Users](docs/cli/users.md)
- [Custom fields](docs/cli/custom-fields.md)
- [Authentication](docs/cli/auth.md) · [Grammar reference](docs/cli/grammar.md) · [Transport policy](docs/cli/transport.md)
- [Worked examples](docs/cli/examples.md)

Expand Down
225 changes: 225 additions & 0 deletions crates/plnk-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ pub enum Command {
Comment(CommentCommand),
/// Manage board labels
Label(LabelCommand),
/// Manage custom field groups
FieldGroup(FieldGroupCommand),
/// Manage custom fields inside a group
Field(FieldCommand),
/// Manage attachments on cards
Attachment(AttachmentCommand),
/// Manage project/board memberships
Expand Down Expand Up @@ -159,6 +163,29 @@ pub enum Command {
#[arg(long)]
board: String,
},
/// Alias for `field list --group|--base-group <id>`
#[command(hide = true)]
Fields {
/// Parent custom field group ID
#[arg(long, group = "field_scope")]
group: Option<String>,
/// Parent base custom field group ID
#[arg(long = "base-group", group = "field_scope")]
base_group: Option<String>,
},
/// Alias for `field-group list --project|--board|--card <id>`
#[command(hide = true)]
FieldGroups {
/// Parent project ID (lists base groups)
#[arg(long, group = "scope")]
project: Option<String>,
/// Parent board ID
#[arg(long, group = "scope")]
board: Option<String>,
/// Parent card ID
#[arg(long, group = "scope")]
card: Option<String>,
},
}

// ── Init ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -542,6 +569,8 @@ pub enum CardAction {
},
/// Manage labels on a card
Label(CardLabelCommand),
/// Manage custom field values on a card
Field(CardFieldCommand),
/// Manage assignees on a card
Assignee(CardAssigneeCommand),
}
Expand Down Expand Up @@ -577,6 +606,51 @@ pub enum CardLabelAction {
},
}

// ── Card Field ──────────────────────────────────────────────────────────

/// Custom field *values* on a card — the string a card stores for one
/// (group, field) pair.
#[derive(Parser)]
pub struct CardFieldCommand {
#[command(subcommand)]
pub action: CardFieldAction,
}

#[derive(Subcommand)]
pub enum CardFieldAction {
/// List custom field values on a card
List {
/// Card ID
card: String,
},
/// Set a custom field value on a card
Set {
/// Card ID
card: String,
/// Custom field group ID or name; use an ID to avoid ambiguity
#[arg(long)]
group: String,
/// Custom field ID or name; use an ID to avoid ambiguity
#[arg(long)]
field: String,
/// Value to store. Capped at 512 characters. An empty value is not
/// accepted — use `plnk card field clear` to remove a value
#[arg(long)]
value: String,
},
/// Clear a custom field value on a card
Clear {
/// Card ID
card: String,
/// Custom field group ID or name; use an ID to avoid ambiguity
#[arg(long)]
group: String,
/// Custom field ID or name; use an ID to avoid ambiguity
#[arg(long)]
field: String,
},
}

// ── Card Assignee ───────────────────────────────────────────────────────

#[derive(Parser)]
Expand Down Expand Up @@ -753,6 +827,157 @@ pub enum LabelAction {
},
}

// ── Field Group ─────────────────────────────────────────────────────────

/// Custom field groups hold the named fields a card can carry values for.
///
/// A group on a *project* is a reusable template (a base group). A card adopts
/// a template with `create --card <id> --base <baseGroupId>`, or defines a
/// one-off group with `create --card <id> --name <name>`.
#[derive(Parser)]
pub struct FieldGroupCommand {
#[command(subcommand)]
pub action: FieldGroupAction,
}

#[derive(Subcommand)]
pub enum FieldGroupAction {
/// List custom field groups in a project, board, or card
List {
/// Parent project ID (lists base groups — the reusable templates)
#[arg(long, group = "scope")]
project: Option<String>,
/// Parent board ID
#[arg(long, group = "scope")]
board: Option<String>,
/// Parent card ID
#[arg(long, group = "scope")]
card: Option<String>,
},
/// Find custom field groups by name within a project, board, or card
Find {
/// Search a project's base groups
#[arg(long, group = "scope")]
project: Option<String>,
/// Search a board's groups
#[arg(long, group = "scope")]
board: Option<String>,
/// Search a card's groups
#[arg(long, group = "scope")]
card: Option<String>,
/// Group name to search for
#[arg(long)]
name: String,
},
/// Get a custom field group by ID
Get {
/// Custom field group ID (base group IDs are accepted too)
id: String,
},
/// Create a custom field group
Create {
/// Parent project ID — creates a reusable base group
#[arg(long, group = "scope")]
project: Option<String>,
/// Parent board ID
#[arg(long, group = "scope")]
board: Option<String>,
/// Parent card ID
#[arg(long, group = "scope")]
card: Option<String>,
/// Group name. Required for --project and --board; on --card it creates
/// a one-off group instead of adopting a template
#[arg(long)]
name: Option<String>,
/// Base group ID to adopt onto a card. Only valid with --card
#[arg(long, conflicts_with = "name")]
base: Option<String>,
},
/// Update a custom field group
Update {
/// Custom field group ID (base group IDs are accepted too)
id: String,
/// New group name
#[arg(long)]
name: Option<String>,
},
/// Delete a custom field group
Delete {
/// Custom field group ID (base group IDs are accepted too)
id: String,
},
}

// ── Field ───────────────────────────────────────────────────────────────

/// Custom fields are the named slots inside a group. A card carries a *value*
/// for a field; see `plnk card field`.
#[derive(Parser)]
pub struct FieldCommand {
#[command(subcommand)]
pub action: FieldAction,
}

#[derive(Subcommand)]
pub enum FieldAction {
/// List custom fields in a group
List {
/// Parent custom field group ID
#[arg(long, group = "field_scope")]
group: Option<String>,
/// Parent base custom field group ID
#[arg(long = "base-group", group = "field_scope")]
base_group: Option<String>,
},
/// Find custom fields by name within a group
Find {
/// Parent custom field group ID
#[arg(long, group = "field_scope")]
group: Option<String>,
/// Parent base custom field group ID
#[arg(long = "base-group", group = "field_scope")]
base_group: Option<String>,
/// Field name to search for
#[arg(long)]
name: String,
},
/// Create a custom field
Create {
/// Parent custom field group ID
#[arg(long, group = "field_scope")]
group: Option<String>,
/// Parent base custom field group ID
#[arg(long = "base-group", group = "field_scope")]
base_group: Option<String>,
/// Field name
#[arg(long)]
name: String,
/// Show this field's value on the front of the card in the Planka web
/// UI. Off by default, matching Planka — it is the difference between a
/// field a human sees at a glance and one only a script reads
#[arg(long = "show-on-front")]
show_on_front: bool,
},
/// Update a custom field
Update {
/// Custom field ID
id: String,
/// New field name
#[arg(long)]
name: Option<String>,
/// Whether the value shows on the front of the card in the Planka web
/// UI. Takes an explicit true or false, so that leaving it unset and
/// setting it false stay distinguishable
#[arg(long = "show-on-front")]
show_on_front: Option<bool>,
},
/// Delete a custom field
Delete {
/// Custom field ID
id: String,
},
}

// ── Attachment ──────────────────────────────────────────────────────────

#[derive(Parser)]
Expand Down
6 changes: 4 additions & 2 deletions crates/plnk-cli/src/commands/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,10 @@ pub async fn execute(
render_message("Card deleted.", format)?;
}
// Label and Assignee subcommands are dispatched in main.rs
crate::app::CardAction::Label(_) | crate::app::CardAction::Assignee(_) => {
unreachable!("card label/assignee dispatched in main.rs")
crate::app::CardAction::Label(_)
| crate::app::CardAction::Assignee(_)
| crate::app::CardAction::Field(_) => {
unreachable!("card label/assignee/field dispatched in main.rs")
}
}
Ok(())
Expand Down
Loading
Loading