Released v1

This commit is contained in:
Steve Cliff
2026-02-12 10:37:19 +00:00
commit b07572fed5
77 changed files with 19518 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Binary
/pcli
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Environment
.env
.env.local
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
@@ -0,0 +1,246 @@
---
name: openspec-bulk-archive-change
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Archive multiple completed changes in a single operation.
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
**Input**: None required (prompts for selection)
**Steps**
1. **Get active changes**
Run `openspec list --json` to get all active changes.
If no active changes exist, inform user and stop.
2. **Prompt for change selection**
Use **AskUserQuestion tool** with multi-select to let user choose changes:
- Show each change with its schema
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
3. **Batch validation - gather status for all selected changes**
For each selected change, collect:
a. **Artifact status** - Run `openspec status --change "<name>" --json`
- Parse `schemaName` and `artifacts` list
- Note which artifacts are `done` vs other states
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
- If no tasks file exists, note as "No tasks"
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
- List which capability specs exist
- For each, extract requirement names (lines matching `### Requirement: <name>`)
4. **Detect spec conflicts**
Build a map of `capability -> [changes that touch it]`:
```
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
api -> [change-c] <- OK (only 1 change)
```
A conflict exists when 2+ selected changes have delta specs for the same capability.
5. **Resolve conflicts agentically**
**For each conflict**, investigate the codebase:
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
b. **Search the codebase** for implementation evidence:
- Look for code implementing requirements from each delta spec
- Check for related files, functions, or tests
c. **Determine resolution**:
- If only one change is actually implemented -> sync that one's specs
- If both implemented -> apply in chronological order (older first, newer overwrites)
- If neither implemented -> skip spec sync, warn user
d. **Record resolution** for each conflict:
- Which change's specs to apply
- In what order (if both)
- Rationale (what was found in codebase)
6. **Show consolidated status table**
Display a table summarizing all changes:
```
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
```
For conflicts, show the resolution:
```
* Conflict resolution:
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
```
For incomplete changes, show warnings:
```
Warnings:
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
```
7. **Confirm batch operation**
Use **AskUserQuestion tool** with a single confirmation:
- "Archive N changes?" with options based on status
- Options might include:
- "Archive all N changes"
- "Archive only N ready changes (skip incomplete)"
- "Cancel"
If there are incomplete changes, make clear they'll be archived with warnings.
8. **Execute archive for each confirmed change**
Process changes in the determined order (respecting conflict resolution):
a. **Sync specs** if delta specs exist:
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
- For conflicts, apply in resolved order
- Track if sync was done
b. **Perform the archive**:
```bash
mkdir -p openspec/changes/archive
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
c. **Track outcome** for each change:
- Success: archived successfully
- Failed: error during archive (record error)
- Skipped: user chose not to archive (if applicable)
9. **Display summary**
Show final results:
```
## Bulk Archive Complete
Archived 3 changes:
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
- project-config -> archive/2026-01-19-project-config/
- add-oauth -> archive/2026-01-19-add-oauth/
Skipped 1 change:
- add-verify-skill (user chose not to archive incomplete)
Spec sync summary:
- 4 delta specs synced to main specs
- 1 conflict resolved (auth: applied both in chronological order)
```
If any failures:
```
Failed 1 change:
- some-change: Archive directory already exists
```
**Conflict Resolution Examples**
Example 1: Only one implemented
```
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
Checking add-oauth:
- Delta adds "OAuth Provider Integration" requirement
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
Checking add-jwt:
- Delta adds "JWT Token Handling" requirement
- Searching codebase... no JWT implementation found
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
```
Example 2: Both implemented
```
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
Checking add-rest-api (created 2026-01-10):
- Delta adds "REST Endpoints" requirement
- Searching codebase... found src/api/rest.ts
Checking add-graphql (created 2026-01-15):
- Delta adds "GraphQL Schema" requirement
- Searching codebase... found src/api/graphql.ts
Resolution: Both implemented. Will apply add-rest-api specs first,
then add-graphql specs (chronological order, newer takes precedence).
```
**Output On Success**
```
## Bulk Archive Complete
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
Spec sync summary:
- N delta specs synced to main specs
- No conflicts (or: M conflicts resolved)
```
**Output On Partial Success**
```
## Bulk Archive Complete (partial)
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
Skipped M changes:
- <change-2> (user chose not to archive incomplete)
Failed K changes:
- <change-3>: Archive directory already exists
```
**Output When No Changes**
```
## No Changes to Archive
No active changes found. Use `/opsx:new` to create a new change.
```
**Guardrails**
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
- Always prompt for selection, never auto-select
- Detect spec conflicts early and resolve by checking codebase
- When both changes are implemented, apply specs in chronological order
- Skip spec sync only when implementation is missing (warn user)
- Show clear per-change status before confirming
- Use single confirmation for entire batch
- Track and report all outcomes (success/skip/fail)
- Preserve .openspec.yaml when moving to archive
- Archive directory target uses current date: YYYY-MM-DD-<name>
- If archive target exists, fail that change but continue with others
@@ -0,0 +1,118 @@
---
name: openspec-continue-change
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Continue working on a change by creating the next artifact.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check current status**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
3. **Act based on status**:
---
**If all artifacts are complete (`isComplete: true`)**:
- Congratulate the user
- Show final status including the schema used
- Suggest: "All artifacts created! You can now implement this change or archive it."
- STOP
---
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
- Pick the FIRST artifact with `status: "ready"` from the status output
- Get its instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- Parse the JSON. The key fields are:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- **Create the artifact file**:
- Read any completed dependency files for context
- Use `template` as the structure - fill in its sections
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
- Write to the output path specified in instructions
- Show what was created and what's now unlocked
- STOP after creating ONE artifact
---
**If no artifacts are ready (all blocked)**:
- This shouldn't happen with a valid schema
- Show status and suggest checking for issues
4. **After creating an artifact, show progress**
```bash
openspec status --change "<name>"
```
**Output**
After each invocation, show:
- Which artifact was created
- Schema workflow being used
- Current progress (N/M complete)
- What artifacts are now unlocked
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
**Artifact Creation Guidelines**
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
For other schemas, follow the `instruction` field from the CLI output.
**Guardrails**
- Create ONE artifact per invocation
- Always read dependency artifacts before creating a new one
- Never skip artifacts or create out of order
- If context is unclear, ask the user before creating
- Verify the artifact file exists after writing before marking progress
- Use the schema's artifact sequence, don't assume specific artifact names
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
+290
View File
@@ -0,0 +1,290 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create one?"
→ Can transition to `/opsx:new` or `/opsx:ff`
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change: /opsx:new <name>
- Fast-forward to tasks: /opsx:ff <name>
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
@@ -0,0 +1,101 @@
---
name: openspec-ff-change
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, suggest continuing that change instead
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,74 @@
---
name: openspec-new-change
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Start a new change using the experimental artifact-driven approach.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Determine the workflow schema**
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- A specific schema name → use `--schema <name>`
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
**Otherwise**: Omit `--schema` to use the default.
3. **Create the change directory**
```bash
openspec new change "<name>"
```
Add `--schema <name>` only if the user requested a specific workflow.
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
4. **Show the artifact status**
```bash
openspec status --change "<name>"
```
This shows which artifacts need to be created and which are ready (dependencies satisfied).
5. **Get instructions for the first artifact**
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
Check the status output to find the first artifact with status "ready".
```bash
openspec instructions <first-artifact-id> --change "<name>"
```
This outputs the template and context for creating the first artifact.
6. **STOP and wait for user direction**
**Output**
After completing the steps, summarize:
- Change name and location
- Schema/workflow being used and its artifact sequence
- Current status (0/N artifacts complete)
- The template for the first artifact
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
**Guardrails**
- Do NOT create any artifacts yet - just show the instructions
- Do NOT advance beyond showing the first artifact template
- If the name is invalid (not kebab-case), ask for a valid name
- If a change with that name already exists, suggest continuing that change instead
- Pass --schema if using a non-default workflow
+529
View File
@@ -0,0 +1,529 @@
---
name: openspec-onboard
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
---
## Preflight
Before starting, check if OpenSpec is initialized:
```bash
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
```
**If not initialized:**
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
Stop here if not initialized.
---
## Phase 1: Welcome
Display:
```
## Welcome to OpenSpec!
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
**What we'll do:**
1. Pick a small, real task in your codebase
2. Explore the problem briefly
3. Create a change (the container for our work)
4. Build the artifacts: proposal → specs → design → tasks
5. Implement the tasks
6. Archive the completed change
**Time:** ~15-20 minutes
Let's start by finding something to work on.
```
---
## Phase 2: Task Selection
### Codebase Analysis
Scan the codebase for small improvement opportunities. Look for:
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
3. **Functions without tests** - Cross-reference `src/` with test directories
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
6. **Missing validation** - User input handlers without validation
Also check recent git activity:
```bash
git log --oneline -10 2>/dev/null || echo "No git history"
```
### Present Suggestions
From your analysis, present 3-4 specific suggestions:
```
## Task Suggestions
Based on scanning your codebase, here are some good starter tasks:
**1. [Most promising task]**
Location: `src/path/to/file.ts:42`
Scope: ~1-2 files, ~20-30 lines
Why it's good: [brief reason]
**2. [Second task]**
Location: `src/another/file.ts`
Scope: ~1 file, ~15 lines
Why it's good: [brief reason]
**3. [Third task]**
Location: [location]
Scope: [estimate]
Why it's good: [brief reason]
**4. Something else?**
Tell me what you'd like to work on.
Which task interests you? (Pick a number or describe your own)
```
**If nothing found:** Fall back to asking what the user wants to build:
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
### Scope Guardrail
If the user picks or describes something too large (major feature, multi-day work):
```
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
**Options:**
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
2. **Pick something else** - One of the other suggestions, or a different small task?
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
What would you prefer?
```
Let the user override if they insist—this is a soft guardrail.
---
## Phase 3: Explore Demo
Once a task is selected, briefly demonstrate explore mode:
```
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
```
Spend 1-2 minutes investigating the relevant code:
- Read the file(s) involved
- Draw a quick ASCII diagram if it helps
- Note any considerations
```
## Quick Exploration
[Your brief analysis—what you found, any considerations]
┌─────────────────────────────────────────┐
│ [Optional: ASCII diagram if helpful] │
└─────────────────────────────────────────┘
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
Now let's create a change to hold our work.
```
**PAUSE** - Wait for user acknowledgment before proceeding.
---
## Phase 4: Create the Change
**EXPLAIN:**
```
## Creating a Change
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
Let me create one for our task.
```
**DO:** Create the change with a derived kebab-case name:
```bash
openspec new change "<derived-name>"
```
**SHOW:**
```
Created: `openspec/changes/<name>/`
The folder structure:
```
openspec/changes/<name>/
├── proposal.md ← Why we're doing this (empty, we'll fill it)
├── design.md ← How we'll build it (empty)
├── specs/ ← Detailed requirements (empty)
└── tasks.md ← Implementation checklist (empty)
```
Now let's fill in the first artifact—the proposal.
```
---
## Phase 5: Proposal
**EXPLAIN:**
```
## The Proposal
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
I'll draft one based on our task.
```
**DO:** Draft the proposal content (don't save yet):
```
Here's a draft proposal:
---
## Why
[1-2 sentences explaining the problem/opportunity]
## What Changes
[Bullet points of what will be different]
## Capabilities
### New Capabilities
- `<capability-name>`: [brief description]
### Modified Capabilities
<!-- If modifying existing behavior -->
## Impact
- `src/path/to/file.ts`: [what changes]
- [other files if applicable]
---
Does this capture the intent? I can adjust before we save it.
```
**PAUSE** - Wait for user approval/feedback.
After approval, save the proposal:
```bash
openspec instructions proposal --change "<name>" --json
```
Then write the content to `openspec/changes/<name>/proposal.md`.
```
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
Next up: specs.
```
---
## Phase 6: Specs
**EXPLAIN:**
```
## Specs
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
For a small task like this, we might only need one spec file.
```
**DO:** Create the spec file:
```bash
mkdir -p openspec/changes/<name>/specs/<capability-name>
```
Draft the spec content:
```
Here's the spec:
---
## ADDED Requirements
### Requirement: <Name>
<Description of what the system should do>
#### Scenario: <Scenario name>
- **WHEN** <trigger condition>
- **THEN** <expected outcome>
- **AND** <additional outcome if needed>
---
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
```
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
---
## Phase 7: Design
**EXPLAIN:**
```
## Design
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
```
**DO:** Draft design.md:
```
Here's the design:
---
## Context
[Brief context about the current state]
## Goals / Non-Goals
**Goals:**
- [What we're trying to achieve]
**Non-Goals:**
- [What's explicitly out of scope]
## Decisions
### Decision 1: [Key decision]
[Explanation of approach and rationale]
---
For a small task, this captures the key decisions without over-engineering.
```
Save to `openspec/changes/<name>/design.md`.
---
## Phase 8: Tasks
**EXPLAIN:**
```
## Tasks
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
These should be small, clear, and in logical order.
```
**DO:** Generate tasks based on specs and design:
```
Here are the implementation tasks:
---
## 1. [Category or file]
- [ ] 1.1 [Specific task]
- [ ] 1.2 [Specific task]
## 2. Verify
- [ ] 2.1 [Verification step]
---
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
```
**PAUSE** - Wait for user to confirm they're ready to implement.
Save to `openspec/changes/<name>/tasks.md`.
---
## Phase 9: Apply (Implementation)
**EXPLAIN:**
```
## Implementation
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
```
**DO:** For each task:
1. Announce: "Working on task N: [description]"
2. Implement the change in the codebase
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
4. Mark complete in tasks.md: `- [ ]``- [x]`
5. Brief status: "✓ Task N complete"
Keep narration light—don't over-explain every line of code.
After all tasks:
```
## Implementation Complete
All tasks done:
- [x] Task 1
- [x] Task 2
- [x] ...
The change is implemented! One more step—let's archive it.
```
---
## Phase 10: Archive
**EXPLAIN:**
```
## Archiving
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```
**DO:**
```bash
openspec archive "<name>"
```
**SHOW:**
```
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
```
---
## Phase 11: Recap & Next Steps
```
## Congratulations!
You just completed a full OpenSpec cycle:
1. **Explore** - Thought through the problem
2. **New** - Created a change container
3. **Proposal** - Captured WHY
4. **Specs** - Defined WHAT in detail
5. **Design** - Decided HOW
6. **Tasks** - Broke it into steps
7. **Apply** - Implemented the work
8. **Archive** - Preserved the record
This same rhythm works for any size change—a small fix or a major feature.
---
## Command Reference
| Command | What it does |
|---------|--------------|
| `/opsx:explore` | Think through problems before/during work |
| `/opsx:new` | Start a new change, step through artifacts |
| `/opsx:ff` | Fast-forward: create all artifacts at once |
| `/opsx:continue` | Continue working on an existing change |
| `/opsx:apply` | Implement tasks from a change |
| `/opsx:verify` | Verify implementation matches artifacts |
| `/opsx:archive` | Archive a completed change |
---
## What's Next?
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
```
---
## Graceful Exit Handling
### User wants to stop mid-way
If the user says they need to stop, want to pause, or seem disengaged:
```
No problem! Your change is saved at `openspec/changes/<name>/`.
To pick up where we left off later:
- `/opsx:continue <name>` - Resume artifact creation
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
The work won't be lost. Come back whenever you're ready.
```
Exit gracefully without pressure.
### User just wants command reference
If the user says they just want to see the commands or skip the tutorial:
```
## OpenSpec Quick Reference
| Command | What it does |
|---------|--------------|
| `/opsx:explore` | Think through problems (no code changes) |
| `/opsx:new <name>` | Start a new change, step by step |
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
| `/opsx:continue <name>` | Continue an existing change |
| `/opsx:apply <name>` | Implement tasks |
| `/opsx:verify <name>` | Verify implementation |
| `/opsx:archive <name>` | Archive when done |
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
```
Exit gracefully.
---
## Guardrails
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
- **Keep narration light** during implementation—teach without lecturing
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
- **Pause for acknowledgment** at marked points, but don't over-pause
- **Handle exits gracefully**—never pressure the user to continue
- **Use real codebase tasks**—don't simulate or use fake examples
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
@@ -0,0 +1,138 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Find delta specs**
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
3. **For each delta spec, apply changes to main specs**
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
4. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -0,0 +1,168 @@
---
name: openspec-verify-change
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.1.1"
---
Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
```bash
openspec instructions apply --change "<name>" --json
```
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in `openspec/changes/<name>/specs/`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
```
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
```
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: `file.ts:123`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"
+152
View File
@@ -0,0 +1,152 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+157
View File
@@ -0,0 +1,157 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, execute `/opsx:sync` logic. Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use /opsx:sync approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+242
View File
@@ -0,0 +1,242 @@
---
name: "OPSX: Bulk Archive"
description: Archive multiple completed changes at once
category: Workflow
tags: [workflow, archive, experimental, bulk]
---
Archive multiple completed changes in a single operation.
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
**Input**: None required (prompts for selection)
**Steps**
1. **Get active changes**
Run `openspec list --json` to get all active changes.
If no active changes exist, inform user and stop.
2. **Prompt for change selection**
Use **AskUserQuestion tool** with multi-select to let user choose changes:
- Show each change with its schema
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
3. **Batch validation - gather status for all selected changes**
For each selected change, collect:
a. **Artifact status** - Run `openspec status --change "<name>" --json`
- Parse `schemaName` and `artifacts` list
- Note which artifacts are `done` vs other states
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
- If no tasks file exists, note as "No tasks"
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
- List which capability specs exist
- For each, extract requirement names (lines matching `### Requirement: <name>`)
4. **Detect spec conflicts**
Build a map of `capability -> [changes that touch it]`:
```
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
api -> [change-c] <- OK (only 1 change)
```
A conflict exists when 2+ selected changes have delta specs for the same capability.
5. **Resolve conflicts agentically**
**For each conflict**, investigate the codebase:
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
b. **Search the codebase** for implementation evidence:
- Look for code implementing requirements from each delta spec
- Check for related files, functions, or tests
c. **Determine resolution**:
- If only one change is actually implemented -> sync that one's specs
- If both implemented -> apply in chronological order (older first, newer overwrites)
- If neither implemented -> skip spec sync, warn user
d. **Record resolution** for each conflict:
- Which change's specs to apply
- In what order (if both)
- Rationale (what was found in codebase)
6. **Show consolidated status table**
Display a table summarizing all changes:
```
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
```
For conflicts, show the resolution:
```
* Conflict resolution:
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
```
For incomplete changes, show warnings:
```
Warnings:
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
```
7. **Confirm batch operation**
Use **AskUserQuestion tool** with a single confirmation:
- "Archive N changes?" with options based on status
- Options might include:
- "Archive all N changes"
- "Archive only N ready changes (skip incomplete)"
- "Cancel"
If there are incomplete changes, make clear they'll be archived with warnings.
8. **Execute archive for each confirmed change**
Process changes in the determined order (respecting conflict resolution):
a. **Sync specs** if delta specs exist:
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
- For conflicts, apply in resolved order
- Track if sync was done
b. **Perform the archive**:
```bash
mkdir -p openspec/changes/archive
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
c. **Track outcome** for each change:
- Success: archived successfully
- Failed: error during archive (record error)
- Skipped: user chose not to archive (if applicable)
9. **Display summary**
Show final results:
```
## Bulk Archive Complete
Archived 3 changes:
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
- project-config -> archive/2026-01-19-project-config/
- add-oauth -> archive/2026-01-19-add-oauth/
Skipped 1 change:
- add-verify-skill (user chose not to archive incomplete)
Spec sync summary:
- 4 delta specs synced to main specs
- 1 conflict resolved (auth: applied both in chronological order)
```
If any failures:
```
Failed 1 change:
- some-change: Archive directory already exists
```
**Conflict Resolution Examples**
Example 1: Only one implemented
```
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
Checking add-oauth:
- Delta adds "OAuth Provider Integration" requirement
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
Checking add-jwt:
- Delta adds "JWT Token Handling" requirement
- Searching codebase... no JWT implementation found
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
```
Example 2: Both implemented
```
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
Checking add-rest-api (created 2026-01-10):
- Delta adds "REST Endpoints" requirement
- Searching codebase... found src/api/rest.ts
Checking add-graphql (created 2026-01-15):
- Delta adds "GraphQL Schema" requirement
- Searching codebase... found src/api/graphql.ts
Resolution: Both implemented. Will apply add-rest-api specs first,
then add-graphql specs (chronological order, newer takes precedence).
```
**Output On Success**
```
## Bulk Archive Complete
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
Spec sync summary:
- N delta specs synced to main specs
- No conflicts (or: M conflicts resolved)
```
**Output On Partial Success**
```
## Bulk Archive Complete (partial)
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
Skipped M changes:
- <change-2> (user chose not to archive incomplete)
Failed K changes:
- <change-3>: Archive directory already exists
```
**Output When No Changes**
```
## No Changes to Archive
No active changes found. Use `/opsx:new` to create a new change.
```
**Guardrails**
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
- Always prompt for selection, never auto-select
- Detect spec conflicts early and resolve by checking codebase
- When both changes are implemented, apply specs in chronological order
- Skip spec sync only when implementation is missing (warn user)
- Show clear per-change status before confirming
- Use single confirmation for entire batch
- Track and report all outcomes (success/skip/fail)
- Preserve .openspec.yaml when moving to archive
- Archive directory target uses current date: YYYY-MM-DD-<name>
- If archive target exists, fail that change but continue with others
+114
View File
@@ -0,0 +1,114 @@
---
name: "OPSX: Continue"
description: Continue working on a change - create the next artifact (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Continue working on a change by creating the next artifact.
**Input**: Optionally specify a change name after `/opsx:continue` (e.g., `/opsx:continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check current status**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
3. **Act based on status**:
---
**If all artifacts are complete (`isComplete: true`)**:
- Congratulate the user
- Show final status including the schema used
- Suggest: "All artifacts created! You can now implement this change with `/opsx:apply` or archive it with `/opsx:archive`."
- STOP
---
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
- Pick the FIRST artifact with `status: "ready"` from the status output
- Get its instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- Parse the JSON. The key fields are:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- **Create the artifact file**:
- Read any completed dependency files for context
- Use `template` as the structure - fill in its sections
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
- Write to the output path specified in instructions
- Show what was created and what's now unlocked
- STOP after creating ONE artifact
---
**If no artifacts are ready (all blocked)**:
- This shouldn't happen with a valid schema
- Show status and suggest checking for issues
4. **After creating an artifact, show progress**
```bash
openspec status --change "<name>"
```
**Output**
After each invocation, show:
- Which artifact was created
- Schema workflow being used
- Current progress (N/M complete)
- What artifacts are now unlocked
- Prompt: "Run `/opsx:continue` to create the next artifact"
**Artifact Creation Guidelines**
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
For other schemas, follow the `instruction` field from the CLI output.
**Guardrails**
- Create ONE artifact per invocation
- Always read dependency artifacts before creating a new one
- Never skip artifacts or create out of order
- If context is unclear, ask the user before creating
- Verify the artifact file exists after writing before marking progress
- Use the schema's artifact sequence, don't assume specific artifact names
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
+174
View File
@@ -0,0 +1,174 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create one?"
→ Can transition to `/opsx:new` or `/opsx:ff`
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into action**: "Ready to start? `/opsx:new` or `/opsx:ff`"
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+94
View File
@@ -0,0 +1,94 @@
---
name: "OPSX: Fast Forward"
description: Create a change and generate all artifacts needed for implementation in one go
category: Workflow
tags: [workflow, artifacts, experimental]
---
Fast-forward through artifact creation - generate everything needed to start implementation.
**Input**: The argument after `/opsx:ff` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use the `template` as a starting point, filling in based on context
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+69
View File
@@ -0,0 +1,69 @@
---
name: "OPSX: New"
description: Start a new change using the experimental artifact workflow (OPSX)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Start a new change using the experimental artifact-driven approach.
**Input**: The argument after `/opsx:new` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Determine the workflow schema**
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- A specific schema name → use `--schema <name>`
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
**Otherwise**: Omit `--schema` to use the default.
3. **Create the change directory**
```bash
openspec new change "<name>"
```
Add `--schema <name>` only if the user requested a specific workflow.
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
4. **Show the artifact status**
```bash
openspec status --change "<name>"
```
This shows which artifacts need to be created and which are ready (dependencies satisfied).
5. **Get instructions for the first artifact**
The first artifact depends on the schema. Check the status output to find the first artifact with status "ready".
```bash
openspec instructions <first-artifact-id> --change "<name>"
```
This outputs the template and context for creating the first artifact.
6. **STOP and wait for user direction**
**Output**
After completing the steps, summarize:
- Change name and location
- Schema/workflow being used and its artifact sequence
- Current status (0/N artifacts complete)
- The template for the first artifact
- Prompt: "Ready to create the first artifact? Run `/opsx:continue` or just describe what this change is about and I'll draft it."
**Guardrails**
- Do NOT create any artifacts yet - just show the instructions
- Do NOT advance beyond showing the first artifact template
- If the name is invalid (not kebab-case), ask for a valid name
- If a change with that name already exists, suggest using `/opsx:continue` instead
- Pass --schema if using a non-default workflow
+525
View File
@@ -0,0 +1,525 @@
---
name: "OPSX: Onboard"
description: Guided onboarding - walk through a complete OpenSpec workflow cycle with narration
category: Workflow
tags: [workflow, onboarding, tutorial, learning]
---
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
---
## Preflight
Before starting, check if OpenSpec is initialized:
```bash
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
```
**If not initialized:**
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
Stop here if not initialized.
---
## Phase 1: Welcome
Display:
```
## Welcome to OpenSpec!
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
**What we'll do:**
1. Pick a small, real task in your codebase
2. Explore the problem briefly
3. Create a change (the container for our work)
4. Build the artifacts: proposal → specs → design → tasks
5. Implement the tasks
6. Archive the completed change
**Time:** ~15-20 minutes
Let's start by finding something to work on.
```
---
## Phase 2: Task Selection
### Codebase Analysis
Scan the codebase for small improvement opportunities. Look for:
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
3. **Functions without tests** - Cross-reference `src/` with test directories
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
6. **Missing validation** - User input handlers without validation
Also check recent git activity:
```bash
git log --oneline -10 2>/dev/null || echo "No git history"
```
### Present Suggestions
From your analysis, present 3-4 specific suggestions:
```
## Task Suggestions
Based on scanning your codebase, here are some good starter tasks:
**1. [Most promising task]**
Location: `src/path/to/file.ts:42`
Scope: ~1-2 files, ~20-30 lines
Why it's good: [brief reason]
**2. [Second task]**
Location: `src/another/file.ts`
Scope: ~1 file, ~15 lines
Why it's good: [brief reason]
**3. [Third task]**
Location: [location]
Scope: [estimate]
Why it's good: [brief reason]
**4. Something else?**
Tell me what you'd like to work on.
Which task interests you? (Pick a number or describe your own)
```
**If nothing found:** Fall back to asking what the user wants to build:
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
### Scope Guardrail
If the user picks or describes something too large (major feature, multi-day work):
```
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
**Options:**
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
2. **Pick something else** - One of the other suggestions, or a different small task?
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
What would you prefer?
```
Let the user override if they insist—this is a soft guardrail.
---
## Phase 3: Explore Demo
Once a task is selected, briefly demonstrate explore mode:
```
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
```
Spend 1-2 minutes investigating the relevant code:
- Read the file(s) involved
- Draw a quick ASCII diagram if it helps
- Note any considerations
```
## Quick Exploration
[Your brief analysis—what you found, any considerations]
┌─────────────────────────────────────────┐
│ [Optional: ASCII diagram if helpful] │
└─────────────────────────────────────────┘
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
Now let's create a change to hold our work.
```
**PAUSE** - Wait for user acknowledgment before proceeding.
---
## Phase 4: Create the Change
**EXPLAIN:**
```
## Creating a Change
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
Let me create one for our task.
```
**DO:** Create the change with a derived kebab-case name:
```bash
openspec new change "<derived-name>"
```
**SHOW:**
```
Created: `openspec/changes/<name>/`
The folder structure:
```
openspec/changes/<name>/
├── proposal.md ← Why we're doing this (empty, we'll fill it)
├── design.md ← How we'll build it (empty)
├── specs/ ← Detailed requirements (empty)
└── tasks.md ← Implementation checklist (empty)
```
Now let's fill in the first artifact—the proposal.
```
---
## Phase 5: Proposal
**EXPLAIN:**
```
## The Proposal
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
I'll draft one based on our task.
```
**DO:** Draft the proposal content (don't save yet):
```
Here's a draft proposal:
---
## Why
[1-2 sentences explaining the problem/opportunity]
## What Changes
[Bullet points of what will be different]
## Capabilities
### New Capabilities
- `<capability-name>`: [brief description]
### Modified Capabilities
<!-- If modifying existing behavior -->
## Impact
- `src/path/to/file.ts`: [what changes]
- [other files if applicable]
---
Does this capture the intent? I can adjust before we save it.
```
**PAUSE** - Wait for user approval/feedback.
After approval, save the proposal:
```bash
openspec instructions proposal --change "<name>" --json
```
Then write the content to `openspec/changes/<name>/proposal.md`.
```
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
Next up: specs.
```
---
## Phase 6: Specs
**EXPLAIN:**
```
## Specs
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
For a small task like this, we might only need one spec file.
```
**DO:** Create the spec file:
```bash
mkdir -p openspec/changes/<name>/specs/<capability-name>
```
Draft the spec content:
```
Here's the spec:
---
## ADDED Requirements
### Requirement: <Name>
<Description of what the system should do>
#### Scenario: <Scenario name>
- **WHEN** <trigger condition>
- **THEN** <expected outcome>
- **AND** <additional outcome if needed>
---
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
```
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
---
## Phase 7: Design
**EXPLAIN:**
```
## Design
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
```
**DO:** Draft design.md:
```
Here's the design:
---
## Context
[Brief context about the current state]
## Goals / Non-Goals
**Goals:**
- [What we're trying to achieve]
**Non-Goals:**
- [What's explicitly out of scope]
## Decisions
### Decision 1: [Key decision]
[Explanation of approach and rationale]
---
For a small task, this captures the key decisions without over-engineering.
```
Save to `openspec/changes/<name>/design.md`.
---
## Phase 8: Tasks
**EXPLAIN:**
```
## Tasks
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
These should be small, clear, and in logical order.
```
**DO:** Generate tasks based on specs and design:
```
Here are the implementation tasks:
---
## 1. [Category or file]
- [ ] 1.1 [Specific task]
- [ ] 1.2 [Specific task]
## 2. Verify
- [ ] 2.1 [Verification step]
---
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
```
**PAUSE** - Wait for user to confirm they're ready to implement.
Save to `openspec/changes/<name>/tasks.md`.
---
## Phase 9: Apply (Implementation)
**EXPLAIN:**
```
## Implementation
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
```
**DO:** For each task:
1. Announce: "Working on task N: [description]"
2. Implement the change in the codebase
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
4. Mark complete in tasks.md: `- [ ]``- [x]`
5. Brief status: "✓ Task N complete"
Keep narration light—don't over-explain every line of code.
After all tasks:
```
## Implementation Complete
All tasks done:
- [x] Task 1
- [x] Task 2
- [x] ...
The change is implemented! One more step—let's archive it.
```
---
## Phase 10: Archive
**EXPLAIN:**
```
## Archiving
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```
**DO:**
```bash
openspec archive "<name>"
```
**SHOW:**
```
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
```
---
## Phase 11: Recap & Next Steps
```
## Congratulations!
You just completed a full OpenSpec cycle:
1. **Explore** - Thought through the problem
2. **New** - Created a change container
3. **Proposal** - Captured WHY
4. **Specs** - Defined WHAT in detail
5. **Design** - Decided HOW
6. **Tasks** - Broke it into steps
7. **Apply** - Implemented the work
8. **Archive** - Preserved the record
This same rhythm works for any size change—a small fix or a major feature.
---
## Command Reference
| Command | What it does |
|---------|--------------|
| `/opsx:explore` | Think through problems before/during work |
| `/opsx:new` | Start a new change, step through artifacts |
| `/opsx:ff` | Fast-forward: create all artifacts at once |
| `/opsx:continue` | Continue working on an existing change |
| `/opsx:apply` | Implement tasks from a change |
| `/opsx:verify` | Verify implementation matches artifacts |
| `/opsx:archive` | Archive a completed change |
---
## What's Next?
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
```
---
## Graceful Exit Handling
### User wants to stop mid-way
If the user says they need to stop, want to pause, or seem disengaged:
```
No problem! Your change is saved at `openspec/changes/<name>/`.
To pick up where we left off later:
- `/opsx:continue <name>` - Resume artifact creation
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
The work won't be lost. Come back whenever you're ready.
```
Exit gracefully without pressure.
### User just wants command reference
If the user says they just want to see the commands or skip the tutorial:
```
## OpenSpec Quick Reference
| Command | What it does |
|---------|--------------|
| `/opsx:explore` | Think through problems (no code changes) |
| `/opsx:new <name>` | Start a new change, step by step |
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
| `/opsx:continue <name>` | Continue an existing change |
| `/opsx:apply <name>` | Implement tasks |
| `/opsx:verify <name>` | Verify implementation |
| `/opsx:archive <name>` | Archive when done |
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
```
Exit gracefully.
---
## Guardrails
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
- **Keep narration light** during implementation—teach without lecturing
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
- **Pause for acknowledgment** at marked points, but don't over-pause
- **Handle exits gracefully**—never pressure the user to continue
- **Use real codebase tasks**—don't simulate or use fake examples
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
+134
View File
@@ -0,0 +1,134 @@
---
name: "OPSX: Sync"
description: Sync delta specs from a change to main specs
category: Workflow
tags: [workflow, specs, experimental]
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Find delta specs**
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
3. **For each delta spec, apply changes to main specs**
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
4. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
+164
View File
@@ -0,0 +1,164 @@
---
name: "OPSX: Verify"
description: Verify implementation matches change artifacts before archiving
category: Workflow
tags: [workflow, verify, experimental]
---
Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name after `/opsx:verify` (e.g., `/opsx:verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
```bash
openspec instructions apply --change "<name>" --json
```
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If tasks.md exists in contextFiles, read it
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in `openspec/changes/<name>/specs/`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If design.md exists in contextFiles:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
```
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
```
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: `file.ts:123`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"
+286
View File
@@ -0,0 +1,286 @@
# pcli - Planka CLI
A command-line interface for interacting with the [Planka](https://planka.app/) project management API.
## Features
- **Single binary** - No runtime dependencies, just download and run
- **JSON-first output** - Machine-parseable by default, with optional table format for humans
- **Environment-based auth** - Configure once via environment variables
- **Full CRUD operations** - Manage cards, comments, tasks, labels, and more
- **Cursor-based pagination** - Automatic handling of paginated API responses
- **Structured logging** - Debug-level HTTP request/response logging available
## Installation
### From Source
```bash
git clone https://git.franklin.lab/steve.cliff/pcli.git
cd pcli
go build -o pcli
sudo mv pcli /usr/local/bin/
```
### Binary Download
Download the latest release from the [releases page](https://git.franklin.lab/steve.cliff/pcli/releases).
## Configuration
`pcli` requires two environment variables:
- `PLANKA_URL` - Base URL of your Planka instance (e.g., `https://planka.example.com`)
- `PLANKA_API_KEY` - User-level API key for authentication
### Setting Environment Variables
```bash
export PLANKA_URL="https://planka.example.com"
export PLANKA_API_KEY="D89VszVs_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
```
Or use command-line flags to override:
```bash
pcli --url https://planka.example.com --api-key your-api-key project list
```
### Getting an API Key
Generate an API key from your Planka user settings. The full key is only shown once at creation time (it is stored as a hash in the database), so store it securely.
## Usage
### Global Flags
- `--format` - Output format: `json` (default) or `table`
- `--url` - Planka API URL (overrides `PLANKA_URL`)
- `--api-key` - API key (overrides `PLANKA_API_KEY`)
- `--log-level` - Log level: `debug`, `info`, `warn` (default), `error`
### Projects
```bash
# List all projects
pcli project list
# Get a specific project
pcli project get <project-id>
```
### Boards
```bash
# List all accessible boards
pcli board list
# Get a board
pcli board get <board-id>
# List board actions (activity log)
pcli board actions <board-id> --limit 50
```
### Cards
```bash
# List cards by board (includes list names)
pcli card list --board <board-id>
# List cards by list
pcli card list --list <list-id>
# Get a card
pcli card get <card-id>
# Create a card
pcli card create --list <list-id> --name "Task name" --description "Details"
# Update a card
pcli card update <card-id> --name "New name" --description "Updated details"
# Delete a card
pcli card delete <card-id>
# Duplicate a card
pcli card duplicate <card-id> --name "Copy of task"
# Move a card to another list
pcli card move <card-id> --list <target-list-id> --position 65536
# Assign/unassign users
pcli card assign <card-id> --user <user-id>
pcli card unassign <card-id> --user <user-id>
# Add/remove labels
pcli card add-label <card-id> --label <label-id>
pcli card remove-label <card-id> --label <label-id>
# List card actions
pcli card actions <card-id> --limit 20
```
### Comments
```bash
# List comments on a card
pcli comment list --card <card-id>
# Create a comment
pcli comment create --card <card-id> --text "This is a comment"
# Update a comment
pcli comment update <comment-id> --text "Updated text"
# Delete a comment
pcli comment delete <comment-id>
```
### Task Lists
```bash
# Create a task list
pcli task-list create --card <card-id> --name "Checklist"
# Get a task list
pcli task-list get <task-list-id>
# Update a task list
pcli task-list update <task-list-id> --name "Updated name"
# Delete a task list
pcli task-list delete <task-list-id>
```
### Tasks
```bash
# Create a task
pcli task create --task-list <task-list-id> --name "Do something"
# Update a task
pcli task update <task-id> --completed true
# Delete a task
pcli task delete <task-id>
```
### Labels
```bash
# Create a label
pcli label create --board <board-id> --name "Bug" --color "berry-red"
# Update a label
pcli label update <label-id> --name "Critical Bug" --color "piggy-red"
# Delete a label
pcli label delete <label-id>
```
### Status
```bash
# Show status summary of all boards and their lists
pcli status
```
## Output Formats
### JSON (default)
All commands return JSON with a consistent envelope:
```json
{
"data": { ... },
"error": null
}
```
On error:
```json
{
"data": null,
"error": "error message"
}
```
### Table
Use `--format=table` for human-readable output:
```bash
pcli --format=table project list
```
Output:
```
ID NAME DESCRIPTION HIDDEN
1357158568008091264 Development Project A project for... false
```
## Logging
Enable debug logging to see HTTP requests and responses:
```bash
pcli --log-level=debug card list --board <board-id>
```
Logs are written to stderr in JSON format and won't interfere with stdout output.
## Examples
### Create a card and add a comment
```bash
# Create the card
CARD_ID=$(pcli card create \
--list <list-id> \
--name "Implement feature X" \
--description "Details about feature X" \
| jq -r '.data.id')
# Add a comment
pcli comment create --card $CARD_ID --text "Started working on this"
```
### Move all cards from one list to another
```bash
# Get all cards from source list
pcli card list --list <source-list-id> | jq -r '.data[].id' | while read card_id; do
pcli card move $card_id --list <target-list-id>
done
```
### Export board structure
```bash
# Get board with all lists
pcli board get <board-id> > board.json
# Get all cards for the board
pcli card list --board <board-id> > cards.json
```
## Future Enhancements
The following improvements are planned for future releases:
- **Concurrent Processing**: Use goroutines with worker pools for parallel board processing in the status command to improve performance with many boards
- **Rate Limiting**: Implement client-side rate limiting to prevent API abuse
- **Enhanced Error Context**: Include command context in all error messages for better debugging
- **Connection Pooling**: Add configurable HTTP client connection limits for better resource management
- **Input Validation**: Enhanced validation for all user inputs including URL formats and API key patterns
## API Compatibility
Built against Planka v2.0 API. See `planka-api.json` for the full OpenAPI specification.
## Contributing
Contributions welcome! Please open an issue or pull request on [GitLab](https://git.franklin.lab/steve.cliff/pcli).
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
CGO_ENABLED=0 go build -ldflags='-s -w -extldflags "-static"' -o pcli .
echo "Built: pcli"
file pcli
+98
View File
@@ -0,0 +1,98 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListBoards(ctx context.Context) ([]model.Board, error) {
data, err := c.DoNoBody(ctx, "GET", "/api/projects")
if err != nil {
return nil, err
}
var response struct {
Included struct {
Boards []model.Board `json:"boards"`
} `json:"included"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects response: %w", err)
}
return response.Included.Boards, nil
}
func (c *Client) GetBoard(ctx context.Context, id string) (*model.Board, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/boards/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Board `json:"item"`
Included struct {
Lists []model.List `json:"lists"`
Cards []model.Card `json:"cards"`
} `json:"included"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal board response: %w", err)
}
response.Item.Lists = response.Included.Lists
response.Item.Cards = response.Included.Cards
return &response.Item, nil
}
func (c *Client) ListBoardActions(ctx context.Context, boardId string, limit int) ([]model.Action, error) {
var all []model.Action
var beforeId string
for {
path := fmt.Sprintf("/api/boards/%s/actions", boardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
c.Logger.Debug("Fetching board actions page",
slog.String("boardId", boardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Action `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal board actions response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
+250
View File
@@ -0,0 +1,250 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/cards/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) CreateCard(ctx context.Context, listId string, fields map[string]any) (*model.Card, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/lists/%s/cards", listId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateCard(ctx context.Context, id string, fields map[string]any) (*model.Card, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/cards/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteCard(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/cards/%s", id))
return err
}
func (c *Client) DuplicateCard(ctx context.Context, id string, name *string, position *float64) (*model.Card, error) {
fields := make(map[string]any)
if name != nil {
fields["name"] = *name
}
if position != nil {
fields["position"] = *position
}
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/cards/%s/duplicate", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
}
func (c *Client) ListCards(ctx context.Context, listId string, limit int) ([]model.Card, error) {
var all []model.Card
var before string
for {
path := fmt.Sprintf("/api/lists/%s/cards", listId)
if before != "" {
path = fmt.Sprintf("%s?before=%s", path, before)
}
c.Logger.Debug("Fetching cards page",
slog.String("listId", listId),
slog.String("before", before),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Card `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal cards response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
before = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) ListCardActions(ctx context.Context, cardId string, limit int) ([]model.Action, error) {
var all []model.Action
var beforeId string
for {
path := fmt.Sprintf("/api/cards/%s/actions", cardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
c.Logger.Debug("Fetching card actions page",
slog.String("cardId", cardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBody(ctx, "GET", path)
if err != nil {
return nil, err
}
var response struct {
Items []model.Action `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card actions response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) ListCardsByBoard(ctx context.Context, boardId string, limit int) ([]model.CardWithList, error) {
board, err := c.GetBoard(ctx, boardId)
if err != nil {
return nil, fmt.Errorf("failed to get board: %w", err)
}
listNames := make(map[string]string)
for _, list := range board.Lists {
name := ""
if list.Name != nil {
name = *list.Name
}
listNames[list.ID] = name
}
var allCards []model.CardWithList
for _, card := range board.Cards {
cardWithList := model.CardWithList{
Card: card,
ListName: listNames[card.ListID],
}
allCards = append(allCards, cardWithList)
if limit > 0 && len(allCards) >= limit {
return allCards[:limit], nil
}
}
return allCards, nil
}
func (c *Client) AddCardLabel(ctx context.Context, cardId, labelId string) error {
fields := map[string]any{
"labelId": labelId,
}
_, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/card-labels", cardId),
fmt.Sprintf("/api/cards/%s/labels", cardId),
fields)
return err
}
func (c *Client) RemoveCardLabel(ctx context.Context, cardId, labelId string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/cards/%s/card-labels/labelId:%s", cardId, labelId),
fmt.Sprintf("/api/cards/%s/labels/%s", cardId, labelId))
return err
}
func (c *Client) AddCardMember(ctx context.Context, cardId, userId string) error {
fields := map[string]any{
"userId": userId,
}
_, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/card-memberships", cardId),
fmt.Sprintf("/api/cards/%s/memberships", cardId),
fields)
return err
}
func (c *Client) RemoveCardMember(ctx context.Context, cardId, userId string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/cards/%s/card-memberships/userId:%s", cardId, userId),
fmt.Sprintf("/api/cards/%s/memberships/%s", cardId, userId))
return err
}
+164
View File
@@ -0,0 +1,164 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.franklin.lab/steve.cliff/pcli/model"
)
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
Logger *slog.Logger
}
func NewClient(baseURL, apiKey string, logger *slog.Logger) *Client {
transport := &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
Logger: logger,
}
}
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
start := time.Now()
url := c.BaseURL + path
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("x-api-key", c.APIKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.Logger.Debug("HTTP request",
slog.String("method", method),
slog.String("path", path),
)
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.Logger.Warn("HTTP request failed",
slog.String("method", method),
slog.String("path", path),
slog.String("error", err.Error()),
)
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
duration := time.Since(start)
c.Logger.Debug("HTTP response",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.Duration("duration", duration),
)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if len(respBody) > 0 && !isJSONContentType(contentType) && respBody[0] == '<' {
c.Logger.Warn("Non-JSON response received",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("content_type", contentType),
)
return nil, &model.APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("unexpected non-JSON response (content-type: %s) — endpoint may not exist", contentType),
}
}
if resp.StatusCode >= 400 {
apiErr := &model.APIError{
StatusCode: resp.StatusCode,
Message: string(respBody),
}
var errResp map[string]any
if json.Unmarshal(respBody, &errResp) == nil {
if msg, ok := errResp["message"].(string); ok {
apiErr.Message = msg
} else if errMsg, ok := errResp["error"].(string); ok {
apiErr.Message = errMsg
}
}
c.Logger.Warn("API error",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("message", apiErr.Message),
)
return nil, apiErr
}
return json.RawMessage(respBody), nil
}
func (c *Client) DoNoBody(ctx context.Context, method, path string) (json.RawMessage, error) {
return c.Do(ctx, method, path, nil)
}
func (c *Client) DoWithFallback(ctx context.Context, method, primary, fallback string, body any) (json.RawMessage, error) {
data, err := c.Do(ctx, method, primary, body)
if err != nil {
if apiErr, ok := err.(*model.APIError); ok && apiErr.StatusCode == 404 {
c.Logger.Debug("Primary path failed, trying fallback",
slog.String("primary", primary),
slog.String("fallback", fallback),
)
return c.Do(ctx, method, fallback, body)
}
return nil, err
}
return data, nil
}
func (c *Client) DoNoBodyWithFallback(ctx context.Context, method, primary, fallback string) (json.RawMessage, error) {
return c.DoWithFallback(ctx, method, primary, fallback, nil)
}
func isJSONContentType(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
return strings.HasPrefix(ct, "application/json") ||
strings.HasPrefix(ct, "text/json")
}
+114
View File
@@ -0,0 +1,114 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListComments(ctx context.Context, cardId string, limit int) ([]model.Comment, error) {
var all []model.Comment
var beforeId string
for {
path := fmt.Sprintf("/api/cards/%s/comments", cardId)
if beforeId != "" {
path = fmt.Sprintf("%s?beforeId=%s", path, beforeId)
}
fallback := fmt.Sprintf("/api/cards/%s/comment-actions", cardId)
if beforeId != "" {
fallback = fmt.Sprintf("%s?beforeId=%s", fallback, beforeId)
}
c.Logger.Debug("Fetching comments page",
slog.String("cardId", cardId),
slog.String("beforeId", beforeId),
)
data, err := c.DoNoBodyWithFallback(ctx, "GET", path, fallback)
if err != nil {
return nil, err
}
var response struct {
Items []model.Comment `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comments response: %w", err)
}
if len(response.Items) == 0 {
break
}
all = append(all, response.Items...)
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = response.Items[len(response.Items)-1].ID
}
return all, nil
}
func (c *Client) CreateComment(ctx context.Context, cardId, text string) (*model.Comment, error) {
fields := map[string]any{
"text": text,
}
data, err := c.DoWithFallback(ctx, "POST",
fmt.Sprintf("/api/cards/%s/comments", cardId),
fmt.Sprintf("/api/cards/%s/comment-actions", cardId),
fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Comment `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comment response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateComment(ctx context.Context, id, text string) (*model.Comment, error) {
fields := map[string]any{
"text": text,
}
data, err := c.DoWithFallback(ctx, "PATCH",
fmt.Sprintf("/api/comments/%s", id),
fmt.Sprintf("/api/comment-actions/%s", id),
fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Comment `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal comment response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteComment(ctx context.Context, id string) error {
_, err := c.DoNoBodyWithFallback(ctx, "DELETE",
fmt.Sprintf("/api/comments/%s", id),
fmt.Sprintf("/api/comment-actions/%s", id))
return err
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateLabel(ctx context.Context, boardId string, fields map[string]any) (*model.Label, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/boards/%s/labels", boardId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Label `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal label response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateLabel(ctx context.Context, id string, fields map[string]any) (*model.Label, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/labels/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Label `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal label response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteLabel(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/labels/%s", id))
return err
}
+43
View File
@@ -0,0 +1,43 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ListProjects(ctx context.Context) ([]model.Project, error) {
data, err := c.DoNoBody(ctx, "GET", "/api/projects")
if err != nil {
return nil, err
}
var response struct {
Items []model.Project `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects response: %w", err)
}
return response.Items, nil
}
func (c *Client) GetProject(ctx context.Context, id string) (*model.Project, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/projects/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Project `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal project response: %w", err)
}
return &response.Item, nil
}
+65
View File
@@ -0,0 +1,65 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateTaskList(ctx context.Context, cardId string, fields map[string]any) (*model.TaskList, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/cards/%s/task-lists", cardId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) GetTaskList(ctx context.Context, id string) (*model.TaskList, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/task-lists/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateTaskList(ctx context.Context, id string, fields map[string]any) (*model.TaskList, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/task-lists/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.TaskList `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task list response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteTaskList(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/task-lists/%s", id))
return err
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"context"
"encoding/json"
"fmt"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) CreateTask(ctx context.Context, taskListId string, fields map[string]any) (*model.Task, error) {
data, err := c.Do(ctx, "POST", fmt.Sprintf("/api/task-lists/%s/tasks", taskListId), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Task `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task response: %w", err)
}
return &response.Item, nil
}
func (c *Client) UpdateTask(ctx context.Context, id string, fields map[string]any) (*model.Task, error) {
data, err := c.Do(ctx, "PATCH", fmt.Sprintf("/api/tasks/%s", id), fields)
if err != nil {
return nil, err
}
var response struct {
Item model.Task `json:"item"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal task response: %w", err)
}
return &response.Item, nil
}
func (c *Client) DeleteTask(ctx context.Context, id string) error {
_, err := c.DoNoBody(ctx, "DELETE", fmt.Sprintf("/api/tasks/%s", id))
return err
}
+66
View File
@@ -0,0 +1,66 @@
package cmd
import (
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var boardCmd = &cobra.Command{
Use: "board",
Short: "Manage boards",
Long: "Commands for managing Planka boards",
}
var boardListCmd = &cobra.Command{
Use: "list",
Short: "List all accessible boards",
RunE: func(cmd *cobra.Command, args []string) error {
boards, err := getClient().ListBoards(getContext())
if err != nil {
return err
}
return output.Print(boards, getFormat(), os.Stdout)
},
}
var boardGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get a board by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
board, err := getClient().GetBoard(getContext(), args[0])
if err != nil {
return err
}
return output.Print(board, getFormat(), os.Stdout)
},
}
var boardActionsCmd = &cobra.Command{
Use: "actions <id>",
Short: "List actions for a board",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
limit, _ := cmd.Flags().GetInt("limit")
actions, err := getClient().ListBoardActions(getContext(), args[0], limit)
if err != nil {
return err
}
return output.Print(actions, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(boardCmd)
boardCmd.AddCommand(boardListCmd)
boardCmd.AddCommand(boardGetCmd)
boardCmd.AddCommand(boardActionsCmd)
boardActionsCmd.Flags().Int("limit", 0, "Limit number of actions (0 = no limit)")
}
+357
View File
@@ -0,0 +1,357 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var cardCmd = &cobra.Command{
Use: "card",
Short: "Manage cards",
Long: "Commands for managing Planka cards",
}
var cardListCmd = &cobra.Command{
Use: "list",
Short: "List cards",
RunE: func(cmd *cobra.Command, args []string) error {
boardId, _ := cmd.Flags().GetString("board")
listId, _ := cmd.Flags().GetString("list")
limit, _ := cmd.Flags().GetInt("limit")
if boardId == "" && listId == "" {
return fmt.Errorf("either --board or --list must be specified")
}
if boardId != "" && listId != "" {
return fmt.Errorf("--board and --list are mutually exclusive")
}
if boardId != "" {
cards, err := getClient().ListCardsByBoard(getContext(), boardId, limit)
if err != nil {
return err
}
return output.Print(cards, getFormat(), os.Stdout)
}
cards, err := getClient().ListCards(getContext(), listId, limit)
if err != nil {
return err
}
return output.Print(cards, getFormat(), os.Stdout)
},
}
var cardGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get a card by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
card, err := getClient().GetCard(getContext(), args[0])
if err != nil {
return err
}
return output.Print(card, getFormat(), os.Stdout)
},
}
var cardCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new card",
RunE: func(cmd *cobra.Command, args []string) error {
listId, _ := cmd.Flags().GetString("list")
name, _ := cmd.Flags().GetString("name")
if listId == "" {
return fmt.Errorf("--list is required")
}
if name == "" {
return fmt.Errorf("--name is required")
}
fields := map[string]any{
"name": name,
"type": "project",
"position": float64(65536),
}
if desc, _ := cmd.Flags().GetString("description"); desc != "" {
fields["description"] = desc
}
if cardType, _ := cmd.Flags().GetString("type"); cardType != "" {
fields["type"] = cardType
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if dueDate, _ := cmd.Flags().GetString("due-date"); dueDate != "" {
fields["dueDate"] = dueDate
}
if dueCompleted, _ := cmd.Flags().GetBool("due-completed"); cmd.Flags().Changed("due-completed") {
fields["isDueCompleted"] = dueCompleted
}
card, err := getClient().CreateCard(getContext(), listId, fields)
if err != nil {
return err
}
return output.Print(card, getFormat(), os.Stdout)
},
}
var cardUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fields := make(map[string]any)
if name, _ := cmd.Flags().GetString("name"); name != "" {
fields["name"] = name
}
if desc, _ := cmd.Flags().GetString("description"); cmd.Flags().Changed("description") {
fields["description"] = desc
}
if cardType, _ := cmd.Flags().GetString("type"); cardType != "" {
fields["type"] = cardType
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if dueDate, _ := cmd.Flags().GetString("due-date"); cmd.Flags().Changed("due-date") {
fields["dueDate"] = dueDate
}
if dueCompleted, _ := cmd.Flags().GetBool("due-completed"); cmd.Flags().Changed("due-completed") {
fields["isDueCompleted"] = dueCompleted
}
if len(fields) == 0 {
return fmt.Errorf("at least one field must be specified for update")
}
card, err := getClient().UpdateCard(getContext(), args[0], fields)
if err != nil {
return err
}
return output.Print(card, getFormat(), os.Stdout)
},
}
var cardDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := getClient().DeleteCard(getContext(), args[0])
if err != nil {
return err
}
return output.Print(map[string]string{"status": "deleted"}, getFormat(), os.Stdout)
},
}
var cardDuplicateCmd = &cobra.Command{
Use: "duplicate <id>",
Short: "Duplicate a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
pos := float64(65536)
if cmd.Flags().Changed("position") {
pos, _ = cmd.Flags().GetFloat64("position")
}
card, err := getClient().DuplicateCard(getContext(), args[0], &name, &pos)
if err != nil {
return err
}
return output.Print(card, getFormat(), os.Stdout)
},
}
var cardMoveCmd = &cobra.Command{
Use: "move <id>",
Short: "Move a card to a different list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
listId, _ := cmd.Flags().GetString("list")
if listId == "" {
return fmt.Errorf("--list is required")
}
fields := map[string]any{
"listId": listId,
"position": float64(65536),
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
card, err := getClient().UpdateCard(getContext(), args[0], fields)
if err != nil {
return err
}
return output.Print(card, getFormat(), os.Stdout)
},
}
var cardAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign a user to a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
userId, _ := cmd.Flags().GetString("user")
if userId == "" {
return fmt.Errorf("--user is required")
}
err := getClient().AddCardMember(getContext(), args[0], userId)
if err != nil {
return err
}
return output.Print(map[string]string{"status": "assigned"}, getFormat(), os.Stdout)
},
}
var cardUnassignCmd = &cobra.Command{
Use: "unassign <id>",
Short: "Unassign a user from a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
userId, _ := cmd.Flags().GetString("user")
if userId == "" {
return fmt.Errorf("--user is required")
}
err := getClient().RemoveCardMember(getContext(), args[0], userId)
if err != nil {
return err
}
return output.Print(map[string]string{"status": "unassigned"}, getFormat(), os.Stdout)
},
}
var cardAddLabelCmd = &cobra.Command{
Use: "add-label <id>",
Short: "Add a label to a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labelId, _ := cmd.Flags().GetString("label")
if labelId == "" {
return fmt.Errorf("--label is required")
}
err := getClient().AddCardLabel(getContext(), args[0], labelId)
if err != nil {
return err
}
return output.Print(map[string]string{"status": "label added"}, getFormat(), os.Stdout)
},
}
var cardRemoveLabelCmd = &cobra.Command{
Use: "remove-label <id>",
Short: "Remove a label from a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labelId, _ := cmd.Flags().GetString("label")
if labelId == "" {
return fmt.Errorf("--label is required")
}
err := getClient().RemoveCardLabel(getContext(), args[0], labelId)
if err != nil {
return err
}
return output.Print(map[string]string{"status": "label removed"}, getFormat(), os.Stdout)
},
}
var cardActionsCmd = &cobra.Command{
Use: "actions <id>",
Short: "List actions for a card",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
limit, _ := cmd.Flags().GetInt("limit")
actions, err := getClient().ListCardActions(getContext(), args[0], limit)
if err != nil {
return err
}
return output.Print(actions, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(cardCmd)
cardCmd.AddCommand(cardListCmd)
cardCmd.AddCommand(cardGetCmd)
cardCmd.AddCommand(cardCreateCmd)
cardCmd.AddCommand(cardUpdateCmd)
cardCmd.AddCommand(cardDeleteCmd)
cardCmd.AddCommand(cardDuplicateCmd)
cardCmd.AddCommand(cardMoveCmd)
cardCmd.AddCommand(cardAssignCmd)
cardCmd.AddCommand(cardUnassignCmd)
cardCmd.AddCommand(cardAddLabelCmd)
cardCmd.AddCommand(cardRemoveLabelCmd)
cardCmd.AddCommand(cardActionsCmd)
cardListCmd.Flags().String("board", "", "Board ID to list cards from")
cardListCmd.Flags().String("list", "", "List ID to list cards from")
cardListCmd.Flags().Int("limit", 0, "Limit number of cards (0 = no limit)")
cardCreateCmd.Flags().String("list", "", "List ID (required)")
cardCreateCmd.Flags().String("name", "", "Card name (required)")
cardCreateCmd.Flags().String("description", "", "Card description")
cardCreateCmd.Flags().String("type", "", "Card type (project or story)")
cardCreateCmd.Flags().Float64("position", 0, "Card position")
cardCreateCmd.Flags().String("due-date", "", "Due date (ISO 8601 format)")
cardCreateCmd.Flags().Bool("due-completed", false, "Whether due date is completed")
cardUpdateCmd.Flags().String("name", "", "Card name")
cardUpdateCmd.Flags().String("description", "", "Card description")
cardUpdateCmd.Flags().String("type", "", "Card type (project or story)")
cardUpdateCmd.Flags().Float64("position", 0, "Card position")
cardUpdateCmd.Flags().String("due-date", "", "Due date (ISO 8601 format)")
cardUpdateCmd.Flags().Bool("due-completed", false, "Whether due date is completed")
cardDuplicateCmd.Flags().String("name", "", "Name for duplicated card")
cardDuplicateCmd.Flags().Float64("position", 0, "Position for duplicated card")
cardMoveCmd.Flags().String("list", "", "Target list ID (required)")
cardMoveCmd.Flags().Float64("position", 0, "Position in target list")
cardAssignCmd.Flags().String("user", "", "User ID (required)")
cardUnassignCmd.Flags().String("user", "", "User ID (required)")
cardAddLabelCmd.Flags().String("label", "", "Label ID (required)")
cardRemoveLabelCmd.Flags().String("label", "", "Label ID (required)")
cardActionsCmd.Flags().Int("limit", 0, "Limit number of actions (0 = no limit)")
}
+108
View File
@@ -0,0 +1,108 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var commentCmd = &cobra.Command{
Use: "comment",
Short: "Manage comments",
Long: "Commands for managing Planka card comments",
}
var commentListCmd = &cobra.Command{
Use: "list",
Short: "List comments for a card",
RunE: func(cmd *cobra.Command, args []string) error {
cardId, _ := cmd.Flags().GetString("card")
limit, _ := cmd.Flags().GetInt("limit")
if cardId == "" {
return fmt.Errorf("--card is required")
}
comments, err := getClient().ListComments(getContext(), cardId, limit)
if err != nil {
return err
}
return output.Print(comments, getFormat(), os.Stdout)
},
}
var commentCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new comment",
RunE: func(cmd *cobra.Command, args []string) error {
cardId, _ := cmd.Flags().GetString("card")
text, _ := cmd.Flags().GetString("text")
if cardId == "" {
return fmt.Errorf("--card is required")
}
if text == "" {
return fmt.Errorf("--text is required")
}
comment, err := getClient().CreateComment(getContext(), cardId, text)
if err != nil {
return err
}
return output.Print(comment, getFormat(), os.Stdout)
},
}
var commentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a comment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
text, _ := cmd.Flags().GetString("text")
if text == "" {
return fmt.Errorf("--text is required")
}
comment, err := getClient().UpdateComment(getContext(), args[0], text)
if err != nil {
return err
}
return output.Print(comment, getFormat(), os.Stdout)
},
}
var commentDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a comment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := getClient().DeleteComment(getContext(), args[0])
if err != nil {
return err
}
return output.Print(map[string]string{"status": "deleted"}, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(commentCmd)
commentCmd.AddCommand(commentListCmd)
commentCmd.AddCommand(commentCreateCmd)
commentCmd.AddCommand(commentUpdateCmd)
commentCmd.AddCommand(commentDeleteCmd)
commentListCmd.Flags().String("card", "", "Card ID (required)")
commentListCmd.Flags().Int("limit", 0, "Limit number of comments (0 = no limit)")
commentCreateCmd.Flags().String("card", "", "Card ID (required)")
commentCreateCmd.Flags().String("text", "", "Comment text (required)")
commentUpdateCmd.Flags().String("text", "", "Comment text (required)")
}
+113
View File
@@ -0,0 +1,113 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var labelCmd = &cobra.Command{
Use: "label",
Short: "Manage labels",
Long: "Commands for managing Planka labels",
}
var labelCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new label",
RunE: func(cmd *cobra.Command, args []string) error {
boardId, _ := cmd.Flags().GetString("board")
name, _ := cmd.Flags().GetString("name")
if boardId == "" {
return fmt.Errorf("--board is required")
}
if name == "" {
return fmt.Errorf("--name is required")
}
color, _ := cmd.Flags().GetString("color")
if color == "" {
return fmt.Errorf("--color is required")
}
fields := map[string]any{
"name": name,
"color": color,
"position": float64(65536),
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
label, err := getClient().CreateLabel(getContext(), boardId, fields)
if err != nil {
return err
}
return output.Print(label, getFormat(), os.Stdout)
},
}
var labelUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a label",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fields := make(map[string]any)
if name, _ := cmd.Flags().GetString("name"); name != "" {
fields["name"] = name
}
if color, _ := cmd.Flags().GetString("color"); color != "" {
fields["color"] = color
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if len(fields) == 0 {
return fmt.Errorf("at least one field must be specified for update")
}
label, err := getClient().UpdateLabel(getContext(), args[0], fields)
if err != nil {
return err
}
return output.Print(label, getFormat(), os.Stdout)
},
}
var labelDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a label",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := getClient().DeleteLabel(getContext(), args[0])
if err != nil {
return err
}
return output.Print(map[string]string{"status": "deleted"}, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(labelCmd)
labelCmd.AddCommand(labelCreateCmd)
labelCmd.AddCommand(labelUpdateCmd)
labelCmd.AddCommand(labelDeleteCmd)
labelCreateCmd.Flags().String("board", "", "Board ID (required)")
labelCreateCmd.Flags().String("name", "", "Label name (required)")
labelCreateCmd.Flags().String("color", "", "Label color")
labelCreateCmd.Flags().Float64("position", 0, "Position")
labelUpdateCmd.Flags().String("name", "", "Label name")
labelUpdateCmd.Flags().String("color", "", "Label color")
labelUpdateCmd.Flags().Float64("position", 0, "Position")
}
+47
View File
@@ -0,0 +1,47 @@
package cmd
import (
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var projectCmd = &cobra.Command{
Use: "project",
Short: "Manage projects",
Long: "Commands for managing Planka projects",
}
var projectListCmd = &cobra.Command{
Use: "list",
Short: "List all projects",
RunE: func(cmd *cobra.Command, args []string) error {
projects, err := getClient().ListProjects(getContext())
if err != nil {
return err
}
return output.Print(projects, getFormat(), os.Stdout)
},
}
var projectGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get a project by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
project, err := getClient().GetProject(getContext(), args[0])
if err != nil {
return err
}
return output.Print(project, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(projectCmd)
projectCmd.AddCommand(projectListCmd)
projectCmd.AddCommand(projectGetCmd)
}
+106
View File
@@ -0,0 +1,106 @@
package cmd
import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"git.franklin.lab/steve.cliff/pcli/client"
"git.franklin.lab/steve.cliff/pcli/logging"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var (
flagFormat string
flagURL string
flagAPIKey string
flagLogLevel string
apiClient *client.Client
logger *slog.Logger
)
var rootCmd = &cobra.Command{
Use: "pcli",
Short: "CLI for Planka API",
Long: "A command-line interface for interacting with the Planka project management API",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
url := flagURL
if url == "" {
url = os.Getenv("PLANKA_URL")
}
if url == "" {
return fmt.Errorf("PLANKA_URL must be set via --url flag or PLANKA_URL environment variable")
}
// Validate URL format
if err := validateURL(url); err != nil {
return fmt.Errorf("invalid PLANKA_URL: %w", err)
}
apiKey := flagAPIKey
if apiKey == "" {
apiKey = os.Getenv("PLANKA_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("PLANKA_API_KEY must be set via --api-key flag or PLANKA_API_KEY environment variable")
}
logger = logging.NewLogger(flagLogLevel)
logger.Debug("Initializing client",
slog.String("url", url),
slog.String("log_level", flagLogLevel),
)
apiClient = client.NewClient(url, apiKey, logger)
return nil
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&flagFormat, "format", "json", "Output format (json or table)")
rootCmd.PersistentFlags().StringVar(&flagURL, "url", "", "Planka API URL (overrides PLANKA_URL env var)")
rootCmd.PersistentFlags().StringVar(&flagAPIKey, "api-key", "", "Planka API key (overrides PLANKA_API_KEY env var)")
rootCmd.PersistentFlags().StringVar(&flagLogLevel, "log-level", "warn", "Log level (debug, info, warn, error)")
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
output.PrintError(err, flagFormat, os.Stdout)
os.Exit(1)
}
}
func getClient() *client.Client {
return apiClient
}
func getContext() context.Context {
return context.Background()
}
func getFormat() string {
return flagFormat
}
func validateURL(urlStr string) error {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("failed to parse URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL must use http or https scheme, got: %s", parsedURL.Scheme)
}
if parsedURL.Host == "" {
return fmt.Errorf("URL must have a valid host")
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/model"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show status summary of all boards and their lists",
Long: "Displays a summary of all boards, their lists, and the number of cards in each list",
RunE: func(cmd *cobra.Command, args []string) error {
// Get all boards
boards, err := getClient().ListBoards(getContext())
if err != nil {
return fmt.Errorf("failed to list boards: %w", err)
}
// Build status summary with error collection
summary := model.StatusSummary{
TotalBoards: len(boards),
Boards: make([]model.BoardSummary, 0, len(boards)),
}
var boardErrors []string
successfulBoards := 0
// For each board, get details and aggregate card counts
for _, board := range boards {
boardDetail, err := getClient().GetBoard(getContext(), board.ID)
if err != nil {
boardErrors = append(boardErrors, fmt.Sprintf("Board %s (%s): %v", board.Name, board.ID, err))
continue
}
// Initialize list summaries with zero counts
listCounts := make(map[string]*model.ListSummary)
listOrder := make([]string, 0, len(boardDetail.Lists))
for _, list := range boardDetail.Lists {
listName := ""
if list.Name != nil {
listName = *list.Name
}
listCounts[list.ID] = &model.ListSummary{
ID: list.ID,
Name: listName,
OpenCards: 0,
ClosedCards: 0,
}
listOrder = append(listOrder, list.ID)
}
// Count cards per list
for _, card := range boardDetail.Cards {
if listSummary, exists := listCounts[card.ListID]; exists {
if card.IsClosed {
listSummary.ClosedCards++
} else {
listSummary.OpenCards++
}
}
}
// Build list summaries in original order
listSummaries := make([]model.ListSummary, 0, len(listOrder))
for _, id := range listOrder {
listSummaries = append(listSummaries, *listCounts[id])
}
// Add board summary
boardSummary := model.BoardSummary{
ID: board.ID,
Name: board.Name,
Lists: listSummaries,
}
summary.Boards = append(summary.Boards, boardSummary)
successfulBoards++
}
// Update total boards to reflect successful loads
summary.TotalBoards = successfulBoards
// If we have errors but some successful boards, add error info to output
if len(boardErrors) > 0 && successfulBoards > 0 {
fmt.Fprintf(os.Stderr, "Warning: %d board(s) failed to load:\n", len(boardErrors))
for _, errMsg := range boardErrors {
fmt.Fprintf(os.Stderr, " - %s\n", errMsg)
}
fmt.Fprintf(os.Stderr, "\nShowing %d successful board(s):\n\n", successfulBoards)
} else if len(boardErrors) > 0 && successfulBoards == 0 {
return fmt.Errorf("failed to load any boards: %s", boardErrors[0])
}
// Output the summary
return output.Print(summary, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(statusCmd)
}
+110
View File
@@ -0,0 +1,110 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var taskCmd = &cobra.Command{
Use: "task",
Short: "Manage tasks",
Long: "Commands for managing Planka tasks",
}
var taskCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new task",
RunE: func(cmd *cobra.Command, args []string) error {
taskListId, _ := cmd.Flags().GetString("task-list")
name, _ := cmd.Flags().GetString("name")
if taskListId == "" {
return fmt.Errorf("--task-list is required")
}
if name == "" {
return fmt.Errorf("--name is required")
}
fields := map[string]any{
"name": name,
"position": float64(65536),
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if completed, _ := cmd.Flags().GetBool("completed"); cmd.Flags().Changed("completed") {
fields["isCompleted"] = completed
}
task, err := getClient().CreateTask(getContext(), taskListId, fields)
if err != nil {
return err
}
return output.Print(task, getFormat(), os.Stdout)
},
}
var taskUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fields := make(map[string]any)
if name, _ := cmd.Flags().GetString("name"); name != "" {
fields["name"] = name
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if completed, _ := cmd.Flags().GetBool("completed"); cmd.Flags().Changed("completed") {
fields["isCompleted"] = completed
}
if len(fields) == 0 {
return fmt.Errorf("at least one field must be specified for update")
}
task, err := getClient().UpdateTask(getContext(), args[0], fields)
if err != nil {
return err
}
return output.Print(task, getFormat(), os.Stdout)
},
}
var taskDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := getClient().DeleteTask(getContext(), args[0])
if err != nil {
return err
}
return output.Print(map[string]string{"status": "deleted"}, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(taskCmd)
taskCmd.AddCommand(taskCreateCmd)
taskCmd.AddCommand(taskUpdateCmd)
taskCmd.AddCommand(taskDeleteCmd)
taskCreateCmd.Flags().String("task-list", "", "Task list ID (required)")
taskCreateCmd.Flags().String("name", "", "Task name (required)")
taskCreateCmd.Flags().Float64("position", 0, "Position")
taskCreateCmd.Flags().Bool("completed", false, "Whether task is completed")
taskUpdateCmd.Flags().String("name", "", "Task name")
taskUpdateCmd.Flags().Float64("position", 0, "Position")
taskUpdateCmd.Flags().Bool("completed", false, "Whether task is completed")
}
+133
View File
@@ -0,0 +1,133 @@
package cmd
import (
"fmt"
"os"
"git.franklin.lab/steve.cliff/pcli/output"
"github.com/spf13/cobra"
)
var taskListCmd = &cobra.Command{
Use: "task-list",
Short: "Manage task lists",
Long: "Commands for managing Planka task lists",
}
var taskListCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new task list",
RunE: func(cmd *cobra.Command, args []string) error {
cardId, _ := cmd.Flags().GetString("card")
name, _ := cmd.Flags().GetString("name")
if cardId == "" {
return fmt.Errorf("--card is required")
}
if name == "" {
return fmt.Errorf("--name is required")
}
fields := map[string]any{
"name": name,
"position": float64(65536),
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if showOnFront, _ := cmd.Flags().GetBool("show-on-front"); cmd.Flags().Changed("show-on-front") {
fields["showOnFrontOfCard"] = showOnFront
}
if hideCompleted, _ := cmd.Flags().GetBool("hide-completed"); cmd.Flags().Changed("hide-completed") {
fields["hideCompletedTasks"] = hideCompleted
}
taskList, err := getClient().CreateTaskList(getContext(), cardId, fields)
if err != nil {
return err
}
return output.Print(taskList, getFormat(), os.Stdout)
},
}
var taskListGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get a task list by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
taskList, err := getClient().GetTaskList(getContext(), args[0])
if err != nil {
return err
}
return output.Print(taskList, getFormat(), os.Stdout)
},
}
var taskListUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a task list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fields := make(map[string]any)
if name, _ := cmd.Flags().GetString("name"); name != "" {
fields["name"] = name
}
if pos, _ := cmd.Flags().GetFloat64("position"); cmd.Flags().Changed("position") {
fields["position"] = pos
}
if showOnFront, _ := cmd.Flags().GetBool("show-on-front"); cmd.Flags().Changed("show-on-front") {
fields["showOnFrontOfCard"] = showOnFront
}
if hideCompleted, _ := cmd.Flags().GetBool("hide-completed"); cmd.Flags().Changed("hide-completed") {
fields["hideCompletedTasks"] = hideCompleted
}
if len(fields) == 0 {
return fmt.Errorf("at least one field must be specified for update")
}
taskList, err := getClient().UpdateTaskList(getContext(), args[0], fields)
if err != nil {
return err
}
return output.Print(taskList, getFormat(), os.Stdout)
},
}
var taskListDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a task list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := getClient().DeleteTaskList(getContext(), args[0])
if err != nil {
return err
}
return output.Print(map[string]string{"status": "deleted"}, getFormat(), os.Stdout)
},
}
func init() {
rootCmd.AddCommand(taskListCmd)
taskListCmd.AddCommand(taskListCreateCmd)
taskListCmd.AddCommand(taskListGetCmd)
taskListCmd.AddCommand(taskListUpdateCmd)
taskListCmd.AddCommand(taskListDeleteCmd)
taskListCreateCmd.Flags().String("card", "", "Card ID (required)")
taskListCreateCmd.Flags().String("name", "", "Task list name (required)")
taskListCreateCmd.Flags().Float64("position", 0, "Position")
taskListCreateCmd.Flags().Bool("show-on-front", true, "Show on front of card")
taskListCreateCmd.Flags().Bool("hide-completed", false, "Hide completed tasks")
taskListUpdateCmd.Flags().String("name", "", "Task list name")
taskListUpdateCmd.Flags().Float64("position", 0, "Position")
taskListUpdateCmd.Flags().Bool("show-on-front", true, "Show on front of card")
taskListUpdateCmd.Flags().Bool("hide-completed", false, "Hide completed tasks")
}
+40
View File
@@ -0,0 +1,40 @@
package cmd
import (
"fmt"
"os/exec"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version information",
Long: "Display the current version of pcli",
Run: func(cmd *cobra.Command, args []string) {
version := getVersion()
fmt.Println(version)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
func getVersion() string {
// Try to get version from git tag
version, err := exec.Command("git", "describe", "--tags", "--exact-match").Output()
if err == nil && len(version) > 0 {
return string(version)
}
// Fallback to timestamp-based version
dateCmd := exec.Command("date", "+%Y%m%d-%H%M%S")
date, err := dateCmd.Output()
if err != nil {
// Ultimate fallback
return "unknown"
}
return "v" + string(date)
}
+143
View File
@@ -0,0 +1,143 @@
---
name: example-skill
description: Manage Planka project boards using the pcli CLI. Use when the user wants to interact with Planka boards, cards, lists, tasks, labels, or comments.
compatibility: Requires pcli binary in PATH and PLANKA_URL + PLANKA_API_KEY environment variables set
metadata:
author: steve-cliff
version: "1.0"
---
# pcli - Planka CLI
CLI for the Planka project management API. All commands return JSON by default with envelope `{"data": ..., "error": null}`. Use `jq` to extract fields.
## Prerequisites
Ensure environment variables are set:
```bash
export PLANKA_URL="https://planka.example.com"
export PLANKA_API_KEY="your-api-key"
```
Ensure `jq` is installed.
## Global Flags
All commands accept: `--format json|table`, `--url <url>`, `--api-key <key>`, `--log-level debug|info|warn|error`
## Commands
### Status Overview
```bash
pcli status
```
Returns summary of all boards, lists, and card counts (open/closed per list).
### Projects
```bash
pcli project list
pcli project get <project-id>
```
### Boards
```bash
pcli board list
pcli board get <board-id> # includes lists and cards
pcli board actions <board-id> [--limit N]
```
### Cards
```bash
# List (one of --board or --list required, mutually exclusive)
pcli card list --board <board-id> [--limit N]
pcli card list --list <list-id> [--limit N]
# CRUD
pcli card get <card-id>
pcli card create --list <list-id> --name "Name" [--description "..."] [--type project|story] [--position N] [--due-date "ISO8601"] [--due-completed]
pcli card update <card-id> [--name "..."] [--description "..."] [--type ...] [--position N] [--due-date "..."] [--due-completed]
pcli card delete <card-id>
pcli card duplicate <card-id> --name "Copy" [--position N]
pcli card move <card-id> --list <target-list-id> [--position N]
# Members
pcli card assign <card-id> --user <user-id>
pcli card unassign <card-id> --user <user-id>
# Labels
pcli card add-label <card-id> --label <label-id>
pcli card remove-label <card-id> --label <label-id>
# Actions
pcli card actions <card-id> [--limit N]
```
### Comments
```bash
pcli comment list --card <card-id> [--limit N]
pcli comment create --card <card-id> --text "..."
pcli comment update <comment-id> --text "..."
pcli comment delete <comment-id>
```
### Task Lists
```bash
pcli task-list create --card <card-id> --name "Checklist" [--position N] [--show-on-front] [--hide-completed]
pcli task-list get <task-list-id>
pcli task-list update <task-list-id> [--name "..."] [--position N] [--show-on-front] [--hide-completed]
pcli task-list delete <task-list-id>
```
### Tasks
```bash
pcli task create --task-list <task-list-id> --name "Item" [--position N] [--completed]
pcli task update <task-id> [--name "..."] [--position N] [--completed]
pcli task delete <task-id>
```
### Labels
```bash
pcli label create --board <board-id> --name "Bug" --color "berry-red" [--position N]
pcli label update <label-id> [--name "..."] [--color "..."] [--position N]
pcli label delete <label-id>
```
## Extracting IDs from Output
All responses use `{"data": ..., "error": null}`. Extract IDs with jq:
```bash
# Single object
pcli card create --list <id> --name "X" | jq -r '.data.id'
# Array
pcli card list --board <id> | jq -r '.data[].id'
```
## Common Workflows
### Create a card with a checklist
```bash
CARD_ID=$(pcli card create --list <list-id> --name "Task" | jq -r '.data.id')
TL_ID=$(pcli task-list create --card $CARD_ID --name "Steps" | jq -r '.data.id')
pcli task create --task-list $TL_ID --name "Step 1"
pcli task create --task-list $TL_ID --name "Step 2"
```
### Move all cards between lists
```bash
pcli card list --list <source-list-id> | jq -r '.data[].id' | while read id; do
pcli card move $id --list <target-list-id>
done
```
+9
View File
@@ -0,0 +1,9 @@
module git.franklin.lab/steve.cliff/pcli
go 1.22.2
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+30
View File
@@ -0,0 +1,30 @@
package logging
import (
"log/slog"
"os"
"strings"
)
func NewLogger(level string) *slog.Logger {
var logLevel slog.Level
switch strings.ToUpper(level) {
case "DEBUG":
logLevel = slog.LevelDebug
case "INFO":
logLevel = slog.LevelInfo
case "WARN":
logLevel = slog.LevelWarn
case "ERROR":
logLevel = slog.LevelError
default:
logLevel = slog.LevelWarn
}
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
})
return slog.New(handler)
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.franklin.lab/steve.cliff/pcli/cmd"
)
func main() {
cmd.Execute()
}
+178
View File
@@ -0,0 +1,178 @@
package model
import (
"encoding/json"
"fmt"
)
type Project struct {
ID string `json:"id"`
OwnerProjectManagerID *string `json:"ownerProjectManagerId"`
BackgroundImageID *string `json:"backgroundImageId"`
Name string `json:"name"`
Description *string `json:"description"`
BackgroundType *string `json:"backgroundType"`
BackgroundGradient *string `json:"backgroundGradient"`
IsHidden bool `json:"isHidden"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Board struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Position float64 `json:"position"`
Name string `json:"name"`
DefaultView string `json:"defaultView"`
DefaultCardType string `json:"defaultCardType"`
LimitCardTypesToDefaultOne bool `json:"limitCardTypesToDefaultOne"`
AlwaysDisplayCardCreator bool `json:"alwaysDisplayCardCreator"`
ExpandTaskListsByDefault bool `json:"expandTaskListsByDefault"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
Lists []List `json:"lists,omitempty"`
Cards []Card `json:"cards,omitempty"`
}
type List struct {
ID string `json:"id"`
BoardID string `json:"boardId"`
Type string `json:"type"`
Position *float64 `json:"position"`
Name *string `json:"name"`
Color *string `json:"color"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Stopwatch struct {
StartedAt string `json:"startedAt"`
Total float64 `json:"total"`
}
type Card struct {
ID string `json:"id"`
BoardID string `json:"boardId"`
ListID string `json:"listId"`
CreatorUserID *string `json:"creatorUserId"`
PrevListID *string `json:"prevListId"`
CoverAttachmentID *string `json:"coverAttachmentId"`
Type string `json:"type"`
Position *float64 `json:"position"`
Name string `json:"name"`
Description *string `json:"description"`
DueDate *string `json:"dueDate"`
IsDueCompleted *bool `json:"isDueCompleted"`
Stopwatch *Stopwatch `json:"stopwatch"`
CommentsTotal int `json:"commentsTotal"`
IsClosed bool `json:"isClosed"`
ListChangedAt *string `json:"listChangedAt"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type CardWithList struct {
Card
ListName string `json:"listName"`
}
type Comment struct {
ID string `json:"id"`
CardID string `json:"cardId"`
UserID *string `json:"userId"`
Text string `json:"text"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type TaskList struct {
ID string `json:"id"`
CardID string `json:"cardId"`
Position float64 `json:"position"`
Name string `json:"name"`
ShowOnFrontOfCard bool `json:"showOnFrontOfCard"`
HideCompletedTasks bool `json:"hideCompletedTasks"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Task struct {
ID string `json:"id"`
TaskListID string `json:"taskListId"`
LinkedCardID *string `json:"linkedCardId"`
AssigneeUserID *string `json:"assigneeUserId"`
Position float64 `json:"position"`
Name string `json:"name"`
IsCompleted bool `json:"isCompleted"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Label struct {
ID string `json:"id"`
BoardID string `json:"boardId"`
Position float64 `json:"position"`
Name *string `json:"name"`
Color string `json:"color"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Action struct {
ID string `json:"id"`
BoardID *string `json:"boardId"`
CardID string `json:"cardId"`
UserID *string `json:"userId"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type CardLabel struct {
ID string `json:"id"`
CardID string `json:"cardId"`
LabelID string `json:"labelId"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type CardMembership struct {
ID string `json:"id"`
CardID string `json:"cardId"`
UserID string `json:"userId"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
}
type Envelope struct {
Data any `json:"data"`
Error *string `json:"error"`
}
type StatusSummary struct {
TotalBoards int `json:"totalBoards"`
Boards []BoardSummary `json:"boards"`
}
type BoardSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Lists []ListSummary `json:"lists"`
}
type ListSummary struct {
ID string `json:"id"`
Name string `json:"name"`
OpenCards int `json:"openCards"`
ClosedCards int `json:"closedCards"`
}
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Message)
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-09
@@ -0,0 +1,215 @@
## Context
This is a greenfield Go project. There is no existing codebase — the repository currently contains only the Planka OpenAPI 3.0 spec (`planka-api.json`) and OpenSpec configuration. The Planka API uses JWT bearer auth and returns JSON responses. The API follows a nested resource model: Projects → Boards → Lists → Cards, with sub-resources (comments, tasks, labels, memberships) hanging off cards and boards.
Primary consumers are AI agents and CI/CD pipelines, not humans at a terminal. This shapes every design decision — JSON-first output, env-var auth, no interactive prompts.
## Goals / Non-Goals
**Goals:**
- Single statically-linked Go binary with zero runtime dependencies
- Predictable, machine-parseable output for every command
- Clean mapping from CLI commands to Planka API operations
- Minimal abstraction — thin client, not an SDK
- Easy to extend with new resources/commands later
**Non-Goals:**
- Interactive TUI or prompt-based workflows
- Caching, offline mode, or local state
- WebSocket/real-time integration (Planka supports it, but out of scope)
- Admin operations (user management, project/board CRUD beyond read)
- Attachment upload/download
- Shell completions (can be added later, not v1)
## Decisions
### 1. Project layout — flat `internal/` package vs separate packages
**Decision**: Separate packages under project root: `cmd/`, `client/`, `model/`, `output/`
**Alternatives considered**:
- `internal/` with sub-packages — adds nesting without benefit for a single-binary tool
- Single `main` package — doesn't scale past ~10 files
**Rationale**: Clear separation of concerns. `cmd/` owns CLI wiring, `client/` owns HTTP, `model/` owns types, `output/` owns formatting. Each package has a single responsibility and can be tested independently.
```
github.com/dcgsteve/pcli
├── main.go → entry point, calls cmd.Execute()
├── cmd/
│ ├── root.go → root command, global flags, env loading
│ ├── project.go → project list, get
│ ├── board.go → board get, actions
│ ├── card.go → card CRUD, move, duplicate, assign, labels
│ ├── comment.go → comment CRUD
│ ├── task_list.go → task-list CRUD
│ ├── task.go → task CRUD
│ └── label.go → label CRUD
├── client/
│ ├── client.go → base HTTP client (URL, token, Do, error handling)
│ ├── projects.go
│ ├── boards.go
│ ├── cards.go
│ ├── comments.go
│ ├── task_lists.go
│ ├── tasks.go
│ └── labels.go
├── model/
│ └── types.go → all API structs + response envelope
├── output/
│ └── output.go → Print(data, format), envelope wrapping, table rendering
├── logging/
│ └── logging.go → structured logger setup, level control
├── go.mod
└── go.sum
```
### 2. HTTP client — code-generated vs hand-rolled
**Decision**: Hand-rolled thin client using `net/http` from the standard library.
**Alternatives considered**:
- `oapi-codegen` — generates typed client from OpenAPI spec. Produces verbose code, hard to customize, and we only cover ~30 of 96 endpoints.
- `go-resty` or similar HTTP wrapper — adds a dependency for minimal benefit over `net/http`.
**Rationale**: The API is straightforward REST/JSON. A base client with `Do(method, path, body) (*http.Response, error)` plus per-resource methods is simple, readable, and fully under our control. ~30 endpoints don't justify code generation overhead.
### 3. Base client design
```go
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
Logger *slog.Logger
}
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error)
```
- `Do` handles: URL construction, bearer token header, JSON marshal/unmarshal, HTTP error → Go error mapping
- `Do` logs every request at DEBUG level (`method`, `path`, `status`, `duration`) and errors at WARN
- Per-resource files add typed methods: `func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error)`
- API errors (4xx/5xx) are mapped to a structured `APIError` type with status code and message
### 4. Response envelope
**Decision**: All CLI output wrapped in `{"data": ..., "error": null}`.
```go
type Envelope struct {
Data any `json:"data"`
Error *string `json:"error"`
}
```
- On success: `{"data": <result>, "error": null}` + exit code 0
- On error: `{"data": null, "error": "<message>"}` + exit code 1
- Table format (`--format=table`) bypasses the envelope — prints human-readable rows directly, errors go to stderr
### 5. Auth — env vars with flag override
**Decision**: `PLANKA_URL` and `PLANKA_TOKEN` environment variables, overridable with `--url` and `--token` global flags.
**Rationale**: Env vars are the standard for CI/CD and agent environments. Flag overrides allow ad-hoc use without modifying the environment. No config file, no login command, no token storage.
Precedence: flag > env var. Missing values produce a clear error message and exit code 1.
### 6. CLI framework — Cobra
**Decision**: `github.com/spf13/cobra` for command structure.
**Alternatives considered**:
- `urfave/cli` — simpler but less ecosystem support, no built-in nested subcommands
- `kong` — struct-based, clean but less widely adopted
**Rationale**: Cobra is the de facto standard for Go CLIs. Nested subcommand support maps directly to our `pcli <resource> <action>` pattern. Well-documented, widely understood.
### 7. `card list --board <id>` enrichment
This command requires multiple API calls:
1. `GET /boards/{id}` → get board details (includes list of lists with IDs and names)
2. `GET /lists/{listId}/cards` for each list → get cards
The client method composes these calls and injects `listName` into each card response. This is the only command that does multi-call enrichment — all others are 1:1 with API endpoints.
### 8. Model types — partial structs
**Decision**: Model structs include all fields from the API schemas that are relevant to our scoped operations. Fields use `json` tags and pointer types for nullable/optional fields.
**Rationale**: We don't need every field from every schema. Types are defined once in `model/types.go` and shared by both `client/` and `cmd/`. Using `json.RawMessage` for truly dynamic fields (like `Action.data`).
### 9. Pagination — cursor-based with auto-fetch
The Planka API uses cursor-based pagination on 4 list endpoints:
- `GET /boards/{boardId}/actions``beforeId` param
- `GET /cards/{cardId}/actions``beforeId` param
- `GET /lists/{listId}/cards``before` param
- `GET /cards/{cardId}/comments``beforeId` param
**Decision**: Build pagination into the client layer from the start. List methods accept an optional limit and automatically page through results.
- `--limit N` flag on all list commands (actions, cards, comments). When set, fetch stops after N total items. When omitted, fetch all pages.
- Client list methods accept a `limit` parameter (0 = no limit).
```go
// Client list methods follow this pattern:
func (c *Client) ListCardComments(ctx context.Context, cardId string, limit int) ([]model.Comment, error) {
var all []model.Comment
var beforeId string
for {
page, err := c.listCardCommentsPage(ctx, cardId, beforeId)
if err != nil { return nil, err }
all = append(all, page...)
if len(page) == 0 { break }
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = page[len(page)-1].ID // cursor = last item's ID
}
return all, nil
}
```
**Rationale**: Cursor-based pagination is easy to get wrong if bolted on later — callers assume they get complete results. Building it in from day one means every list command returns complete data by default, while `--limit` gives agents control over response size and latency. The loop terminates when a page returns empty results or the limit is reached.
Logging at DEBUG level tracks each page fetch so pagination behavior is observable.
### 10. Structured logging
**Decision**: Use Go's standard library `log/slog` for structured logging.
**Alternatives considered**:
- `zerolog` — fast, but an external dependency for something `slog` handles well
- `logrus` — widely used but effectively in maintenance mode; `slog` is the successor
- No logging — makes debugging agent/CI failures opaque
**Rationale**: `slog` is in the standard library (Go 1.21+), produces structured JSON or text output, and has zero dependencies. Perfect fit for a tool consumed by agents.
**Log levels**:
- `DEBUG` — HTTP request/response details, pagination page fetches, config resolution
- `INFO` — command execution summary (what resource, what action)
- `WARN` — non-fatal issues (e.g., unexpected API response fields)
- `ERROR` — failures that cause non-zero exit
**Control**: `--log-level` global flag (default: `WARN`). Agents can set `--log-level=debug` for full visibility. Logs go to **stderr** so they never pollute the JSON output on stdout.
```go
// In root.go
var logLevel string // global flag: --log-level
func initLogger() *slog.Logger {
var level slog.Level
level.UnmarshalText([]byte(logLevel))
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
}
```
## Risks / Trade-offs
- **API version coupling** → The client is built against Planka v2.0 API. Breaking API changes require pcli updates. Mitigation: pin to known API version, document compatibility.
- **Pagination completeness** → Auto-fetching all pages could be slow for very large result sets (e.g., thousands of actions). Mitigation: `--limit N` flag available on all list commands to cap results.
- **Multi-call `card list --board`** → Slower than a single API call, N+1 pattern (1 board call + N list calls). Mitigation: acceptable for typical board sizes (< 20 lists). Could parallelize list fetches if needed.
- **No retry/backoff** → Network errors fail immediately. Mitigation: acceptable for v1. Agents can implement their own retry logic. Can add later with minimal changes to `client.Do`.
- **Bearer token in env var** → Token visible in process environment. Mitigation: standard practice for CI/CD tools; better than on-disk storage or passing credentials.
@@ -0,0 +1,35 @@
## Why
Planka is a self-hosted Kanban board application with a REST API, but there is no CLI tool to interact with it programmatically. AI agents and automated workflows need a simple, scriptable interface to manage cards, tasks, and comments without using the web UI. A single Go binary (`pcli`) provides a lightweight, dependency-free tool that can be dropped into any CI/CD pipeline or agent environment.
## What Changes
- New Go binary `pcli` providing a Cobra-based CLI to interact with the Planka API
- Nested command structure: `pcli <resource> <action> [args] [flags]`
- Authentication via bearer token from environment variables (`PLANKA_URL`, `PLANKA_TOKEN`) — no login flow, no on-disk token storage
- JSON output by default with consistent `{"data": ..., "error": null}` response envelope
- Optional `--format=table` for human-readable output
- Read-only access to projects and boards
- Full CRUD for cards, comments, task lists, tasks, and labels
- Card operations: move, duplicate, assign/unassign users, add/remove labels
- Board and card activity log (actions) — read-only
- `card list --board <id>` enriches responses with `listName` (resolved from list data)
- Lists treated as an implementation detail — not exposed as top-level commands
## Capabilities
### New Capabilities
- `api-client`: HTTP client layer for Planka API — base client with auth, request/response handling, and per-resource methods
- `cli-commands`: Cobra command tree — project, board, card, comment, task-list, task, label subcommands with flags and argument parsing
- `output-formatting`: Response envelope and format switching — JSON default, table option, consistent error handling
- `card-operations`: Card-specific composite operations — move (list + position), duplicate, assign/unassign members, add/remove labels, enriched board-level card listing
### Modified Capabilities
None — greenfield project.
## Impact
- **New repository structure**: `main.go`, `cmd/`, `client/`, `model/`, `output/` packages
- **Dependencies**: Go module `github.com/dcgsteve/pcli`, Cobra library, standard library HTTP client
- **External API**: Depends on Planka v2.0 API (OpenAPI 3.0 spec in `planka-api.json`)
- **Environment**: Requires `PLANKA_URL` and `PLANKA_TOKEN` environment variables at runtime
@@ -0,0 +1,195 @@
## ADDED Requirements
### Requirement: Base HTTP client
The system SHALL provide a base HTTP client that sends requests to the Planka API. The client SHALL construct URLs by joining the configured base URL with the API path. The client SHALL attach an `Authorization: Bearer <token>` header to every request. The client SHALL send and receive JSON (`Content-Type: application/json`). The client SHALL accept a `*slog.Logger` and log every request at DEBUG level with method, path, status code, and duration. The client SHALL log errors at WARN level.
#### Scenario: Successful API request
- **WHEN** the client sends a request to a valid endpoint
- **THEN** the response body SHALL be returned as parsed JSON
- **AND** the request SHALL include the bearer token header
- **AND** a DEBUG log entry SHALL be emitted with method, path, status, and duration
#### Scenario: API returns error status
- **WHEN** the API responds with a 4xx or 5xx status code
- **THEN** the client SHALL return an `APIError` containing the HTTP status code and response message
- **AND** a WARN log entry SHALL be emitted
#### Scenario: Network failure
- **WHEN** the HTTP request fails due to a network error (connection refused, timeout, DNS failure)
- **THEN** the client SHALL return a Go error wrapping the underlying network error
### Requirement: Authentication from environment
The system SHALL read `PLANKA_URL` from the environment to determine the API base URL. The system SHALL read `PLANKA_TOKEN` from the environment to determine the bearer token. Global flags `--url` and `--token` SHALL override the corresponding environment variables. Flag values SHALL take precedence over environment variables.
#### Scenario: Auth from environment variables
- **WHEN** `PLANKA_URL` and `PLANKA_TOKEN` are set in the environment
- **AND** no `--url` or `--token` flags are provided
- **THEN** the client SHALL use the environment variable values
#### Scenario: Flag overrides environment
- **WHEN** `--url` or `--token` flags are provided
- **THEN** the flag values SHALL take precedence over environment variables
#### Scenario: Missing configuration
- **WHEN** neither the environment variable nor the flag is set for URL or token
- **THEN** the system SHALL print an error message and exit with code 1
### Requirement: Cursor-based pagination
The system SHALL implement cursor-based pagination for all list endpoints that support it. Paginated endpoints are: `GET /boards/{boardId}/actions` (`beforeId`), `GET /cards/{cardId}/actions` (`beforeId`), `GET /lists/{listId}/cards` (`before`), `GET /cards/{cardId}/comments` (`beforeId`). List methods SHALL accept a `limit` parameter. When `limit` is 0, the client SHALL fetch all pages. When `limit` is greater than 0, the client SHALL stop fetching after accumulating at least `limit` items and truncate the result to exactly `limit` items. Each page fetch SHALL be logged at DEBUG level.
#### Scenario: Fetch all pages
- **WHEN** a list method is called with limit 0
- **THEN** the client SHALL fetch pages until an empty page is returned
- **AND** all items from all pages SHALL be returned
#### Scenario: Fetch with limit
- **WHEN** a list method is called with limit N (N > 0)
- **THEN** the client SHALL stop fetching after accumulating N or more items
- **AND** the returned slice SHALL contain exactly N items
#### Scenario: Single page result
- **WHEN** the API returns fewer items than a page size and no more pages exist
- **THEN** the client SHALL return those items without making additional requests
### Requirement: Project operations
The client SHALL provide methods to list all accessible projects (`GET /projects`) and get a single project by ID (`GET /projects/{id}`).
#### Scenario: List projects
- **WHEN** `ListProjects` is called
- **THEN** the client SHALL send `GET /projects` and return a slice of Project models
#### Scenario: Get project
- **WHEN** `GetProject` is called with a project ID
- **THEN** the client SHALL send `GET /projects/{id}` and return a Project model
### Requirement: Board operations
The client SHALL provide a method to get a single board by ID (`GET /boards/{id}`) and list board actions (`GET /boards/{boardId}/actions`) with pagination support.
#### Scenario: Get board
- **WHEN** `GetBoard` is called with a board ID
- **THEN** the client SHALL send `GET /boards/{id}` and return a Board model including its included lists
#### Scenario: List board actions
- **WHEN** `ListBoardActions` is called with a board ID and limit
- **THEN** the client SHALL send paginated `GET /boards/{boardId}/actions` requests and return a slice of Action models
### Requirement: Card CRUD operations
The client SHALL provide methods for: get card (`GET /cards/{id}`), create card (`POST /lists/{listId}/cards`), update card (`PATCH /cards/{id}`), delete card (`DELETE /cards/{id}`), and duplicate card (`POST /cards/{id}/duplicate`). The client SHALL provide a method to list cards in a list (`GET /lists/{listId}/cards`) with pagination support. The client SHALL provide a method to list card actions (`GET /cards/{cardId}/actions`) with pagination support.
#### Scenario: Get card
- **WHEN** `GetCard` is called with a card ID
- **THEN** the client SHALL send `GET /cards/{id}` and return a Card model
#### Scenario: Create card
- **WHEN** `CreateCard` is called with a list ID and card fields (name, description, type, position, dueDate, isDueCompleted)
- **THEN** the client SHALL send `POST /lists/{listId}/cards` with the provided fields and return the created Card
#### Scenario: Update card
- **WHEN** `UpdateCard` is called with a card ID and update fields
- **THEN** the client SHALL send `PATCH /cards/{id}` with only the provided fields and return the updated Card
#### Scenario: Delete card
- **WHEN** `DeleteCard` is called with a card ID
- **THEN** the client SHALL send `DELETE /cards/{id}`
#### Scenario: Duplicate card
- **WHEN** `DuplicateCard` is called with a card ID, name, and position
- **THEN** the client SHALL send `POST /cards/{id}/duplicate` and return the new Card
#### Scenario: List cards in list
- **WHEN** `ListCards` is called with a list ID and limit
- **THEN** the client SHALL send paginated `GET /lists/{listId}/cards` requests and return a slice of Card models
#### Scenario: List card actions
- **WHEN** `ListCardActions` is called with a card ID and limit
- **THEN** the client SHALL send paginated `GET /cards/{cardId}/actions` requests and return a slice of Action models
### Requirement: Comment operations
The client SHALL provide methods for: list comments (`GET /cards/{cardId}/comments`) with pagination, create comment (`POST /cards/{cardId}/comments`), update comment (`PATCH /comments/{id}`), and delete comment (`DELETE /comments/{id}`).
#### Scenario: List comments
- **WHEN** `ListComments` is called with a card ID and limit
- **THEN** the client SHALL send paginated `GET /cards/{cardId}/comments` requests and return a slice of Comment models
#### Scenario: Create comment
- **WHEN** `CreateComment` is called with a card ID and text
- **THEN** the client SHALL send `POST /cards/{cardId}/comments` with the text and return the created Comment
#### Scenario: Update comment
- **WHEN** `UpdateComment` is called with a comment ID and text
- **THEN** the client SHALL send `PATCH /comments/{id}` and return the updated Comment
#### Scenario: Delete comment
- **WHEN** `DeleteComment` is called with a comment ID
- **THEN** the client SHALL send `DELETE /comments/{id}`
### Requirement: Task list operations
The client SHALL provide methods for: create task list (`POST /cards/{cardId}/task-lists`), get task list (`GET /task-lists/{id}`), update task list (`PATCH /task-lists/{id}`), and delete task list (`DELETE /task-lists/{id}`).
#### Scenario: Create task list
- **WHEN** `CreateTaskList` is called with a card ID and fields (name, position, showOnFrontOfCard, hideCompletedTasks)
- **THEN** the client SHALL send `POST /cards/{cardId}/task-lists` and return the created TaskList
#### Scenario: Get task list
- **WHEN** `GetTaskList` is called with a task list ID
- **THEN** the client SHALL send `GET /task-lists/{id}` and return a TaskList model
#### Scenario: Update task list
- **WHEN** `UpdateTaskList` is called with a task list ID and update fields
- **THEN** the client SHALL send `PATCH /task-lists/{id}` and return the updated TaskList
#### Scenario: Delete task list
- **WHEN** `DeleteTaskList` is called with a task list ID
- **THEN** the client SHALL send `DELETE /task-lists/{id}`
### Requirement: Task operations
The client SHALL provide methods for: create task (`POST /task-lists/{taskListId}/tasks`), update task (`PATCH /tasks/{id}`), and delete task (`DELETE /tasks/{id}`).
#### Scenario: Create task
- **WHEN** `CreateTask` is called with a task list ID and fields (name, position, isCompleted, assigneeUserId, linkedCardId)
- **THEN** the client SHALL send `POST /task-lists/{taskListId}/tasks` and return the created Task
#### Scenario: Update task
- **WHEN** `UpdateTask` is called with a task ID and update fields (name, position, isCompleted, assigneeUserId, taskListId)
- **THEN** the client SHALL send `PATCH /tasks/{id}` and return the updated Task
#### Scenario: Delete task
- **WHEN** `DeleteTask` is called with a task ID
- **THEN** the client SHALL send `DELETE /tasks/{id}`
### Requirement: Label operations
The client SHALL provide methods for: create label (`POST /boards/{boardId}/labels`), update label (`PATCH /labels/{id}`), and delete label (`DELETE /labels/{id}`).
#### Scenario: Create label
- **WHEN** `CreateLabel` is called with a board ID and fields (name, color, position)
- **THEN** the client SHALL send `POST /boards/{boardId}/labels` and return the created Label
#### Scenario: Update label
- **WHEN** `UpdateLabel` is called with a label ID and update fields (name, color, position)
- **THEN** the client SHALL send `PATCH /labels/{id}` and return the updated Label
#### Scenario: Delete label
- **WHEN** `DeleteLabel` is called with a label ID
- **THEN** the client SHALL send `DELETE /labels/{id}`
### Requirement: Card label operations
The client SHALL provide methods for: add label to card (`POST /cards/{cardId}/card-labels`) and remove label from card (`DELETE /cards/{cardId}/card-labels/labelId:{labelId}`).
#### Scenario: Add label to card
- **WHEN** `AddCardLabel` is called with a card ID and label ID
- **THEN** the client SHALL send `POST /cards/{cardId}/card-labels` with the label ID
#### Scenario: Remove label from card
- **WHEN** `RemoveCardLabel` is called with a card ID and label ID
- **THEN** the client SHALL send `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`
### Requirement: Card membership operations
The client SHALL provide methods for: assign user to card (`POST /cards/{cardId}/card-memberships`) and unassign user from card (`DELETE /cards/{cardId}/card-memberships/userId:{userId}`).
#### Scenario: Assign user to card
- **WHEN** `AddCardMember` is called with a card ID and user ID
- **THEN** the client SHALL send `POST /cards/{cardId}/card-memberships` with the user ID
#### Scenario: Unassign user from card
- **WHEN** `RemoveCardMember` is called with a card ID and user ID
- **THEN** the client SHALL send `DELETE /cards/{cardId}/card-memberships/userId:{userId}`
@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: Move card between lists
The system SHALL provide a `card move` operation that updates a card's `listId` to move it to a different list. The operation SHALL accept an optional `position` to place the card at a specific position within the target list. The move operation SHALL be implemented as a `PATCH /cards/{id}` call with `listId` and optionally `position` fields. The CLI command SHALL be `pcli card move <id> --list <listId> [--position N]`.
#### Scenario: Move card to another list
- **WHEN** `pcli card move <id> --list <targetListId>` is executed
- **THEN** the system SHALL send `PATCH /cards/{id}` with `{"listId": "<targetListId>"}`
- **AND** the system SHALL output the updated card with its new listId
#### Scenario: Move card to specific position
- **WHEN** `pcli card move <id> --list <targetListId> --position 0` is executed
- **THEN** the system SHALL send `PATCH /cards/{id}` with `{"listId": "<targetListId>", "position": 0}`
- **AND** the card SHALL appear at the specified position in the target list
#### Scenario: Move card missing list flag
- **WHEN** `pcli card move <id>` is executed without `--list`
- **THEN** the system SHALL print an error indicating `--list` is required and exit with code 1
### Requirement: Duplicate card
The system SHALL provide a `card duplicate` operation that creates a copy of an existing card. The operation SHALL call `POST /cards/{id}/duplicate`. The CLI command SHALL be `pcli card duplicate <id> [--name <name>] [--position N]`. If `--name` is not provided, the API determines the name of the duplicate.
#### Scenario: Duplicate card with defaults
- **WHEN** `pcli card duplicate <id>` is executed
- **THEN** the system SHALL send `POST /cards/{id}/duplicate`
- **AND** the system SHALL output the newly created duplicate card
#### Scenario: Duplicate card with custom name
- **WHEN** `pcli card duplicate <id> --name "Copy of task"` is executed
- **THEN** the system SHALL send `POST /cards/{id}/duplicate` with `{"name": "Copy of task"}`
- **AND** the duplicate card SHALL have the specified name
### Requirement: Assign and unassign card members
The system SHALL provide `card assign` and `card unassign` operations to manage card memberships. `card assign` SHALL call `POST /cards/{cardId}/card-memberships` with the user ID. `card unassign` SHALL call `DELETE /cards/{cardId}/card-memberships/userId:{userId}`.
#### Scenario: Assign user to card
- **WHEN** `pcli card assign <cardId> --user <userId>` is executed
- **THEN** the system SHALL send `POST /cards/{cardId}/card-memberships` with `{"userId": "<userId>"}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Unassign user from card
- **WHEN** `pcli card unassign <cardId> --user <userId>` is executed
- **THEN** the system SHALL send `DELETE /cards/{cardId}/card-memberships/userId:{userId}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Assign missing user flag
- **WHEN** `pcli card assign <cardId>` is executed without `--user`
- **THEN** the system SHALL print an error indicating `--user` is required and exit with code 1
### Requirement: Add and remove card labels
The system SHALL provide `card add-label` and `card remove-label` operations to manage labels on cards. `card add-label` SHALL call `POST /cards/{cardId}/card-labels` with the label ID. `card remove-label` SHALL call `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`.
#### Scenario: Add label to card
- **WHEN** `pcli card add-label <cardId> --label <labelId>` is executed
- **THEN** the system SHALL send `POST /cards/{cardId}/card-labels` with `{"labelId": "<labelId>"}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Remove label from card
- **WHEN** `pcli card remove-label <cardId> --label <labelId>` is executed
- **THEN** the system SHALL send `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Add label missing label flag
- **WHEN** `pcli card add-label <cardId>` is executed without `--label`
- **THEN** the system SHALL print an error indicating `--label` is required and exit with code 1
### Requirement: Enriched board-level card listing
The system SHALL provide a `card list --board <id>` operation that returns all cards across all lists in a board, with each card enriched with the `listName` field. The operation SHALL: (1) call `GET /boards/{id}` to retrieve the board and its included lists, (2) call `GET /lists/{listId}/cards` for each list to retrieve cards (with pagination support), (3) inject `listName` into each card based on the list it belongs to. The `--limit` flag SHALL apply to the total number of cards returned across all lists.
#### Scenario: List all cards on a board
- **WHEN** `pcli card list --board <id>` is executed
- **THEN** the system SHALL return all cards from all lists in the board
- **AND** each card SHALL include a `listName` field with the name of its containing list
- **AND** each card SHALL include a `listId` field
#### Scenario: Board card listing with limit
- **WHEN** `pcli card list --board <id> --limit 10` is executed
- **THEN** the system SHALL return at most 10 cards total across all lists
- **AND** each card SHALL include the `listName` field
#### Scenario: Board with no cards
- **WHEN** `pcli card list --board <id>` is executed on a board with empty lists
- **THEN** the system SHALL return an empty array
#### Scenario: Board with multiple lists
- **WHEN** a board has lists "To Do", "In Progress", and "Done" each containing cards
- **THEN** the returned cards SHALL have `listName` set to the respective list name
- **AND** cards from all lists SHALL be included in the result
@@ -0,0 +1,188 @@
## ADDED Requirements
### Requirement: Root command and global flags
The system SHALL provide a root command `pcli` that serves as the entry point. The root command SHALL register global flags: `--format` (string, default `json`, values `json` or `table`), `--url` (string, overrides `PLANKA_URL`), `--token` (string, overrides `PLANKA_TOKEN`), and `--log-level` (string, default `warn`, values `debug`, `info`, `warn`, `error`). The root command SHALL initialize the logger based on `--log-level` and configure it to write structured JSON to stderr. The root command SHALL validate that URL and token are available (from flags or environment) before executing any subcommand.
#### Scenario: Display help
- **WHEN** `pcli` is run with no arguments or `--help`
- **THEN** the system SHALL display usage information listing all resource subcommands and global flags
#### Scenario: Invalid format flag
- **WHEN** `--format` is set to an unsupported value
- **THEN** the system SHALL print an error and exit with code 1
#### Scenario: Log level controls output
- **WHEN** `--log-level=debug` is set
- **THEN** DEBUG-level log entries SHALL appear on stderr
- **AND** stdout SHALL contain only the command's data output
### Requirement: Project commands
The system SHALL provide a `project` command group with subcommands `list` and `get`. `pcli project list` SHALL call the client's ListProjects method and output the result. `pcli project get <id>` SHALL accept a project ID as a positional argument, call GetProject, and output the result.
#### Scenario: List projects
- **WHEN** `pcli project list` is executed
- **THEN** the system SHALL output all accessible projects
#### Scenario: Get project by ID
- **WHEN** `pcli project get <id>` is executed with a valid project ID
- **THEN** the system SHALL output the project details
#### Scenario: Get project missing ID
- **WHEN** `pcli project get` is executed without an ID argument
- **THEN** the system SHALL print an error indicating the ID is required and exit with code 1
### Requirement: Board commands
The system SHALL provide a `board` command group with subcommands `get` and `actions`. `pcli board get <id>` SHALL accept a board ID as a positional argument and output the board details. `pcli board actions <id>` SHALL accept a board ID and an optional `--limit` flag (int, default 0) and output the board's action history.
#### Scenario: Get board
- **WHEN** `pcli board get <id>` is executed
- **THEN** the system SHALL output the board details including its lists
#### Scenario: List board actions
- **WHEN** `pcli board actions <id>` is executed
- **THEN** the system SHALL output the board's action history
#### Scenario: List board actions with limit
- **WHEN** `pcli board actions <id> --limit 10` is executed
- **THEN** the system SHALL output at most 10 action entries
### Requirement: Card commands
The system SHALL provide a `card` command group with subcommands: `list`, `get`, `create`, `update`, `delete`, `duplicate`, `move`, `assign`, `unassign`, `add-label`, `remove-label`, `actions`.
`pcli card list` SHALL require either `--board <id>` or `--list <id>` flag and accept an optional `--limit` flag. `pcli card get <id>` SHALL accept a card ID as a positional argument. `pcli card create` SHALL require `--list <id>` and `--name <name>` flags and accept optional flags: `--description`, `--type`, `--position`, `--due-date`, `--due-completed`. `pcli card update <id>` SHALL accept a card ID and optional flags for each updatable field: `--name`, `--description`, `--type`, `--position`, `--due-date`, `--due-completed`. `pcli card delete <id>` SHALL accept a card ID. `pcli card duplicate <id>` SHALL accept a card ID and optional `--name` and `--position` flags. `pcli card move <id>` SHALL require `--list <id>` and accept optional `--position` flag. `pcli card assign <id>` SHALL require `--user <userId>`. `pcli card unassign <id>` SHALL require `--user <userId>`. `pcli card add-label <id>` SHALL require `--label <labelId>`. `pcli card remove-label <id>` SHALL require `--label <labelId>`. `pcli card actions <id>` SHALL accept an optional `--limit` flag.
#### Scenario: List cards by board
- **WHEN** `pcli card list --board <id>` is executed
- **THEN** the system SHALL output all cards across all lists in the board, each enriched with listName
#### Scenario: List cards by list
- **WHEN** `pcli card list --list <id>` is executed
- **THEN** the system SHALL output cards in the specified list
#### Scenario: List cards with limit
- **WHEN** `pcli card list --list <id> --limit 5` is executed
- **THEN** the system SHALL output at most 5 cards
#### Scenario: Card list missing board or list flag
- **WHEN** `pcli card list` is executed without `--board` or `--list`
- **THEN** the system SHALL print an error indicating one is required and exit with code 1
#### Scenario: Get card
- **WHEN** `pcli card get <id>` is executed
- **THEN** the system SHALL output the card details
#### Scenario: Create card
- **WHEN** `pcli card create --list <id> --name "Task name"` is executed
- **THEN** the system SHALL create the card and output the created card
#### Scenario: Create card missing required flags
- **WHEN** `pcli card create` is executed without `--list` or `--name`
- **THEN** the system SHALL print an error and exit with code 1
#### Scenario: Update card
- **WHEN** `pcli card update <id> --name "New name"` is executed
- **THEN** the system SHALL update the card and output the updated card
#### Scenario: Delete card
- **WHEN** `pcli card delete <id>` is executed
- **THEN** the system SHALL delete the card and output a success confirmation
#### Scenario: Duplicate card
- **WHEN** `pcli card duplicate <id>` is executed
- **THEN** the system SHALL duplicate the card and output the new card
#### Scenario: Move card
- **WHEN** `pcli card move <id> --list <listId>` is executed
- **THEN** the system SHALL update the card's listId (and optionally position) and output the updated card
#### Scenario: Assign user to card
- **WHEN** `pcli card assign <id> --user <userId>` is executed
- **THEN** the system SHALL add the user as a card member
#### Scenario: Unassign user from card
- **WHEN** `pcli card unassign <id> --user <userId>` is executed
- **THEN** the system SHALL remove the user from the card's members
#### Scenario: Add label to card
- **WHEN** `pcli card add-label <id> --label <labelId>` is executed
- **THEN** the system SHALL add the label to the card
#### Scenario: Remove label from card
- **WHEN** `pcli card remove-label <id> --label <labelId>` is executed
- **THEN** the system SHALL remove the label from the card
#### Scenario: List card actions
- **WHEN** `pcli card actions <id>` is executed
- **THEN** the system SHALL output the card's action history
### Requirement: Comment commands
The system SHALL provide a `comment` command group with subcommands: `list`, `create`, `update`, `delete`. `pcli comment list` SHALL require `--card <id>` and accept optional `--limit`. `pcli comment create` SHALL require `--card <id>` and `--text <text>`. `pcli comment update <id>` SHALL require `--text <text>`. `pcli comment delete <id>` SHALL accept a comment ID.
#### Scenario: List comments
- **WHEN** `pcli comment list --card <id>` is executed
- **THEN** the system SHALL output comments for the card
#### Scenario: Create comment
- **WHEN** `pcli comment create --card <id> --text "comment text"` is executed
- **THEN** the system SHALL create the comment and output the created comment
#### Scenario: Update comment
- **WHEN** `pcli comment update <id> --text "updated text"` is executed
- **THEN** the system SHALL update the comment and output the updated comment
#### Scenario: Delete comment
- **WHEN** `pcli comment delete <id>` is executed
- **THEN** the system SHALL delete the comment and output a success confirmation
### Requirement: Task list commands
The system SHALL provide a `task-list` command group with subcommands: `create`, `get`, `update`, `delete`. `pcli task-list create` SHALL require `--card <id>` and `--name <name>` and accept optional flags: `--position`, `--show-on-front`, `--hide-completed`. `pcli task-list get <id>` SHALL accept a task list ID. `pcli task-list update <id>` SHALL accept optional flags for each updatable field. `pcli task-list delete <id>` SHALL accept a task list ID.
#### Scenario: Create task list
- **WHEN** `pcli task-list create --card <id> --name "Checklist"` is executed
- **THEN** the system SHALL create the task list and output the created task list
#### Scenario: Get task list
- **WHEN** `pcli task-list get <id>` is executed
- **THEN** the system SHALL output the task list details including its tasks
#### Scenario: Update task list
- **WHEN** `pcli task-list update <id> --name "Renamed"` is executed
- **THEN** the system SHALL update the task list and output the updated task list
#### Scenario: Delete task list
- **WHEN** `pcli task-list delete <id>` is executed
- **THEN** the system SHALL delete the task list and output a success confirmation
### Requirement: Task commands
The system SHALL provide a `task` command group with subcommands: `create`, `update`, `delete`. `pcli task create` SHALL require `--task-list <id>` and `--name <name>` and accept optional flags: `--position`, `--completed`, `--assignee`, `--linked-card`. `pcli task update <id>` SHALL accept optional flags for each updatable field: `--name`, `--position`, `--completed`, `--assignee`, `--task-list`. `pcli task delete <id>` SHALL accept a task ID.
#### Scenario: Create task
- **WHEN** `pcli task create --task-list <id> --name "Do something"` is executed
- **THEN** the system SHALL create the task and output the created task
#### Scenario: Update task
- **WHEN** `pcli task update <id> --completed` is executed
- **THEN** the system SHALL mark the task as completed and output the updated task
#### Scenario: Move task to different list
- **WHEN** `pcli task update <id> --task-list <newListId>` is executed
- **THEN** the system SHALL move the task to the specified task list
#### Scenario: Delete task
- **WHEN** `pcli task delete <id>` is executed
- **THEN** the system SHALL delete the task and output a success confirmation
### Requirement: Label commands
The system SHALL provide a `label` command group with subcommands: `create`, `update`, `delete`. `pcli label create` SHALL require `--board <id>` and `--name <name>` and accept optional flags: `--color`, `--position`. `pcli label update <id>` SHALL accept optional flags: `--name`, `--color`, `--position`. `pcli label delete <id>` SHALL accept a label ID.
#### Scenario: Create label
- **WHEN** `pcli label create --board <id> --name "Bug" --color red` is executed
- **THEN** the system SHALL create the label and output the created label
#### Scenario: Update label
- **WHEN** `pcli label update <id> --name "Feature" --color green` is executed
- **THEN** the system SHALL update the label and output the updated label
#### Scenario: Delete label
- **WHEN** `pcli label delete <id>` is executed
- **THEN** the system SHALL delete the label and output a success confirmation
@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: JSON output envelope
The system SHALL wrap all successful command output in a JSON envelope with the structure `{"data": <result>, "error": null}`. The system SHALL wrap all error output in a JSON envelope with the structure `{"data": null, "error": "<message>"}`. The envelope SHALL be written to stdout. The `data` field SHALL contain the direct result of the command (object or array). The `error` field SHALL be null on success and a string message on failure.
#### Scenario: Successful command output
- **WHEN** a command completes successfully in JSON format
- **THEN** stdout SHALL contain `{"data": <result>, "error": null}`
- **AND** the process SHALL exit with code 0
#### Scenario: Error command output
- **WHEN** a command fails in JSON format
- **THEN** stdout SHALL contain `{"data": null, "error": "<message>"}`
- **AND** the process SHALL exit with code 1
#### Scenario: Envelope structure is consistent
- **WHEN** any command is executed in JSON format
- **THEN** the output SHALL always contain exactly the keys `data` and `error` at the top level
### Requirement: Table format output
The system SHALL support a `--format=table` flag that outputs results as human-readable tabular text. Table output SHALL be written to stdout. Table output SHALL NOT use the JSON envelope. When table format is active and an error occurs, the error message SHALL be written to stderr (not stdout). Each resource type SHALL define its own column set for table rendering.
#### Scenario: Table output for a list of items
- **WHEN** a list command is executed with `--format=table`
- **THEN** stdout SHALL contain a header row followed by one row per item
- **AND** columns SHALL be aligned and separated by whitespace
#### Scenario: Table output for a single item
- **WHEN** a get command is executed with `--format=table`
- **THEN** stdout SHALL contain a key-value representation of the item
#### Scenario: Error in table format
- **WHEN** a command fails with `--format=table`
- **THEN** the error message SHALL be written to stderr
- **AND** the process SHALL exit with code 1
### Requirement: Format flag default
The system SHALL default to JSON format when no `--format` flag is provided. The `--format` flag SHALL accept values `json` and `table`. Any other value SHALL cause an error and exit with code 1.
#### Scenario: Default format is JSON
- **WHEN** a command is executed without `--format`
- **THEN** the output SHALL be in JSON envelope format
#### Scenario: Explicit JSON format
- **WHEN** a command is executed with `--format=json`
- **THEN** the output SHALL be in JSON envelope format
#### Scenario: Explicit table format
- **WHEN** a command is executed with `--format=table`
- **THEN** the output SHALL be in table format
#### Scenario: Invalid format value
- **WHEN** a command is executed with `--format=xml`
- **THEN** the system SHALL print an error and exit with code 1
### Requirement: Log output separation
All log output SHALL be written to stderr using structured JSON via `log/slog`. Log output SHALL never appear on stdout. This ensures that stdout contains only the command's data output (JSON envelope or table) and is safe to pipe or parse programmatically.
#### Scenario: Logs do not pollute stdout
- **WHEN** a command is executed with `--log-level=debug`
- **THEN** all log entries SHALL appear on stderr
- **AND** stdout SHALL contain only the command's data output
#### Scenario: Logs are structured JSON
- **WHEN** a log entry is emitted
- **THEN** it SHALL be a valid JSON object with at minimum `time`, `level`, and `msg` fields
@@ -0,0 +1,133 @@
## 1. Project Scaffolding
- [x] 1.1 Initialize Go module (`go mod init github.com/dcgsteve/pcli`)
- [x] 1.2 Create directory structure: `cmd/`, `client/`, `model/`, `output/`, `logging/`
- [x] 1.3 Add Cobra dependency (`go get github.com/spf13/cobra`)
- [x] 1.4 Create `main.go` entry point that calls `cmd.Execute()`
## 2. Logging
- [x] 2.1 Implement `logging/logging.go``NewLogger(level string) *slog.Logger` that parses level string, creates JSON handler writing to stderr
## 3. Model Types
- [x] 3.1 Define `model/types.go` — Project, Board, List, Card, Comment, TaskList, Task, Label, Action, CardLabel, CardMembership structs with JSON tags and pointer types for nullable fields
- [x] 3.2 Define `Envelope` struct (`Data any`, `Error *string`) in `model/types.go`
- [x] 3.3 Define `APIError` struct (StatusCode int, Message string) implementing the `error` interface
## 4. Output Formatting
- [x] 4.1 Implement `output/output.go``Print(data any, format string, w io.Writer)` function that switches on format
- [x] 4.2 Implement JSON output mode — marshal `Envelope{Data: data, Error: nil}` to stdout
- [x] 4.3 Implement error output — `PrintError(err error, format string, w io.Writer)` that writes `Envelope{Data: nil, Error: msg}` for JSON, or error to stderr for table
- [x] 4.4 Implement table output mode — tabwriter-based rendering with per-resource column definitions
## 5. Base HTTP Client
- [x] 5.1 Implement `client/client.go``Client` struct with BaseURL, Token, HTTPClient, Logger fields
- [x] 5.2 Implement `NewClient(baseURL, token string, logger *slog.Logger) *Client`
- [x] 5.3 Implement `Do(ctx, method, path string, body any) (json.RawMessage, error)` — URL construction, bearer header, JSON marshal/unmarshal, DEBUG logging with method/path/status/duration
- [x] 5.4 Implement `APIError` handling — map 4xx/5xx responses to APIError, WARN logging on errors
- [x] 5.5 Implement `DoNoBody(ctx, method, path string) (json.RawMessage, error)` convenience method for GET/DELETE
## 6. Root Command and Global Flags
- [x] 6.1 Implement `cmd/root.go` — root Cobra command with `--format`, `--url`, `--token`, `--log-level` persistent flags
- [x] 6.2 Implement `PersistentPreRunE` — resolve URL/token from flags or env vars (flag > env), validate both present, initialize logger, create client instance
- [x] 6.3 Wire output format into a shared variable accessible by all subcommands
## 7. Client — Project Operations
- [x] 7.1 Implement `client/projects.go``ListProjects(ctx) ([]model.Project, error)`
- [x] 7.2 Implement `GetProject(ctx, id string) (*model.Project, error)`
## 8. Client — Board Operations
- [x] 8.1 Implement `client/boards.go``GetBoard(ctx, id string) (*model.Board, error)`
- [x] 8.2 Implement `ListBoardActions(ctx, boardId string, limit int) ([]model.Action, error)` with cursor-based pagination using `beforeId`
## 9. Client — Card Operations
- [x] 9.1 Implement `client/cards.go``GetCard(ctx, id string) (*model.Card, error)`
- [x] 9.2 Implement `CreateCard(ctx, listId string, fields) (*model.Card, error)`
- [x] 9.3 Implement `UpdateCard(ctx, id string, fields) (*model.Card, error)`
- [x] 9.4 Implement `DeleteCard(ctx, id string) error`
- [x] 9.5 Implement `DuplicateCard(ctx, id string, name *string, position *float64) (*model.Card, error)`
- [x] 9.6 Implement `ListCards(ctx, listId string, limit int) ([]model.Card, error)` with cursor-based pagination using `before`
- [x] 9.7 Implement `ListCardActions(ctx, cardId string, limit int) ([]model.Action, error)` with cursor-based pagination using `beforeId`
- [x] 9.8 Implement `ListCardsByBoard(ctx, boardId string, limit int) ([]model.CardWithList, error)` — multi-call enrichment: get board → get cards per list → inject listName
- [x] 9.9 Implement `AddCardLabel(ctx, cardId, labelId string) error`
- [x] 9.10 Implement `RemoveCardLabel(ctx, cardId, labelId string) error`
- [x] 9.11 Implement `AddCardMember(ctx, cardId, userId string) error`
- [x] 9.12 Implement `RemoveCardMember(ctx, cardId, userId string) error`
## 10. Client — Comment Operations
- [x] 10.1 Implement `client/comments.go``ListComments(ctx, cardId string, limit int) ([]model.Comment, error)` with cursor-based pagination using `beforeId`
- [x] 10.2 Implement `CreateComment(ctx, cardId, text string) (*model.Comment, error)`
- [x] 10.3 Implement `UpdateComment(ctx, id, text string) (*model.Comment, error)`
- [x] 10.4 Implement `DeleteComment(ctx, id string) error`
## 11. Client — Task List Operations
- [x] 11.1 Implement `client/task_lists.go``CreateTaskList(ctx, cardId string, fields) (*model.TaskList, error)`
- [x] 11.2 Implement `GetTaskList(ctx, id string) (*model.TaskList, error)`
- [x] 11.3 Implement `UpdateTaskList(ctx, id string, fields) (*model.TaskList, error)`
- [x] 11.4 Implement `DeleteTaskList(ctx, id string) error`
## 12. Client — Task Operations
- [x] 12.1 Implement `client/tasks.go``CreateTask(ctx, taskListId string, fields) (*model.Task, error)`
- [x] 12.2 Implement `UpdateTask(ctx, id string, fields) (*model.Task, error)`
- [x] 12.3 Implement `DeleteTask(ctx, id string) error`
## 13. Client — Label Operations
- [x] 13.1 Implement `client/labels.go``CreateLabel(ctx, boardId string, fields) (*model.Label, error)`
- [x] 13.2 Implement `UpdateLabel(ctx, id string, fields) (*model.Label, error)`
- [x] 13.3 Implement `DeleteLabel(ctx, id string) error`
## 14. CLI — Project Commands
- [x] 14.1 Implement `cmd/project.go``project` parent command, `project list` subcommand, `project get <id>` subcommand
## 15. CLI — Board Commands
- [x] 15.1 Implement `cmd/board.go``board` parent command, `board get <id>` subcommand, `board actions <id>` subcommand with `--limit` flag
## 16. CLI — Card Commands
- [x] 16.1 Implement `cmd/card.go``card` parent command
- [x] 16.2 Implement `card list` subcommand with mutually required `--board` / `--list` flags and `--limit`
- [x] 16.3 Implement `card get <id>` subcommand
- [x] 16.4 Implement `card create` subcommand with `--list`, `--name` (required), `--description`, `--type`, `--position`, `--due-date`, `--due-completed` flags
- [x] 16.5 Implement `card update <id>` subcommand with optional update flags
- [x] 16.6 Implement `card delete <id>` subcommand
- [x] 16.7 Implement `card duplicate <id>` subcommand with optional `--name`, `--position`
- [x] 16.8 Implement `card move <id>` subcommand with required `--list` and optional `--position`
- [x] 16.9 Implement `card assign <id>` and `card unassign <id>` subcommands with required `--user`
- [x] 16.10 Implement `card add-label <id>` and `card remove-label <id>` subcommands with required `--label`
- [x] 16.11 Implement `card actions <id>` subcommand with `--limit`
## 17. CLI — Comment Commands
- [x] 17.1 Implement `cmd/comment.go``comment` parent command, `comment list` with `--card` and `--limit`, `comment create` with `--card` and `--text`, `comment update <id>` with `--text`, `comment delete <id>`
## 18. CLI — Task List Commands
- [x] 18.1 Implement `cmd/task_list.go``task-list` parent command, `task-list create` with `--card`, `--name`, optional flags, `task-list get <id>`, `task-list update <id>`, `task-list delete <id>`
## 19. CLI — Task Commands
- [x] 19.1 Implement `cmd/task.go``task` parent command, `task create` with `--task-list`, `--name`, optional flags, `task update <id>` with optional flags, `task delete <id>`
## 20. CLI — Label Commands
- [x] 20.1 Implement `cmd/label.go``label` parent command, `label create` with `--board`, `--name`, optional `--color`, `--position`, `label update <id>`, `label delete <id>`
## 21. Build and Verify
- [x] 21.1 Verify `go build` produces a clean binary
- [x] 21.2 Verify `pcli --help` displays all commands and global flags
- [x] 21.3 Verify error output when `PLANKA_URL` / `PLANKA_TOKEN` are missing
- [x] 21.4 Add README.md with installation, configuration, and usage examples
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-11
@@ -0,0 +1,45 @@
## Context
pcli is a Cobra-based CLI for the Planka project management API. Commands follow a `pcli <resource> <action>` pattern (e.g., `pcli board get`, `pcli card list`). The client layer already provides `ListBoards()` (via `/api/projects`) and `GetBoard(id)` (via `/api/boards/:id`), where `GetBoard` returns the board's lists and cards in the `included` block. Output is handled by `output.Print()` which dispatches to JSON envelope or table rendering based on the `--format` flag.
## Goals / Non-Goals
**Goals:**
- Provide a single top-level `pcli status` command that summarizes all boards, their lists, and card counts
- Reuse existing client methods with no new API endpoints
- Support both JSON and table output formats via the existing `--format` flag
- Show open card counts as the primary number, with closed cards noted separately
**Non-Goals:**
- Concurrent/parallel board fetching (sequential is sufficient for expected board counts)
- Filtering by project, board, or list (this is a full overview command)
- Showing card-level detail (names, assignees, due dates) — that's what `card list` is for
- Caching or incremental updates
## Decisions
### 1. Top-level command, not a subcommand
The `status` command will be registered directly on `rootCmd`, not under `board` or any other resource group. It's a cross-cutting summary, not a resource operation.
**Alternative considered:** `pcli board status` — rejected because the command spans all boards and is conceptually similar to `git status` as a quick overview.
### 2. Data aggregation in the command layer
The status command will call `ListBoards()` then `GetBoard(id)` for each board, aggregating card counts per list in the command handler. No new client method is needed.
**Alternative considered:** A dedicated `client.GetStatus()` method — rejected because there's no single Planka API endpoint for this; the aggregation is purely a CLI concern.
### 3. New model types for status data
A `StatusSummary` struct will hold the aggregated data, containing a slice of `BoardSummary` structs (board name + list summaries), each containing a slice of `ListSummary` structs (list name, open count, closed count). This gives both JSON and table formatters a clean data structure to work with.
**Alternative considered:** Reusing existing `Board`/`List` types with card slices — rejected because the status output is a derived summary, not raw API data.
### 4. Table output uses per-board sections
In table format, each board is rendered as a headed section with a list/cards table beneath it. A total board count line appears first. This matches the natural reading pattern for a summary view.
### 5. JSON output uses the standard envelope
JSON output wraps the `StatusSummary` in the existing `{"data": ..., "error": null}` envelope, consistent with all other commands.
## Risks / Trade-offs
- **N+1 API calls** — One `ListBoards` call plus one `GetBoard` per board. For typical usage (< 20 boards) this is fine. If it becomes a problem, concurrent fetching can be added later without changing the interface. → Mitigation: defer optimization until needed.
- **Stale data between calls** — Board state could change between sequential `GetBoard` calls. → Mitigation: acceptable for a summary view; not a transactional operation.
@@ -0,0 +1,30 @@
## Why
There is no quick way to get an overview of the state of all boards in Planka from the CLI. Users currently need to run multiple commands (`board list`, then `board get` per board, then mentally tally cards) to understand what's where. A single `pcli status` command provides an at-a-glance summary — similar in spirit to `git status`.
## What Changes
- Add a new top-level `pcli status` command that:
- Lists the total number of boards
- For each board, displays a table of its lists with card counts
- Card counts show open cards as the primary number, with closed cards in parentheses (e.g., `12 (2 closed)`)
- Empty lists are shown with `0` cards
- Respects the global `--format` flag: JSON by default, table when `--format table` is specified
- Uses existing `ListBoards` and `GetBoard` client methods — no new API endpoints needed
- Sequential board fetching (one `GetBoard` call per board)
## Capabilities
### New Capabilities
- `status-command`: The top-level `pcli status` command, its data aggregation logic, and its output formatting (both JSON and table)
### Modified Capabilities
- `cli-commands`: Adding the new `status` top-level command to the CLI command tree
## Impact
- **New file**: `cmd/status.go` — command definition and aggregation logic
- **Modified file**: `output/output.go` — table rendering for the status summary type
- **Modified file**: `model/types.go` — new types for the status summary data structure
- No new dependencies required
- No breaking changes
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Status command
The system SHALL provide a top-level `status` command registered directly on the root command (not as a subcommand of any resource group). `pcli status` SHALL take no positional arguments. The command SHALL fetch all boards via `ListBoards`, then fetch each board's details via `GetBoard` sequentially, aggregate card counts per list, and output the result via the standard `output.Print` mechanism respecting the global `--format` flag.
#### Scenario: Run status command
- **WHEN** `pcli status` is executed
- **THEN** the system SHALL output a summary of all boards with their lists and card counts
#### Scenario: Status command respects format flag
- **WHEN** `pcli status --format table` is executed
- **THEN** the output SHALL be in table format
#### Scenario: Status command default format
- **WHEN** `pcli status` is executed without a `--format` flag
- **THEN** the output SHALL be in JSON envelope format
@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: Status command summary output
The system SHALL provide a top-level `pcli status` command that outputs a summary of all boards, their lists, and card counts. The summary SHALL include the total number of boards. For each board, the summary SHALL include the board name and a breakdown of each list within that board showing the list name, the number of open cards (where `isClosed` is false), and the number of closed cards (where `isClosed` is true). Empty lists SHALL be included in the output with 0 open and 0 closed cards.
#### Scenario: Status with multiple boards and lists
- **WHEN** `pcli status` is executed and there are boards with lists containing cards
- **THEN** the output SHALL include the total board count
- **AND** each board SHALL list all its lists with open and closed card counts
#### Scenario: Status with empty lists
- **WHEN** a board contains a list with no cards
- **THEN** that list SHALL appear in the output with 0 open cards and 0 closed cards
#### Scenario: Status with no boards
- **WHEN** `pcli status` is executed and there are no boards
- **THEN** the output SHALL indicate 0 boards
#### Scenario: Status with closed cards
- **WHEN** a list contains both open and closed cards
- **THEN** the open card count SHALL exclude closed cards
- **AND** the closed card count SHALL be shown separately
### Requirement: Status command JSON output
The system SHALL output the status summary in the standard JSON envelope format (`{"data": ..., "error": null}`) when `--format json` is used or no format flag is provided. The `data` field SHALL contain an object with `totalBoards` (integer) and `boards` (array). Each board object SHALL contain `id` (string), `name` (string), and `lists` (array). Each list object SHALL contain `id` (string), `name` (string), `openCards` (integer), and `closedCards` (integer).
#### Scenario: JSON output structure
- **WHEN** `pcli status` is executed with `--format json` or no format flag
- **THEN** the output SHALL be a JSON envelope with the status summary as the `data` field
#### Scenario: JSON output field types
- **WHEN** the JSON output is parsed
- **THEN** `totalBoards` SHALL be an integer
- **AND** each list's `openCards` and `closedCards` SHALL be integers
### Requirement: Status command table output
The system SHALL output the status summary in a human-readable table format when `--format table` is specified. The table output SHALL begin with a line showing the total number of boards (e.g., `3 boards`). For each board, the output SHALL display a board header line (e.g., `Board: Sprint Planning`) followed by a table with columns `LIST` and `CARDS`. The `CARDS` column SHALL display the open card count, and if there are closed cards, append ` (<n> closed)` (e.g., `12 (2 closed)`). If there are no closed cards, only the open count SHALL be displayed (e.g., `12`).
#### Scenario: Table output with closed cards
- **WHEN** `pcli status --format table` is executed and a list has 12 open and 2 closed cards
- **THEN** the CARDS column for that list SHALL display `12 (2 closed)`
#### Scenario: Table output with no closed cards
- **WHEN** `pcli status --format table` is executed and a list has 5 open and 0 closed cards
- **THEN** the CARDS column for that list SHALL display `5`
#### Scenario: Table output with empty list
- **WHEN** `pcli status --format table` is executed and a list has 0 open and 0 closed cards
- **THEN** the CARDS column for that list SHALL display `0`
#### Scenario: Table output board count line
- **WHEN** `pcli status --format table` is executed
- **THEN** the first line of output SHALL show the total board count (e.g., `3 boards`)
@@ -0,0 +1,17 @@
## 1. Model Types
- [x] 1.1 Add `StatusSummary`, `BoardSummary`, and `ListSummary` structs to `model/types.go`
## 2. Command Implementation
- [x] 2.1 Create `cmd/status.go` with the top-level `pcli status` command registered on `rootCmd`
- [x] 2.2 Implement the command handler: call `ListBoards`, then `GetBoard` per board, aggregate open/closed card counts per list, build `StatusSummary`, and pass to `output.Print`
## 3. Output Formatting
- [x] 3.1 Add `printStatusTable` function to `output/output.go` rendering the board count header line, per-board sections with LIST/CARDS columns, and closed card counts in parentheses
- [x] 3.2 Register `StatusSummary` in the `printTable` dispatch logic (both direct and pointer cases)
## 4. Verification
- [x] 4.1 Build and manually test `pcli status` with `--format json` (default) and `--format table`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-11
@@ -0,0 +1,43 @@
## Context
pcli currently authenticates to the Planka API using two mechanisms:
1. **Bearer token**`Authorization: Bearer <jwt>` header on every request
2. **OIDC mode** — Bearer token + `httpOnlyToken` cookie sent together
Both rely on session-based JWTs obtained externally. The `Client` struct carries both `Token` and `HttpOnlyToken` fields, and the `Do()` method conditionally attaches the cookie. The CLI exposes `PLANKA_TOKEN`, `--token`, `PLANKA_HTTP_TOKEN`, and `--http-token`.
Planka now supports user-level API keys authenticated via the `x-api-key` header. This is simpler, long-lived, and eliminates the dual-mode complexity.
## Goals / Non-Goals
**Goals:**
- Replace all authentication with a single `x-api-key` header
- Simplify the `Client` struct and constructor
- Rename env var to `PLANKA_API_KEY` and flag to `--api-key`
- Remove all OIDC/httpOnlyToken support
**Non-Goals:**
- Supporting both old and new auth simultaneously (no transition period)
- API key management (creation/rotation/deletion) via pcli
- Any changes to API endpoint paths or request/response formats
## Decisions
### Decision 1: Use `x-api-key` header directly
**Choice**: Set `x-api-key: <key>` header instead of `Authorization: Bearer <key>`.
**Rationale**: This is the header format documented by Planka for API key auth. Using the standard Planka format ensures compatibility.
**Alternatives considered**: Using `Authorization: Bearer <api-key>` — rejected because Planka's API key auth specifically uses the `x-api-key` header.
### Decision 2: Clean break, no backward compatibility
**Choice**: Remove `PLANKA_TOKEN` and `--token` entirely, replace with `PLANKA_API_KEY` and `--api-key`.
**Rationale**: Supporting both old and new env vars adds complexity for no real benefit. This is a CLI tool where users control their own environment. A clean break with clear error messages is simpler.
**Alternatives considered**: Supporting both `PLANKA_TOKEN` and `PLANKA_API_KEY` with deprecation warnings — rejected as unnecessary complexity for a CLI tool.
### Decision 3: Single field on Client struct
**Choice**: Replace `Token` + `HttpOnlyToken` fields with a single `APIKey` field.
**Rationale**: API key auth has no secondary credential. One field, one header, one code path.
## Risks / Trade-offs
- **Breaking change for all users** → Mitigated by clear naming (`PLANKA_API_KEY`) and error message that tells users what's needed. Users must generate an API key in Planka before upgrading.
- **API key shown only once at creation** → Not a pcli concern, but worth noting in README that users should store their key securely.
@@ -0,0 +1,31 @@
## Why
Planka now supports user-level API key authentication. The current pcli authentication uses session-based JWT tokens (via `Authorization: Bearer <token>`) with an optional OIDC httpOnlyToken cookie path. API keys are simpler, long-lived, and eliminate the need for multiple auth modes. Replacing the current auth with API key auth simplifies both the codebase and the user experience.
## What Changes
- **BREAKING**: Replace `Authorization: Bearer <token>` header with `x-api-key: <key>` header on all API requests
- **BREAKING**: Rename environment variable `PLANKA_TOKEN``PLANKA_API_KEY`
- **BREAKING**: Rename CLI flag `--token``--api-key`
- Remove `PLANKA_HTTP_TOKEN` environment variable support
- Remove `--http-token` CLI flag
- Remove `HttpOnlyToken` field from the `Client` struct and all OIDC cookie logic
- Simplify `NewClient` constructor to accept only base URL, API key, and logger
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `api-client`: Authentication header changes from `Authorization: Bearer` to `x-api-key`. Client struct drops `HttpOnlyToken` field. `NewClient` signature simplifies. OIDC cookie logic removed.
- `cli-commands`: Root command global flags change: `--token``--api-key`, `--http-token` removed. Environment variable changes: `PLANKA_TOKEN``PLANKA_API_KEY`, `PLANKA_HTTP_TOKEN` removed.
## Impact
- **Code**: `client/client.go` (struct, constructor, `Do()` method), `cmd/root.go` (flags, env vars, client init)
- **Users**: All existing users must update their environment variables and any scripts from `PLANKA_TOKEN` to `PLANKA_API_KEY` and generate an API key in Planka
- **Dependencies**: No dependency changes
- **API**: No Planka API endpoint changes — only the authentication mechanism used by pcli changes
@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: Base HTTP client
The system SHALL provide a base HTTP client that sends requests to the Planka API. The client SHALL construct URLs by joining the configured base URL with the API path. The client SHALL attach an `x-api-key: <key>` header to every request. The client SHALL send and receive JSON (`Content-Type: application/json`). The client SHALL accept a `*slog.Logger` and log every request at DEBUG level with method, path, status code, and duration. The client SHALL log errors at WARN level.
#### Scenario: Successful API request
- **WHEN** the client sends a request to a valid endpoint
- **THEN** the response body SHALL be returned as parsed JSON
- **AND** the request SHALL include the `x-api-key` header
- **AND** a DEBUG log entry SHALL be emitted with method, path, status, and duration
#### Scenario: API returns error status
- **WHEN** the API responds with a 4xx or 5xx status code
- **THEN** the client SHALL return an `APIError` containing the HTTP status code and response message
- **AND** a WARN log entry SHALL be emitted
#### Scenario: Network failure
- **WHEN** the HTTP request fails due to a network error (connection refused, timeout, DNS failure)
- **THEN** the client SHALL return a Go error wrapping the underlying network error
### Requirement: Authentication from environment
The system SHALL read `PLANKA_URL` from the environment to determine the API base URL. The system SHALL read `PLANKA_API_KEY` from the environment to determine the API key. Global flags `--url` and `--api-key` SHALL override the corresponding environment variables. Flag values SHALL take precedence over environment variables.
#### Scenario: Auth from environment variables
- **WHEN** `PLANKA_URL` and `PLANKA_API_KEY` are set in the environment
- **AND** no `--url` or `--api-key` flags are provided
- **THEN** the client SHALL use the environment variable values
#### Scenario: Flag overrides environment
- **WHEN** `--url` or `--api-key` flags are provided
- **THEN** the flag values SHALL take precedence over environment variables
#### Scenario: Missing configuration
- **WHEN** neither the environment variable nor the flag is set for URL or API key
- **THEN** the system SHALL print an error message and exit with code 1
@@ -0,0 +1,22 @@
## MODIFIED Requirements
### Requirement: Root command and global flags
The system SHALL provide a root command `pcli` that serves as the entry point. The root command SHALL register global flags: `--format` (string, default `json`, values `json` or `table`), `--url` (string, overrides `PLANKA_URL`), `--api-key` (string, overrides `PLANKA_API_KEY`), and `--log-level` (string, default `warn`, values `debug`, `info`, `warn`, `error`). The root command SHALL initialize the logger based on `--log-level` and configure it to write structured JSON to stderr. The root command SHALL validate that URL and API key are available (from flags or environment) before executing any subcommand.
#### Scenario: Display help
- **WHEN** `pcli` is run with no arguments or `--help`
- **THEN** the system SHALL display usage information listing all resource subcommands and global flags
#### Scenario: Invalid format flag
- **WHEN** `--format` is set to an unsupported value
- **THEN** the system SHALL print an error and exit with code 1
#### Scenario: Log level controls output
- **WHEN** `--log-level=debug` is set
- **THEN** DEBUG-level log entries SHALL appear on stderr
- **AND** stdout SHALL contain only the command's data output
#### Scenario: Missing API key
- **WHEN** neither `PLANKA_API_KEY` environment variable nor `--api-key` flag is provided
- **THEN** the system SHALL print an error indicating `PLANKA_API_KEY` must be set via `--api-key` flag or `PLANKA_API_KEY` environment variable
- **AND** the system SHALL exit with code 1
@@ -0,0 +1,20 @@
## 1. Client Authentication
- [x] 1.1 Rename `Token` and `HttpOnlyToken` fields to single `APIKey` field in `Client` struct in `client/client.go`
- [x] 1.2 Update `NewClient` constructor to accept `(baseURL, apiKey string, logger *slog.Logger)` — remove `httpOnlyToken` parameter
- [x] 1.3 Replace `Authorization: Bearer` header with `x-api-key` header in `Do()` method
- [x] 1.4 Remove OIDC cookie logic (`httpOnlyToken` cookie attachment) from `Do()` method
## 2. CLI Flags and Environment Variables
- [x] 2.1 Rename `flagToken` to `flagAPIKey` and `flagHttpToken` removal in `cmd/root.go`
- [x] 2.2 Replace `--token` flag with `--api-key` flag and remove `--http-token` flag in `cmd/root.go`
- [x] 2.3 Replace `PLANKA_TOKEN` env var lookup with `PLANKA_API_KEY` in `PersistentPreRunE`
- [x] 2.4 Remove `PLANKA_HTTP_TOKEN` env var lookup from `PersistentPreRunE`
- [x] 2.5 Update error message to reference `PLANKA_API_KEY` and `--api-key`
- [x] 2.6 Update `NewClient` call site in `PersistentPreRunE` to match new constructor signature
- [x] 2.7 Remove OIDC-related debug log line (`oidc_mode` slog field)
## 3. Documentation
- [x] 3.1 Update `README.md` authentication section to reference `PLANKA_API_KEY` and `--api-key`
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+195
View File
@@ -0,0 +1,195 @@
## ADDED Requirements
### Requirement: Base HTTP client
The system SHALL provide a base HTTP client that sends requests to the Planka API. The client SHALL construct URLs by joining the configured base URL with the API path. The client SHALL attach an `x-api-key: <key>` header to every request. The client SHALL send and receive JSON (`Content-Type: application/json`). The client SHALL accept a `*slog.Logger` and log every request at DEBUG level with method, path, status code, and duration. The client SHALL log errors at WARN level.
#### Scenario: Successful API request
- **WHEN** the client sends a request to a valid endpoint
- **THEN** the response body SHALL be returned as parsed JSON
- **AND** the request SHALL include the `x-api-key` header
- **AND** a DEBUG log entry SHALL be emitted with method, path, status, and duration
#### Scenario: API returns error status
- **WHEN** the API responds with a 4xx or 5xx status code
- **THEN** the client SHALL return an `APIError` containing the HTTP status code and response message
- **AND** a WARN log entry SHALL be emitted
#### Scenario: Network failure
- **WHEN** the HTTP request fails due to a network error (connection refused, timeout, DNS failure)
- **THEN** the client SHALL return a Go error wrapping the underlying network error
### Requirement: Authentication from environment
The system SHALL read `PLANKA_URL` from the environment to determine the API base URL. The system SHALL read `PLANKA_API_KEY` from the environment to determine the API key. Global flags `--url` and `--api-key` SHALL override the corresponding environment variables. Flag values SHALL take precedence over environment variables.
#### Scenario: Auth from environment variables
- **WHEN** `PLANKA_URL` and `PLANKA_API_KEY` are set in the environment
- **AND** no `--url` or `--api-key` flags are provided
- **THEN** the client SHALL use the environment variable values
#### Scenario: Flag overrides environment
- **WHEN** `--url` or `--api-key` flags are provided
- **THEN** the flag values SHALL take precedence over environment variables
#### Scenario: Missing configuration
- **WHEN** neither the environment variable nor the flag is set for URL or API key
- **THEN** the system SHALL print an error message and exit with code 1
### Requirement: Cursor-based pagination
The system SHALL implement cursor-based pagination for all list endpoints that support it. Paginated endpoints are: `GET /boards/{boardId}/actions` (`beforeId`), `GET /cards/{cardId}/actions` (`beforeId`), `GET /lists/{listId}/cards` (`before`), `GET /cards/{cardId}/comments` (`beforeId`). List methods SHALL accept a `limit` parameter. When `limit` is 0, the client SHALL fetch all pages. When `limit` is greater than 0, the client SHALL stop fetching after accumulating at least `limit` items and truncate the result to exactly `limit` items. Each page fetch SHALL be logged at DEBUG level.
#### Scenario: Fetch all pages
- **WHEN** a list method is called with limit 0
- **THEN** the client SHALL fetch pages until an empty page is returned
- **AND** all items from all pages SHALL be returned
#### Scenario: Fetch with limit
- **WHEN** a list method is called with limit N (N > 0)
- **THEN** the client SHALL stop fetching after accumulating N or more items
- **AND** the returned slice SHALL contain exactly N items
#### Scenario: Single page result
- **WHEN** the API returns fewer items than a page size and no more pages exist
- **THEN** the client SHALL return those items without making additional requests
### Requirement: Project operations
The client SHALL provide methods to list all accessible projects (`GET /projects`) and get a single project by ID (`GET /projects/{id}`).
#### Scenario: List projects
- **WHEN** `ListProjects` is called
- **THEN** the client SHALL send `GET /projects` and return a slice of Project models
#### Scenario: Get project
- **WHEN** `GetProject` is called with a project ID
- **THEN** the client SHALL send `GET /projects/{id}` and return a Project model
### Requirement: Board operations
The client SHALL provide a method to get a single board by ID (`GET /boards/{id}`) and list board actions (`GET /boards/{boardId}/actions`) with pagination support.
#### Scenario: Get board
- **WHEN** `GetBoard` is called with a board ID
- **THEN** the client SHALL send `GET /boards/{id}` and return a Board model including its included lists
#### Scenario: List board actions
- **WHEN** `ListBoardActions` is called with a board ID and limit
- **THEN** the client SHALL send paginated `GET /boards/{boardId}/actions` requests and return a slice of Action models
### Requirement: Card CRUD operations
The client SHALL provide methods for: get card (`GET /cards/{id}`), create card (`POST /lists/{listId}/cards`), update card (`PATCH /cards/{id}`), delete card (`DELETE /cards/{id}`), and duplicate card (`POST /cards/{id}/duplicate`). The client SHALL provide a method to list cards in a list (`GET /lists/{listId}/cards`) with pagination support. The client SHALL provide a method to list card actions (`GET /cards/{cardId}/actions`) with pagination support.
#### Scenario: Get card
- **WHEN** `GetCard` is called with a card ID
- **THEN** the client SHALL send `GET /cards/{id}` and return a Card model
#### Scenario: Create card
- **WHEN** `CreateCard` is called with a list ID and card fields (name, description, type, position, dueDate, isDueCompleted)
- **THEN** the client SHALL send `POST /lists/{listId}/cards` with the provided fields and return the created Card
#### Scenario: Update card
- **WHEN** `UpdateCard` is called with a card ID and update fields
- **THEN** the client SHALL send `PATCH /cards/{id}` with only the provided fields and return the updated Card
#### Scenario: Delete card
- **WHEN** `DeleteCard` is called with a card ID
- **THEN** the client SHALL send `DELETE /cards/{id}`
#### Scenario: Duplicate card
- **WHEN** `DuplicateCard` is called with a card ID, name, and position
- **THEN** the client SHALL send `POST /cards/{id}/duplicate` and return the new Card
#### Scenario: List cards in list
- **WHEN** `ListCards` is called with a list ID and limit
- **THEN** the client SHALL send paginated `GET /lists/{listId}/cards` requests and return a slice of Card models
#### Scenario: List card actions
- **WHEN** `ListCardActions` is called with a card ID and limit
- **THEN** the client SHALL send paginated `GET /cards/{cardId}/actions` requests and return a slice of Action models
### Requirement: Comment operations
The client SHALL provide methods for: list comments (`GET /cards/{cardId}/comments`) with pagination, create comment (`POST /cards/{cardId}/comments`), update comment (`PATCH /comments/{id}`), and delete comment (`DELETE /comments/{id}`).
#### Scenario: List comments
- **WHEN** `ListComments` is called with a card ID and limit
- **THEN** the client SHALL send paginated `GET /cards/{cardId}/comments` requests and return a slice of Comment models
#### Scenario: Create comment
- **WHEN** `CreateComment` is called with a card ID and text
- **THEN** the client SHALL send `POST /cards/{cardId}/comments` with the text and return the created Comment
#### Scenario: Update comment
- **WHEN** `UpdateComment` is called with a comment ID and text
- **THEN** the client SHALL send `PATCH /comments/{id}` and return the updated Comment
#### Scenario: Delete comment
- **WHEN** `DeleteComment` is called with a comment ID
- **THEN** the client SHALL send `DELETE /comments/{id}`
### Requirement: Task list operations
The client SHALL provide methods for: create task list (`POST /cards/{cardId}/task-lists`), get task list (`GET /task-lists/{id}`), update task list (`PATCH /task-lists/{id}`), and delete task list (`DELETE /task-lists/{id}`).
#### Scenario: Create task list
- **WHEN** `CreateTaskList` is called with a card ID and fields (name, position, showOnFrontOfCard, hideCompletedTasks)
- **THEN** the client SHALL send `POST /cards/{cardId}/task-lists` and return the created TaskList
#### Scenario: Get task list
- **WHEN** `GetTaskList` is called with a task list ID
- **THEN** the client SHALL send `GET /task-lists/{id}` and return a TaskList model
#### Scenario: Update task list
- **WHEN** `UpdateTaskList` is called with a task list ID and update fields
- **THEN** the client SHALL send `PATCH /task-lists/{id}` and return the updated TaskList
#### Scenario: Delete task list
- **WHEN** `DeleteTaskList` is called with a task list ID
- **THEN** the client SHALL send `DELETE /task-lists/{id}`
### Requirement: Task operations
The client SHALL provide methods for: create task (`POST /task-lists/{taskListId}/tasks`), update task (`PATCH /tasks/{id}`), and delete task (`DELETE /tasks/{id}`).
#### Scenario: Create task
- **WHEN** `CreateTask` is called with a task list ID and fields (name, position, isCompleted, assigneeUserId, linkedCardId)
- **THEN** the client SHALL send `POST /task-lists/{taskListId}/tasks` and return the created Task
#### Scenario: Update task
- **WHEN** `UpdateTask` is called with a task ID and update fields (name, position, isCompleted, assigneeUserId, taskListId)
- **THEN** the client SHALL send `PATCH /tasks/{id}` and return the updated Task
#### Scenario: Delete task
- **WHEN** `DeleteTask` is called with a task ID
- **THEN** the client SHALL send `DELETE /tasks/{id}`
### Requirement: Label operations
The client SHALL provide methods for: create label (`POST /boards/{boardId}/labels`), update label (`PATCH /labels/{id}`), and delete label (`DELETE /labels/{id}`).
#### Scenario: Create label
- **WHEN** `CreateLabel` is called with a board ID and fields (name, color, position)
- **THEN** the client SHALL send `POST /boards/{boardId}/labels` and return the created Label
#### Scenario: Update label
- **WHEN** `UpdateLabel` is called with a label ID and update fields (name, color, position)
- **THEN** the client SHALL send `PATCH /labels/{id}` and return the updated Label
#### Scenario: Delete label
- **WHEN** `DeleteLabel` is called with a label ID
- **THEN** the client SHALL send `DELETE /labels/{id}`
### Requirement: Card label operations
The client SHALL provide methods for: add label to card (`POST /cards/{cardId}/card-labels`) and remove label from card (`DELETE /cards/{cardId}/card-labels/labelId:{labelId}`).
#### Scenario: Add label to card
- **WHEN** `AddCardLabel` is called with a card ID and label ID
- **THEN** the client SHALL send `POST /cards/{cardId}/card-labels` with the label ID
#### Scenario: Remove label from card
- **WHEN** `RemoveCardLabel` is called with a card ID and label ID
- **THEN** the client SHALL send `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`
### Requirement: Card membership operations
The client SHALL provide methods for: assign user to card (`POST /cards/{cardId}/card-memberships`) and unassign user from card (`DELETE /cards/{cardId}/card-memberships/userId:{userId}`).
#### Scenario: Assign user to card
- **WHEN** `AddCardMember` is called with a card ID and user ID
- **THEN** the client SHALL send `POST /cards/{cardId}/card-memberships` with the user ID
#### Scenario: Unassign user from card
- **WHEN** `RemoveCardMember` is called with a card ID and user ID
- **THEN** the client SHALL send `DELETE /cards/{cardId}/card-memberships/userId:{userId}`
+88
View File
@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: Move card between lists
The system SHALL provide a `card move` operation that updates a card's `listId` to move it to a different list. The operation SHALL accept an optional `position` to place the card at a specific position within the target list. The move operation SHALL be implemented as a `PATCH /cards/{id}` call with `listId` and optionally `position` fields. The CLI command SHALL be `pcli card move <id> --list <listId> [--position N]`.
#### Scenario: Move card to another list
- **WHEN** `pcli card move <id> --list <targetListId>` is executed
- **THEN** the system SHALL send `PATCH /cards/{id}` with `{"listId": "<targetListId>"}`
- **AND** the system SHALL output the updated card with its new listId
#### Scenario: Move card to specific position
- **WHEN** `pcli card move <id> --list <targetListId> --position 0` is executed
- **THEN** the system SHALL send `PATCH /cards/{id}` with `{"listId": "<targetListId>", "position": 0}`
- **AND** the card SHALL appear at the specified position in the target list
#### Scenario: Move card missing list flag
- **WHEN** `pcli card move <id>` is executed without `--list`
- **THEN** the system SHALL print an error indicating `--list` is required and exit with code 1
### Requirement: Duplicate card
The system SHALL provide a `card duplicate` operation that creates a copy of an existing card. The operation SHALL call `POST /cards/{id}/duplicate`. The CLI command SHALL be `pcli card duplicate <id> [--name <name>] [--position N]`. If `--name` is not provided, the API determines the name of the duplicate.
#### Scenario: Duplicate card with defaults
- **WHEN** `pcli card duplicate <id>` is executed
- **THEN** the system SHALL send `POST /cards/{id}/duplicate`
- **AND** the system SHALL output the newly created duplicate card
#### Scenario: Duplicate card with custom name
- **WHEN** `pcli card duplicate <id> --name "Copy of task"` is executed
- **THEN** the system SHALL send `POST /cards/{id}/duplicate` with `{"name": "Copy of task"}`
- **AND** the duplicate card SHALL have the specified name
### Requirement: Assign and unassign card members
The system SHALL provide `card assign` and `card unassign` operations to manage card memberships. `card assign` SHALL call `POST /cards/{cardId}/card-memberships` with the user ID. `card unassign` SHALL call `DELETE /cards/{cardId}/card-memberships/userId:{userId}`.
#### Scenario: Assign user to card
- **WHEN** `pcli card assign <cardId> --user <userId>` is executed
- **THEN** the system SHALL send `POST /cards/{cardId}/card-memberships` with `{"userId": "<userId>"}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Unassign user from card
- **WHEN** `pcli card unassign <cardId> --user <userId>` is executed
- **THEN** the system SHALL send `DELETE /cards/{cardId}/card-memberships/userId:{userId}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Assign missing user flag
- **WHEN** `pcli card assign <cardId>` is executed without `--user`
- **THEN** the system SHALL print an error indicating `--user` is required and exit with code 1
### Requirement: Add and remove card labels
The system SHALL provide `card add-label` and `card remove-label` operations to manage labels on cards. `card add-label` SHALL call `POST /cards/{cardId}/card-labels` with the label ID. `card remove-label` SHALL call `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`.
#### Scenario: Add label to card
- **WHEN** `pcli card add-label <cardId> --label <labelId>` is executed
- **THEN** the system SHALL send `POST /cards/{cardId}/card-labels` with `{"labelId": "<labelId>"}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Remove label from card
- **WHEN** `pcli card remove-label <cardId> --label <labelId>` is executed
- **THEN** the system SHALL send `DELETE /cards/{cardId}/card-labels/labelId:{labelId}`
- **AND** the system SHALL output a success confirmation
#### Scenario: Add label missing label flag
- **WHEN** `pcli card add-label <cardId>` is executed without `--label`
- **THEN** the system SHALL print an error indicating `--label` is required and exit with code 1
### Requirement: Enriched board-level card listing
The system SHALL provide a `card list --board <id>` operation that returns all cards across all lists in a board, with each card enriched with the `listName` field. The operation SHALL: (1) call `GET /boards/{id}` to retrieve the board and its included lists, (2) call `GET /lists/{listId}/cards` for each list to retrieve cards (with pagination support), (3) inject `listName` into each card based on the list it belongs to. The `--limit` flag SHALL apply to the total number of cards returned across all lists.
#### Scenario: List all cards on a board
- **WHEN** `pcli card list --board <id>` is executed
- **THEN** the system SHALL return all cards from all lists in the board
- **AND** each card SHALL include a `listName` field with the name of its containing list
- **AND** each card SHALL include a `listId` field
#### Scenario: Board card listing with limit
- **WHEN** `pcli card list --board <id> --limit 10` is executed
- **THEN** the system SHALL return at most 10 cards total across all lists
- **AND** each card SHALL include the `listName` field
#### Scenario: Board with no cards
- **WHEN** `pcli card list --board <id>` is executed on a board with empty lists
- **THEN** the system SHALL return an empty array
#### Scenario: Board with multiple lists
- **WHEN** a board has lists "To Do", "In Progress", and "Done" each containing cards
- **THEN** the returned cards SHALL have `listName` set to the respective list name
- **AND** cards from all lists SHALL be included in the result
+208
View File
@@ -0,0 +1,208 @@
## ADDED Requirements
### Requirement: Root command and global flags
The system SHALL provide a root command `pcli` that serves as the entry point. The root command SHALL register global flags: `--format` (string, default `json`, values `json` or `table`), `--url` (string, overrides `PLANKA_URL`), `--api-key` (string, overrides `PLANKA_API_KEY`), and `--log-level` (string, default `warn`, values `debug`, `info`, `warn`, `error`). The root command SHALL initialize the logger based on `--log-level` and configure it to write structured JSON to stderr. The root command SHALL validate that URL and API key are available (from flags or environment) before executing any subcommand.
#### Scenario: Display help
- **WHEN** `pcli` is run with no arguments or `--help`
- **THEN** the system SHALL display usage information listing all resource subcommands and global flags
#### Scenario: Invalid format flag
- **WHEN** `--format` is set to an unsupported value
- **THEN** the system SHALL print an error and exit with code 1
#### Scenario: Log level controls output
- **WHEN** `--log-level=debug` is set
- **THEN** DEBUG-level log entries SHALL appear on stderr
- **AND** stdout SHALL contain only the command's data output
#### Scenario: Missing API key
- **WHEN** neither `PLANKA_API_KEY` environment variable nor `--api-key` flag is provided
- **THEN** the system SHALL print an error indicating `PLANKA_API_KEY` must be set via `--api-key` flag or `PLANKA_API_KEY` environment variable
- **AND** the system SHALL exit with code 1
### Requirement: Project commands
The system SHALL provide a `project` command group with subcommands `list` and `get`. `pcli project list` SHALL call the client's ListProjects method and output the result. `pcli project get <id>` SHALL accept a project ID as a positional argument, call GetProject, and output the result.
#### Scenario: List projects
- **WHEN** `pcli project list` is executed
- **THEN** the system SHALL output all accessible projects
#### Scenario: Get project by ID
- **WHEN** `pcli project get <id>` is executed with a valid project ID
- **THEN** the system SHALL output the project details
#### Scenario: Get project missing ID
- **WHEN** `pcli project get` is executed without an ID argument
- **THEN** the system SHALL print an error indicating the ID is required and exit with code 1
### Requirement: Board commands
The system SHALL provide a `board` command group with subcommands `get` and `actions`. `pcli board get <id>` SHALL accept a board ID as a positional argument and output the board details. `pcli board actions <id>` SHALL accept a board ID and an optional `--limit` flag (int, default 0) and output the board's action history.
#### Scenario: Get board
- **WHEN** `pcli board get <id>` is executed
- **THEN** the system SHALL output the board details including its lists
#### Scenario: List board actions
- **WHEN** `pcli board actions <id>` is executed
- **THEN** the system SHALL output the board's action history
#### Scenario: List board actions with limit
- **WHEN** `pcli board actions <id> --limit 10` is executed
- **THEN** the system SHALL output at most 10 action entries
### Requirement: Card commands
The system SHALL provide a `card` command group with subcommands: `list`, `get`, `create`, `update`, `delete`, `duplicate`, `move`, `assign`, `unassign`, `add-label`, `remove-label`, `actions`.
`pcli card list` SHALL require either `--board <id>` or `--list <id>` flag and accept an optional `--limit` flag. `pcli card get <id>` SHALL accept a card ID as a positional argument. `pcli card create` SHALL require `--list <id>` and `--name <name>` flags and accept optional flags: `--description`, `--type`, `--position`, `--due-date`, `--due-completed`. `pcli card update <id>` SHALL accept a card ID and optional flags for each updatable field: `--name`, `--description`, `--type`, `--position`, `--due-date`, `--due-completed`. `pcli card delete <id>` SHALL accept a card ID. `pcli card duplicate <id>` SHALL accept a card ID and optional `--name` and `--position` flags. `pcli card move <id>` SHALL require `--list <id>` and accept optional `--position` flag. `pcli card assign <id>` SHALL require `--user <userId>`. `pcli card unassign <id>` SHALL require `--user <userId>`. `pcli card add-label <id>` SHALL require `--label <labelId>`. `pcli card remove-label <id>` SHALL require `--label <labelId>`. `pcli card actions <id>` SHALL accept an optional `--limit` flag.
#### Scenario: List cards by board
- **WHEN** `pcli card list --board <id>` is executed
- **THEN** the system SHALL output all cards across all lists in the board, each enriched with listName
#### Scenario: List cards by list
- **WHEN** `pcli card list --list <id>` is executed
- **THEN** the system SHALL output cards in the specified list
#### Scenario: List cards with limit
- **WHEN** `pcli card list --list <id> --limit 5` is executed
- **THEN** the system SHALL output at most 5 cards
#### Scenario: Card list missing board or list flag
- **WHEN** `pcli card list` is executed without `--board` or `--list`
- **THEN** the system SHALL print an error indicating one is required and exit with code 1
#### Scenario: Get card
- **WHEN** `pcli card get <id>` is executed
- **THEN** the system SHALL output the card details
#### Scenario: Create card
- **WHEN** `pcli card create --list <id> --name "Task name"` is executed
- **THEN** the system SHALL create the card and output the created card
#### Scenario: Create card missing required flags
- **WHEN** `pcli card create` is executed without `--list` or `--name`
- **THEN** the system SHALL print an error and exit with code 1
#### Scenario: Update card
- **WHEN** `pcli card update <id> --name "New name"` is executed
- **THEN** the system SHALL update the card and output the updated card
#### Scenario: Delete card
- **WHEN** `pcli card delete <id>` is executed
- **THEN** the system SHALL delete the card and output a success confirmation
#### Scenario: Duplicate card
- **WHEN** `pcli card duplicate <id>` is executed
- **THEN** the system SHALL duplicate the card and output the new card
#### Scenario: Move card
- **WHEN** `pcli card move <id> --list <listId>` is executed
- **THEN** the system SHALL update the card's listId (and optionally position) and output the updated card
#### Scenario: Assign user to card
- **WHEN** `pcli card assign <id> --user <userId>` is executed
- **THEN** the system SHALL add the user as a card member
#### Scenario: Unassign user from card
- **WHEN** `pcli card unassign <id> --user <userId>` is executed
- **THEN** the system SHALL remove the user from the card's members
#### Scenario: Add label to card
- **WHEN** `pcli card add-label <id> --label <labelId>` is executed
- **THEN** the system SHALL add the label to the card
#### Scenario: Remove label from card
- **WHEN** `pcli card remove-label <id> --label <labelId>` is executed
- **THEN** the system SHALL remove the label from the card
#### Scenario: List card actions
- **WHEN** `pcli card actions <id>` is executed
- **THEN** the system SHALL output the card's action history
### Requirement: Comment commands
The system SHALL provide a `comment` command group with subcommands: `list`, `create`, `update`, `delete`. `pcli comment list` SHALL require `--card <id>` and accept optional `--limit`. `pcli comment create` SHALL require `--card <id>` and `--text <text>`. `pcli comment update <id>` SHALL require `--text <text>`. `pcli comment delete <id>` SHALL accept a comment ID.
#### Scenario: List comments
- **WHEN** `pcli comment list --card <id>` is executed
- **THEN** the system SHALL output comments for the card
#### Scenario: Create comment
- **WHEN** `pcli comment create --card <id> --text "comment text"` is executed
- **THEN** the system SHALL create the comment and output the created comment
#### Scenario: Update comment
- **WHEN** `pcli comment update <id> --text "updated text"` is executed
- **THEN** the system SHALL update the comment and output the updated comment
#### Scenario: Delete comment
- **WHEN** `pcli comment delete <id>` is executed
- **THEN** the system SHALL delete the comment and output a success confirmation
### Requirement: Task list commands
The system SHALL provide a `task-list` command group with subcommands: `create`, `get`, `update`, `delete`. `pcli task-list create` SHALL require `--card <id>` and `--name <name>` and accept optional flags: `--position`, `--show-on-front`, `--hide-completed`. `pcli task-list get <id>` SHALL accept a task list ID. `pcli task-list update <id>` SHALL accept optional flags for each updatable field. `pcli task-list delete <id>` SHALL accept a task list ID.
#### Scenario: Create task list
- **WHEN** `pcli task-list create --card <id> --name "Checklist"` is executed
- **THEN** the system SHALL create the task list and output the created task list
#### Scenario: Get task list
- **WHEN** `pcli task-list get <id>` is executed
- **THEN** the system SHALL output the task list details including its tasks
#### Scenario: Update task list
- **WHEN** `pcli task-list update <id> --name "Renamed"` is executed
- **THEN** the system SHALL update the task list and output the updated task list
#### Scenario: Delete task list
- **WHEN** `pcli task-list delete <id>` is executed
- **THEN** the system SHALL delete the task list and output a success confirmation
### Requirement: Task commands
The system SHALL provide a `task` command group with subcommands: `create`, `update`, `delete`. `pcli task create` SHALL require `--task-list <id>` and `--name <name>` and accept optional flags: `--position`, `--completed`, `--assignee`, `--linked-card`. `pcli task update <id>` SHALL accept optional flags for each updatable field: `--name`, `--position`, `--completed`, `--assignee`, `--task-list`. `pcli task delete <id>` SHALL accept a task ID.
#### Scenario: Create task
- **WHEN** `pcli task create --task-list <id> --name "Do something"` is executed
- **THEN** the system SHALL create the task and output the created task
#### Scenario: Update task
- **WHEN** `pcli task update <id> --completed` is executed
- **THEN** the system SHALL mark the task as completed and output the updated task
#### Scenario: Move task to different list
- **WHEN** `pcli task update <id> --task-list <newListId>` is executed
- **THEN** the system SHALL move the task to the specified task list
#### Scenario: Delete task
- **WHEN** `pcli task delete <id>` is executed
- **THEN** the system SHALL delete the task and output a success confirmation
### Requirement: Status command
The system SHALL provide a top-level `status` command registered directly on the root command (not as a subcommand of any resource group). `pcli status` SHALL take no positional arguments. The command SHALL fetch all boards via `ListBoards`, then fetch each board's details via `GetBoard` sequentially, aggregate card counts per list, and output the result via the standard `output.Print` mechanism respecting the global `--format` flag.
#### Scenario: Run status command
- **WHEN** `pcli status` is executed
- **THEN** the system SHALL output a summary of all boards with their lists and card counts
#### Scenario: Status command respects format flag
- **WHEN** `pcli status --format table` is executed
- **THEN** the output SHALL be in table format
#### Scenario: Status command default format
- **WHEN** `pcli status` is executed without a `--format` flag
- **THEN** the output SHALL be in JSON envelope format
### Requirement: Label commands
The system SHALL provide a `label` command group with subcommands: `create`, `update`, `delete`. `pcli label create` SHALL require `--board <id>` and `--name <name>` and accept optional flags: `--color`, `--position`. `pcli label update <id>` SHALL accept optional flags: `--name`, `--color`, `--position`. `pcli label delete <id>` SHALL accept a label ID.
#### Scenario: Create label
- **WHEN** `pcli label create --board <id> --name "Bug" --color red` is executed
- **THEN** the system SHALL create the label and output the created label
#### Scenario: Update label
- **WHEN** `pcli label update <id> --name "Feature" --color green` is executed
- **THEN** the system SHALL update the label and output the updated label
#### Scenario: Delete label
- **WHEN** `pcli label delete <id>` is executed
- **THEN** the system SHALL delete the label and output a success confirmation
+66
View File
@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: JSON output envelope
The system SHALL wrap all successful command output in a JSON envelope with the structure `{"data": <result>, "error": null}`. The system SHALL wrap all error output in a JSON envelope with the structure `{"data": null, "error": "<message>"}`. The envelope SHALL be written to stdout. The `data` field SHALL contain the direct result of the command (object or array). The `error` field SHALL be null on success and a string message on failure.
#### Scenario: Successful command output
- **WHEN** a command completes successfully in JSON format
- **THEN** stdout SHALL contain `{"data": <result>, "error": null}`
- **AND** the process SHALL exit with code 0
#### Scenario: Error command output
- **WHEN** a command fails in JSON format
- **THEN** stdout SHALL contain `{"data": null, "error": "<message>"}`
- **AND** the process SHALL exit with code 1
#### Scenario: Envelope structure is consistent
- **WHEN** any command is executed in JSON format
- **THEN** the output SHALL always contain exactly the keys `data` and `error` at the top level
### Requirement: Table format output
The system SHALL support a `--format=table` flag that outputs results as human-readable tabular text. Table output SHALL be written to stdout. Table output SHALL NOT use the JSON envelope. When table format is active and an error occurs, the error message SHALL be written to stderr (not stdout). Each resource type SHALL define its own column set for table rendering.
#### Scenario: Table output for a list of items
- **WHEN** a list command is executed with `--format=table`
- **THEN** stdout SHALL contain a header row followed by one row per item
- **AND** columns SHALL be aligned and separated by whitespace
#### Scenario: Table output for a single item
- **WHEN** a get command is executed with `--format=table`
- **THEN** stdout SHALL contain a key-value representation of the item
#### Scenario: Error in table format
- **WHEN** a command fails with `--format=table`
- **THEN** the error message SHALL be written to stderr
- **AND** the process SHALL exit with code 1
### Requirement: Format flag default
The system SHALL default to JSON format when no `--format` flag is provided. The `--format` flag SHALL accept values `json` and `table`. Any other value SHALL cause an error and exit with code 1.
#### Scenario: Default format is JSON
- **WHEN** a command is executed without `--format`
- **THEN** the output SHALL be in JSON envelope format
#### Scenario: Explicit JSON format
- **WHEN** a command is executed with `--format=json`
- **THEN** the output SHALL be in JSON envelope format
#### Scenario: Explicit table format
- **WHEN** a command is executed with `--format=table`
- **THEN** the output SHALL be in table format
#### Scenario: Invalid format value
- **WHEN** a command is executed with `--format=xml`
- **THEN** the system SHALL print an error and exit with code 1
### Requirement: Log output separation
All log output SHALL be written to stderr using structured JSON via `log/slog`. Log output SHALL never appear on stdout. This ensures that stdout contains only the command's data output (JSON envelope or table) and is safe to pipe or parse programmatically.
#### Scenario: Logs do not pollute stdout
- **WHEN** a command is executed with `--log-level=debug`
- **THEN** all log entries SHALL appear on stderr
- **AND** stdout SHALL contain only the command's data output
#### Scenario: Logs are structured JSON
- **WHEN** a log entry is emitted
- **THEN** it SHALL be a valid JSON object with at minimum `time`, `level`, and `msg` fields
+53
View File
@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: Status command summary output
The system SHALL provide a top-level `pcli status` command that outputs a summary of all boards, their lists, and card counts. The summary SHALL include the total number of boards. For each board, the summary SHALL include the board name and a breakdown of each list within that board showing the list name, the number of open cards (where `isClosed` is false), and the number of closed cards (where `isClosed` is true). Empty lists SHALL be included in the output with 0 open and 0 closed cards.
#### Scenario: Status with multiple boards and lists
- **WHEN** `pcli status` is executed and there are boards with lists containing cards
- **THEN** the output SHALL include the total board count
- **AND** each board SHALL list all its lists with open and closed card counts
#### Scenario: Status with empty lists
- **WHEN** a board contains a list with no cards
- **THEN** that list SHALL appear in the output with 0 open cards and 0 closed cards
#### Scenario: Status with no boards
- **WHEN** `pcli status` is executed and there are no boards
- **THEN** the output SHALL indicate 0 boards
#### Scenario: Status with closed cards
- **WHEN** a list contains both open and closed cards
- **THEN** the open card count SHALL exclude closed cards
- **AND** the closed card count SHALL be shown separately
### Requirement: Status command JSON output
The system SHALL output the status summary in the standard JSON envelope format (`{"data": ..., "error": null}`) when `--format json` is used or no format flag is provided. The `data` field SHALL contain an object with `totalBoards` (integer) and `boards` (array). Each board object SHALL contain `id` (string), `name` (string), and `lists` (array). Each list object SHALL contain `id` (string), `name` (string), `openCards` (integer), and `closedCards` (integer).
#### Scenario: JSON output structure
- **WHEN** `pcli status` is executed with `--format json` or no format flag
- **THEN** the output SHALL be a JSON envelope with the status summary as the `data` field
#### Scenario: JSON output field types
- **WHEN** the JSON output is parsed
- **THEN** `totalBoards` SHALL be an integer
- **AND** each list's `openCards` and `closedCards` SHALL be integers
### Requirement: Status command table output
The system SHALL output the status summary in a human-readable table format when `--format table` is specified. The table output SHALL begin with a line showing the total number of boards (e.g., `3 boards`). For each board, the output SHALL display a board header line (e.g., `Board: Sprint Planning`) followed by a table with columns `LIST` and `CARDS`. The `CARDS` column SHALL display the open card count, and if there are closed cards, append ` (<n> closed)` (e.g., `12 (2 closed)`). If there are no closed cards, only the open count SHALL be displayed (e.g., `12`).
#### Scenario: Table output with closed cards
- **WHEN** `pcli status --format table` is executed and a list has 12 open and 2 closed cards
- **THEN** the CARDS column for that list SHALL display `12 (2 closed)`
#### Scenario: Table output with no closed cards
- **WHEN** `pcli status --format table` is executed and a list has 5 open and 0 closed cards
- **THEN** the CARDS column for that list SHALL display `5`
#### Scenario: Table output with empty list
- **WHEN** `pcli status --format table` is executed and a list has 0 open and 0 closed cards
- **THEN** the CARDS column for that list SHALL display `0`
#### Scenario: Table output board count line
- **WHEN** `pcli status --format table` is executed
- **THEN** the first line of output SHALL show the total board count (e.g., `3 boards`)
+239
View File
@@ -0,0 +1,239 @@
package output
import (
"encoding/json"
"fmt"
"io"
"os"
"reflect"
"text/tabwriter"
"git.franklin.lab/steve.cliff/pcli/model"
)
func Print(data any, format string, w io.Writer) error {
if format == "table" {
return printTable(data, w)
}
return printJSON(data, w)
}
func printJSON(data any, w io.Writer) error {
envelope := model.Envelope{
Data: data,
Error: nil,
}
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(envelope)
}
func PrintError(err error, format string, w io.Writer) error {
if format == "table" {
fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
return nil
}
errMsg := err.Error()
envelope := model.Envelope{
Data: nil,
Error: &errMsg,
}
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(envelope)
}
func PrintErrorWithCommand(err error, format string, w io.Writer, command string) error {
if format == "table" {
fmt.Fprintf(os.Stderr, "Error in %s command: %s\n", command, err.Error())
return nil
}
errMsg := fmt.Sprintf("Error in %s command: %s", command, err.Error())
envelope := model.Envelope{
Data: nil,
Error: &errMsg,
}
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(envelope)
}
func printTable(data any, w io.Writer) error {
if data == nil {
return nil
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
defer tw.Flush()
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice {
if v.Len() == 0 {
return nil
}
elemType := v.Index(0).Type()
switch elemType.Name() {
case "Project":
return printProjectTable(data.([]model.Project), tw)
case "Board":
return printBoardTable(data.([]model.Board), tw)
case "Card":
return printCardTable(data.([]model.Card), tw)
case "CardWithList":
return printCardWithListTable(data.([]model.CardWithList), tw)
case "Comment":
return printCommentTable(data.([]model.Comment), tw)
case "TaskList":
return printTaskListTable(data.([]model.TaskList), tw)
case "Task":
return printTaskTable(data.([]model.Task), tw)
case "Label":
return printLabelTable(data.([]model.Label), tw)
case "Action":
return printActionTable(data.([]model.Action), tw)
default:
return fmt.Errorf("unsupported slice type for table output: %s", elemType.Name())
}
}
switch data := data.(type) {
case *model.Project:
return printProjectTable([]model.Project{*data}, tw)
case *model.Board:
return printBoardTable([]model.Board{*data}, tw)
case *model.Card:
return printCardTable([]model.Card{*data}, tw)
case *model.Comment:
return printCommentTable([]model.Comment{*data}, tw)
case *model.TaskList:
return printTaskListTable([]model.TaskList{*data}, tw)
case *model.Task:
return printTaskTable([]model.Task{*data}, tw)
case *model.Label:
return printLabelTable([]model.Label{*data}, tw)
case model.StatusSummary:
return printStatusTable(data, tw)
case *model.StatusSummary:
return printStatusTable(*data, tw)
default:
return fmt.Errorf("unsupported type for table output: %T", data)
}
}
func printProjectTable(projects []model.Project, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tDESCRIPTION\tHIDDEN")
for _, p := range projects {
desc := ""
if p.Description != nil {
desc = *p.Description
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\n", p.ID, p.Name, desc, p.IsHidden)
}
return nil
}
func printBoardTable(boards []model.Board, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tPROJECT_ID\tDEFAULT_VIEW")
for _, b := range boards {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", b.ID, b.Name, b.ProjectID, b.DefaultView)
}
return nil
}
func printCardTable(cards []model.Card, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tLIST_ID\tTYPE\tCLOSED")
for _, c := range cards {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%v\n", c.ID, c.Name, c.ListID, c.Type, c.IsClosed)
}
return nil
}
func printCardWithListTable(cards []model.CardWithList, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tLIST\tTYPE\tCLOSED")
for _, c := range cards {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%v\n", c.ID, c.Name, c.ListName, c.Type, c.IsClosed)
}
return nil
}
func printCommentTable(comments []model.Comment, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tCARD_ID\tTEXT\tCREATED_AT")
for _, c := range comments {
createdAt := ""
if c.CreatedAt != nil {
createdAt = *c.CreatedAt
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.ID, c.CardID, c.Text, createdAt)
}
return nil
}
func printTaskListTable(taskLists []model.TaskList, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tCARD_ID\tPOSITION")
for _, tl := range taskLists {
fmt.Fprintf(tw, "%s\t%s\t%s\t%.0f\n", tl.ID, tl.Name, tl.CardID, tl.Position)
}
return nil
}
func printTaskTable(tasks []model.Task, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tTASK_LIST_ID\tCOMPLETED")
for _, t := range tasks {
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\n", t.ID, t.Name, t.TaskListID, t.IsCompleted)
}
return nil
}
func printLabelTable(labels []model.Label, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tCOLOR\tBOARD_ID")
for _, l := range labels {
name := ""
if l.Name != nil {
name = *l.Name
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", l.ID, name, l.Color, l.BoardID)
}
return nil
}
func printActionTable(actions []model.Action, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tTYPE\tCARD_ID\tCREATED_AT")
for _, a := range actions {
createdAt := ""
if a.CreatedAt != nil {
createdAt = *a.CreatedAt
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Type, a.CardID, createdAt)
}
return nil
}
func printStatusTable(summary model.StatusSummary, tw *tabwriter.Writer) error {
// Print total board count
fmt.Fprintf(tw, "%d boards\n\n", summary.TotalBoards)
// Print each board with its lists
for i, board := range summary.Boards {
fmt.Fprintf(tw, "Board: %s\n", board.Name)
fmt.Fprintln(tw, "LIST\tCARDS")
for _, list := range board.Lists {
cardsText := fmt.Sprintf("%d", list.OpenCards)
if list.ClosedCards > 0 {
cardsText += fmt.Sprintf(" (%d closed)", list.ClosedCards)
}
fmt.Fprintf(tw, "%s\t%s\n", list.Name, cardsText)
}
// Add blank line between boards (except last one)
if i < len(summary.Boards)-1 {
fmt.Fprintln(tw)
}
}
return nil
}
File diff suppressed because it is too large Load Diff
Executable
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the project first
echo "Building project..."
./build.sh
# Get version from git tag or use a default
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "v$(date +%Y%m%d-%H%M%S)")
echo "Creating release: $VERSION"
# Create the release using tea CLI
# This assumes you have tea configured and authenticated
tea release create \
--title "Release $VERSION" \
--note "Automated release for version $VERSION" \
--tag "$VERSION" \
--target "$(git rev-parse HEAD)" \
--asset pcli
echo "Release $VERSION created successfully!"
Executable
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env bash
set -uo pipefail
PCLI="./pcli"
PASS=0
FAIL=0
SKIP=0
ERRORS=""
# Resource IDs for cleanup
CARD_ID=""
DUP_CARD_ID=""
COMMENT_ID=""
TL_ID=""
TASK_ID=""
TASK_ID2=""
LABEL_ID=""
green() { printf "\033[32m%s\033[0m\n" "$1"; }
red() { printf "\033[31m%s\033[0m\n" "$1"; }
yellow() { printf "\033[33m%s\033[0m\n" "$1"; }
run_test() {
local desc="$1"
shift
local output
if output=$("$@" 2>&1); then
PASS=$((PASS + 1))
green "$desc"
echo "$output"
else
FAIL=$((FAIL + 1))
red "$desc"
ERRORS="${ERRORS}\n - ${desc}"
echo "$output"
fi
}
run_test_optional() {
local desc="$1"
shift
local output
if output=$("$@" 2>&1); then
PASS=$((PASS + 1))
green "$desc"
echo "$output"
else
SKIP=$((SKIP + 1))
yellow "$desc (skipped — may not be supported)"
fi
}
extract_id() {
echo "$1" | jq -r '.data.id // empty'
}
cleanup() {
echo ""
echo "--- Cleanup ---"
[ -n "$TASK_ID2" ] && { $PCLI task delete "$TASK_ID2" >/dev/null 2>&1 && green " ✓ deleted task $TASK_ID2" || yellow " ⚠ could not delete task $TASK_ID2"; }
[ -n "$TASK_ID" ] && { $PCLI task delete "$TASK_ID" >/dev/null 2>&1 && green " ✓ deleted task $TASK_ID" || yellow " ⚠ could not delete task $TASK_ID"; }
[ -n "$TL_ID" ] && { $PCLI task-list delete "$TL_ID" >/dev/null 2>&1 && green " ✓ deleted task-list $TL_ID" || yellow " ⚠ could not delete task-list $TL_ID"; }
[ -n "$COMMENT_ID" ] && { $PCLI comment delete "$COMMENT_ID" >/dev/null 2>&1 && green " ✓ deleted comment $COMMENT_ID" || yellow " ⚠ could not delete comment $COMMENT_ID"; }
[ -n "$LABEL_ID" ] && { $PCLI label delete "$LABEL_ID" >/dev/null 2>&1 && green " ✓ deleted label $LABEL_ID" || yellow " ⚠ could not delete label $LABEL_ID"; }
[ -n "$DUP_CARD_ID" ] && { $PCLI card delete "$DUP_CARD_ID" >/dev/null 2>&1 && green " ✓ deleted card $DUP_CARD_ID (dup)" || yellow " ⚠ could not delete card $DUP_CARD_ID"; }
[ -n "$CARD_ID" ] && { $PCLI card delete "$CARD_ID" >/dev/null 2>&1 && green " ✓ deleted card $CARD_ID" || yellow " ⚠ could not delete card $CARD_ID"; }
}
trap cleanup EXIT
echo "============================================"
echo " pcli Integration Test Suite"
echo "============================================"
echo ""
# Check prerequisites
if [ -z "${PLANKA_URL:-}" ]; then
red "PLANKA_URL is not set"
exit 1
fi
if [ -z "${PLANKA_API_KEY:-}" ]; then
red "PLANKA_API_KEY is not set"
exit 1
fi
echo "Using PLANKA_URL: $PLANKA_URL"
echo ""
# Build
echo "--- Building pcli ---"
if go build -o pcli; then
PASS=$((PASS + 1))
green " ✓ Build successful"
else
red " ✗ Build failed"
exit 1
fi
echo ""
# ==========================================
# 1. Help & Error Handling
# ==========================================
echo "--- 1. Help & Error Handling ---"
run_test "pcli --help" $PCLI --help
echo ""
run_test "pcli project --help" $PCLI project --help
echo ""
run_test "pcli board --help" $PCLI board --help
echo ""
run_test "pcli card --help" $PCLI card --help
echo ""
# Test missing env vars
echo " Testing missing PLANKA_URL error..."
ERR_OUTPUT=$(PLANKA_URL="" PLANKA_API_KEY="" $PCLI project list 2>&1) || true
if echo "$ERR_OUTPUT" | grep -q "PLANKA_URL"; then
PASS=$((PASS + 1))
green " ✓ Missing PLANKA_URL produces error"
else
FAIL=$((FAIL + 1))
red " ✗ Missing PLANKA_URL error not detected"
ERRORS="${ERRORS}\n - Missing PLANKA_URL error"
fi
echo ""
# ==========================================
# 2. Project Commands
# ==========================================
echo "--- 2. Project Commands ---"
PROJECT_LIST=$($PCLI project list) || { red " ✗ project list failed"; exit 1; }
run_test "project list" echo "$PROJECT_LIST"
echo ""
PROJECT_ID=$(echo "$PROJECT_LIST" | jq -r '.data[0].id')
echo " Using project ID: $PROJECT_ID"
run_test "project get $PROJECT_ID" $PCLI project get "$PROJECT_ID"
echo ""
run_test "project list --format=table" $PCLI --format=table project list
echo ""
# ==========================================
# 3. Board Commands
# ==========================================
echo "--- 3. Board Commands ---"
BOARD_LIST=$($PCLI board list) || { red " ✗ board list failed"; exit 1; }
run_test "board list" echo "$BOARD_LIST"
echo ""
BOARD_ID=$(echo "$BOARD_LIST" | jq -r '.data[0].id')
echo " Using board ID: $BOARD_ID"
BOARD_GET=$($PCLI board get "$BOARD_ID") || { red " ✗ board get failed"; exit 1; }
run_test "board get $BOARD_ID" echo "$BOARD_GET"
echo ""
run_test_optional "board actions $BOARD_ID --limit 5" $PCLI board actions "$BOARD_ID" --limit 5
echo ""
run_test "board list --format=table" $PCLI --format=table board list
echo ""
# ==========================================
# 3b. Status Command
# ==========================================
echo "--- 3b. Status Command ---"
run_test "status (json)" $PCLI status
echo ""
run_test "status (table)" $PCLI --format=table status
echo ""
# ==========================================
# 4. Card CRUD
# ==========================================
echo "--- 4. Card CRUD ---"
# Get a list ID from the board
LIST_ID=$(echo "$BOARD_GET" | jq -r '.data.lists[0].id // empty')
if [ -z "$LIST_ID" ]; then
red " ✗ No lists found on board $BOARD_ID"
exit 1
fi
echo " Using list ID: $LIST_ID"
# Create a card
CARD_CREATE=$($PCLI card create --list "$LIST_ID" --name "Test Card from pcli" --description "Created by test script")
run_test "card create" echo "$CARD_CREATE"
CARD_ID=$(extract_id "$CARD_CREATE")
echo " Created card ID: $CARD_ID"
echo ""
if [ -z "$CARD_ID" ]; then
red " ✗ Card creation failed, cannot continue card tests"
else
# Get the card
run_test "card get $CARD_ID" $PCLI card get "$CARD_ID"
echo ""
# Update the card
CARD_UPDATE=$($PCLI card update "$CARD_ID" --name "Test Card Updated" --description "Updated by test script")
run_test "card update $CARD_ID" echo "$CARD_UPDATE"
echo ""
# List cards by list (may not work on older Planka versions)
run_test_optional "card list --list $LIST_ID" $PCLI card list --list "$LIST_ID"
echo ""
# List cards by board
run_test "card list --board $BOARD_ID" $PCLI card list --board "$BOARD_ID"
echo ""
# List cards by board with limit
run_test "card list --board $BOARD_ID --limit 2" $PCLI card list --board "$BOARD_ID" --limit 2
echo ""
# Card actions (optional)
run_test_optional "card actions $CARD_ID --limit 5" $PCLI card actions "$CARD_ID" --limit 5
echo ""
# Duplicate the card
CARD_DUP=$($PCLI card duplicate "$CARD_ID" --name "Duplicated Test Card")
run_test "card duplicate $CARD_ID" echo "$CARD_DUP"
DUP_CARD_ID=$(extract_id "$CARD_DUP")
echo " Duplicated card ID: $DUP_CARD_ID"
echo ""
# Move test
SECOND_LIST_ID=$(echo "$BOARD_GET" | jq -r '.data.lists[1].id // empty')
if [ -n "$SECOND_LIST_ID" ]; then
CARD_MOVE=$($PCLI card move "$CARD_ID" --list "$SECOND_LIST_ID")
run_test "card move $CARD_ID --list $SECOND_LIST_ID" echo "$CARD_MOVE"
echo ""
else
yellow " ⚠ Skipping card move test — only one list on board"
echo ""
fi
# Table format
run_test "card list --board $BOARD_ID --format=table" $PCLI --format=table card list --board "$BOARD_ID"
echo ""
# ==========================================
# 5. Comment CRUD (optional — may 404 on some Planka versions)
# ==========================================
echo "--- 5. Comment CRUD ---"
COMMENT_CREATE=$($PCLI comment create --card "$CARD_ID" --text "Test comment from pcli" 2>&1) || true
CMT_ID=$(echo "$COMMENT_CREATE" | jq -r '.data.id // empty' 2>/dev/null)
if [ -n "$CMT_ID" ]; then
COMMENT_ID="$CMT_ID"
PASS=$((PASS + 1))
green " ✓ comment create"
echo "$COMMENT_CREATE"
echo " Created comment ID: $COMMENT_ID"
echo ""
run_test_optional "comment list --card $CARD_ID" $PCLI comment list --card "$CARD_ID"
echo ""
run_test "comment update $COMMENT_ID" $PCLI comment update "$COMMENT_ID" --text "Updated test comment"
echo ""
else
SKIP=$((SKIP + 1))
yellow " ⚠ comment create not supported on this Planka instance (skipped)"
echo ""
fi
# ==========================================
# 6. Task List CRUD (optional — may not exist on older Planka)
# ==========================================
echo "--- 6. Task List CRUD ---"
TL_CREATE=$($PCLI task-list create --card "$CARD_ID" --name "Test Checklist" 2>&1) || true
TL_ID_VAL=$(echo "$TL_CREATE" | jq -r '.data.id // empty' 2>/dev/null)
if [ -n "$TL_ID_VAL" ]; then
TL_ID="$TL_ID_VAL"
PASS=$((PASS + 1))
green " ✓ task-list create"
echo " Created task list ID: $TL_ID"
echo ""
run_test "task-list get $TL_ID" $PCLI task-list get "$TL_ID"
echo ""
TL_UPDATE=$($PCLI task-list update "$TL_ID" --name "Updated Checklist")
run_test "task-list update $TL_ID" echo "$TL_UPDATE"
echo ""
# ==========================================
# 7. Task CRUD
# ==========================================
echo "--- 7. Task CRUD ---"
TASK_CREATE=$($PCLI task create --task-list "$TL_ID" --name "Test Task 1")
run_test "task create" echo "$TASK_CREATE"
TASK_ID=$(extract_id "$TASK_CREATE")
echo " Created task ID: $TASK_ID"
echo ""
if [ -n "$TASK_ID" ]; then
TASK_UPDATE=$($PCLI task update "$TASK_ID" --completed)
run_test "task update $TASK_ID --completed" echo "$TASK_UPDATE"
echo ""
fi
TASK_CREATE2=$($PCLI task create --task-list "$TL_ID" --name "Test Task 2")
run_test "task create (second)" echo "$TASK_CREATE2"
TASK_ID2=$(extract_id "$TASK_CREATE2")
echo ""
else
SKIP=$((SKIP + 1))
yellow " ⚠ task-list create not supported on this Planka instance (skipped)"
echo ""
fi
# ==========================================
# 8. Label CRUD
# ==========================================
echo "--- 8. Label CRUD ---"
LABEL_CREATE=$($PCLI label create --board "$BOARD_ID" --name "test-label" --color "berry-red")
run_test "label create" echo "$LABEL_CREATE"
LABEL_ID=$(extract_id "$LABEL_CREATE")
echo " Created label ID: $LABEL_ID"
echo ""
if [ -n "$LABEL_ID" ]; then
LABEL_UPDATE=$($PCLI label update "$LABEL_ID" --name "updated-label" --color "lagoon-blue")
run_test "label update $LABEL_ID" echo "$LABEL_UPDATE"
echo ""
# ==========================================
# 9. Card Label Operations
# ==========================================
echo "--- 9. Card Label Operations ---"
run_test "card add-label $CARD_ID --label $LABEL_ID" $PCLI card add-label "$CARD_ID" --label "$LABEL_ID"
echo ""
run_test "card remove-label $CARD_ID --label $LABEL_ID" $PCLI card remove-label "$CARD_ID" --label "$LABEL_ID"
echo ""
fi
fi
# Cleanup runs via EXIT trap
# ==========================================
# Summary
# ==========================================
echo ""
echo "============================================"
echo " Test Summary"
echo "============================================"
green " Passed: $PASS"
if [ "$SKIP" -gt 0 ]; then
yellow " Skipped: $SKIP"
fi
if [ "$FAIL" -gt 0 ]; then
red " Failed: $FAIL"
echo ""
red " Failed tests:"
echo -e "$ERRORS"
else
green " Failed: 0"
fi
echo ""
TOTAL=$((PASS + FAIL))
echo " Total: $TOTAL (plus $SKIP skipped)"
echo "============================================"
exit "$FAIL"