Initial commit: plane-cli zsh wrapper for Plane

A lightweight zsh + curl CLI for Plane with fzf pickers: list/create/edit/
delete issues, state/priority/title filters, JSON output, comments, labels,
multi-project switching, and cross-project sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
theo 2026-06-04 17:00:36 +02:00
commit f846426239
4 changed files with 691 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.DS_Store
*.local
.env
.env.*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# plane-cli
A lightweight **zsh + curl** CLI for [Plane](https://plane.so) (works with self-hosted
instances). No build step, no dependencies to install beyond what you already have — it's
a single sourced shell file that wraps the Plane REST API with `fzf` pickers.
> Personal tool, built for a self-hosted Plane instance. Inspired by
> [`cpatrickalves/plane-cli`](https://github.com/cpatrickalves/plane-cli), but deliberately
> tiny: just functions in your shell.
## Requirements
- `zsh`
- `curl`
- `python3` (used for JSON shaping — stdlib only)
- `fzf` (interactive pickers)
## Install
```sh
git clone https://forgejo.ludique.dev/theo/plane-cli.git ~/Developer/plane-cli
```
Add to your `~/.zshrc`:
```sh
export PLANE_API_KEY="plane_api_xxxxxxxxxxxxxxxx" # keep this out of version control
source ~/Developer/plane-cli/plane.zsh
```
Reload: `source ~/.zshrc`.
### Configuration
| Env var | Required | Default |
|----------------------|----------|-----------------------------------------------------------|
| `PLANE_API_KEY` | **yes** | — (Plane → *Settings → API tokens*) |
| `PLANE_BASE` | no | `https://plane.ludique.dev/api/v1/workspaces/oloap` |
| `PLANE_PROJECT` | no | a project UUID |
| `PLANE_PROJECT_NAME` | no | `colorcatchers` |
`PLANE_BASE` is `<your-plane>/api/v1/workspaces/<workspace-slug>`. The active project can
also be switched at runtime with `pluse` (it re-fetches the project's workflow states).
## Commands
```
pl list open issues (shows labels)
pl -a list ALL issues (incl. done/cancelled)
pl -s backlog|todo|... filter by state
pl -p urgent|high|... filter by priority
pl /text search titles containing 'text'
pl -j raw JSON output (pipe to jq); honours filters
pl -h full help
pladd create an issue (interactive wizard)
pladd "title" [opts] create inline
-p priority urgent|high|medium|low (default: none)
-s state backlog|todo|progress (default: backlog)
-d YYYY-MM-DD due date
-l a,b,c attach labels (must already exist)
plstate [#] change state — fzf picker if no id
pldone [#] mark done — fzf picker if no id
plshow fzf-pick and inspect an issue (shows labels)
pledit [#] edit title/priority/due/labels
plrm [# ...] delete — fzf multi-select if no ids
plcomment [#] [text] list comments on an issue, or add one
pllabels [add "n" [#hex]] list project labels, or create one
plprojects list all projects
pluse [name|id] switch active project (fzf if no arg)
plsync [--dry-run] mirror open issues from all projects into an "Everything" project
```
### Examples
```sh
pl -s todo # only todo issues
pl -p high # only high priority
pl /login # titles containing "login"
pl -j | jq -r '.[].name' # scriptable JSON
pladd "Fix export button" -p high -s todo -l bug,urgent
plcomment 7 "fixed in latest deploy"
pllabels add "bug" "#FF0000"
```
## Notes
- `pl -j` emits the filtered issue list as JSON for piping to `jq`.
- Comments are created via Plane's `comment_html` field (the API stores HTML).
- `plsync` is idempotent — it keys mirrored items by `[IDENTIFIER] title` and removes ones
that have been closed at the source.
## License
MIT

567
plane.zsh Normal file
View file

@ -0,0 +1,567 @@
#!/usr/bin/env zsh
# plane-cli — a lightweight zsh + curl CLI for Plane (https://plane.so).
# Source this file from your ~/.zshrc: source /path/to/plane.zsh
#
# Required: export PLANE_API_KEY="plane_api_xxx" (Plane: Settings -> API tokens)
# Optional: PLANE_BASE, PLANE_PROJECT, PLANE_PROJECT_NAME (see README)
# Deps: zsh, curl, python3, fzf
# Plane API token — REQUIRED, taken from the environment (never hard-code it).
_PL_KEY="${PLANE_API_KEY:?plane-cli: set PLANE_API_KEY to your Plane API token}"
_PL_BASE="${PLANE_BASE:-https://plane.ludique.dev/api/v1/workspaces/oloap}"
_PL_PROJECT="${PLANE_PROJECT:-9eab6f76-e0a6-47a3-ab4e-2cd53b71883c}"
_PL_PROJECT_NAME="${PLANE_PROJECT_NAME:-colorcatchers}"
typeset -gA _PL_STATE
_PL_STATE=([backlog]="02bd8972-e615-4b1f-893f-66e69d20a8e2" [todo]="00507acd-e959-446b-9a8f-c9580bafd1d5" [progress]="09becae7-4702-44c8-9bd5-27d20f82ee02" [done]="65f467f7-d9e1-4d63-a02d-c35b124abb53" [cancelled]="70dd95eb-dabc-4d52-8b7c-6cd27ad9be69")
_pl_get() { curl -s -H "X-Api-Key: $_PL_KEY" "$@"; }
_pl_post() { curl -s -X POST -H "X-Api-Key: $_PL_KEY" -H "Content-Type: application/json" "$@"; }
_pl_patch() { curl -s -X PATCH -H "X-Api-Key: $_PL_KEY" -H "Content-Type: application/json" "$@"; }
_pl_delete() { curl -s -X DELETE -H "X-Api-Key: $_PL_KEY" "$@"; }
# state key→id map as JSON (reflects the active project after `pluse`)
_pl_state_json() { python3 -c "import json,sys; print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2]))))" "${(@kv)_PL_STATE}"; }
# label id→name map as JSON for the active project
_pl_labels_json() {
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c "
import sys, json
d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', [])
print(json.dumps({l['id']: l['name'] for l in r}))
"
}
# Resolve "a,b,c" label names → JSON id array (warns on unknown names → stderr)
_pl_resolve_labels() {
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c "
import sys, json
d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', [])
by = {l['name'].lower(): l['id'] for l in r}
ids = []
for nm in [x.strip() for x in sys.argv[1].split(',') if x.strip()]:
if nm.lower() in by: ids.append(by[nm.lower()])
else: sys.stderr.write(f' ! no label named \"{nm}\" (create with: pllabels add \"{nm}\")\n')
print(json.dumps(ids))
" "$1"
}
# Resolve sequence_id → UUID
_pl_id_from_seq() {
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \
| python3 -c "
import sys, json
for i in json.load(sys.stdin).get('results', []):
if str(i['sequence_id']) == '${1}':
print(i['id']); break
"
}
# Filter + format issues.
# args: <all> <json> <state> <priority> <query> <state_map_json> <label_map_json>
_pl_fmt_issues() {
python3 -c "
import sys, json
all_flag, json_out, fstate, fprio, query = sys.argv[1:6]
smap = json.loads(sys.argv[6]) # state key -> id
labels = json.loads(sys.argv[7]) # label id -> name
id2key = {v: k for k, v in smap.items()}
done_ids = {smap.get('done'), smap.get('cancelled')}
out = []
for i in json.load(sys.stdin).get('results', []):
if all_flag != '-a' and i.get('state') in done_ids: continue
if fstate and i.get('state') != smap.get(fstate): continue
if fprio and i.get('priority') != fprio: continue
if query and query.lower() not in i['name'].lower(): continue
out.append(i)
if json_out == '1':
print(json.dumps(out, indent=2)); sys.exit()
for i in out:
due = (i.get('target_date') or '')[:10] or '—'
lbl = ','.join(labels.get(l, l[:6]) for l in i.get('labels', []))
lbl = ' {' + lbl + '}' if lbl else ''
print(f'#{i[\"sequence_id\"]:>3} {i[\"name\"]} [{i[\"priority\"]}] [{id2key.get(i[\"state\"],\"?\")}] due:{due}{lbl}')
" "$@"
}
# fzf picker → sequence_id; pass --multi for multi-select
_pl_pick() {
local prompt="${1:-Issue}" multi="${2:---no-multi}"
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \
| python3 -c "
import sys, json
sn = {'02bd8972-e615-4b1f-893f-66e69d20a8e2':'backlog','00507acd-e959-446b-9a8f-c9580bafd1d5':'todo','09becae7-4702-44c8-9bd5-27d20f82ee02':'progress','65f467f7-d9e1-4d63-a02d-c35b124abb53':'done','70dd95eb-dabc-4d52-8b7c-6cd27ad9be69':'cancelled'}
for i in json.load(sys.stdin).get('results', []):
print(f'{i[\"sequence_id\"]} {i[\"name\"]} [{i[\"priority\"]}] [{sn.get(i[\"state\"],\"?\")}]')
" | fzf --prompt="$prompt > " $multi | awk '{print $1}'
}
# List open issues | pl -a for all | pl -h for help
pl() {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
cat <<EOF
Plane CLI (project: $_PL_PROJECT_NAME)
pl list open issues
pl -a list ALL issues (incl. done/cancelled)
pl -s backlog|todo|... filter by state
pl -p urgent|high|... filter by priority
pl /text search titles containing 'text'
pl -j raw JSON output (pipe to jq); honours filters
pladd create an issue (interactive wizard)
pladd <title> [opts] create an issue (inline)
-p, --priority urgent|high|medium|low default: none
-s, --state backlog|todo|progress default: backlog
-d, --due YYYY-MM-DD set due date
-l, --labels a,b,c attach labels (must exist)
plstate [#] change state — fzf picker if no id
pldone [#] mark done — fzf picker if no id
plshow fuzzy-pick and inspect an issue (shows labels)
pledit [#] edit title/priority/due/labels — fzf if no id
plrm [# ...] delete — fzf multi-select if no ids
plcomment [#] [text] list comments on an issue, or add one
pllabels [add "n" [#hex]] list project labels, or create one
plprojects list all projects
pluse [name|id] switch active project (fzf if no arg)
plsync [--dry-run] mirror open issues from all projects → Everything
EOF
return
fi
local all="" json="" state="" prio="" query=""
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--all) all="-a"; shift ;;
-j|--json) json="1"; shift ;;
-s|--state) state="$2"; shift 2 ;;
-p|--priority) prio="$2"; shift 2 ;;
/*) query="${1#/}"; shift ;;
*) query="$1"; shift ;;
esac
done
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \
| _pl_fmt_issues "$all" "$json" "$state" "$prio" "$query" "$(_pl_state_json)" "$(_pl_labels_json)"
}
# pladd "fix login bug" -p high -s todo -d 2026-05-20
# pladd (interactive wizard)
pladd() {
local title="" priority="none" state_key="backlog" due="" labels_csv=""
local interactive=false
if [[ $# -eq 0 ]]; then
interactive=true
# 1. Pick project
local proj_choice proj_id proj_name
proj_choice=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
import sys, json
projects = json.load(sys.stdin)
projects = projects if isinstance(projects, list) else projects.get('results', [])
active = '${_PL_PROJECT}'
for p in projects:
marker = ' ←' if p['id'] == active else ''
print(f'{p[\"id\"]} {p[\"name\"]}{marker}')
" | fzf --prompt="Project > ")
[[ -z "$proj_choice" ]] && return
proj_id=$(echo "$proj_choice" | awk '{print $1}')
proj_name=$(echo "$proj_choice" | sed 's/^[^ ]* *//')
# 2. Title
echo -n "Title: "; read title
[[ -z "$title" ]] && { echo "Cancelled."; return 1; }
# 3. Priority
priority=$(printf '%s\n' none urgent high medium low | fzf --prompt="Priority > ")
[[ -z "$priority" ]] && priority="none"
# 4. State
state_key=$(printf '%s\n' backlog todo progress | fzf --prompt="State > ")
[[ -z "$state_key" ]] && state_key="backlog"
# 5. Due date (optional)
echo -n "Due date (YYYY-MM-DD, enter to skip): "; read due
# 6. Labels (optional, multi-select from the chosen project's labels)
labels_csv=$(_pl_get "$_PL_BASE/projects/$proj_id/labels/" | python3 -c "
import sys, json
d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', [])
for l in r: print(l['name'])
" | fzf --multi --prompt="Labels (tab=multi, esc=none) > " | paste -sd, -)
# Temporarily switch project if different
local orig_project="$_PL_PROJECT" orig_name="$_PL_PROJECT_NAME"
if [[ "$proj_id" != "$_PL_PROJECT" ]]; then
pluse "$proj_id" > /dev/null
fi
else
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--priority) priority="$2"; shift 2 ;;
-s|--state) state_key="$2"; shift 2 ;;
-d|--due) due="$2"; shift 2 ;;
-l|--labels) labels_csv="$2"; shift 2 ;;
*) title+="${title:+ }$1"; shift ;;
esac
done
[[ -z "$title" ]] && { echo "Usage: pladd <title> [-p priority] [-s state] [-d YYYY-MM-DD] [-l labels]"; return 1; }
fi
local state_id="${_PL_STATE[$state_key]:-${_PL_STATE[backlog]}}"
local labels_json="[]"
[[ -n "$labels_csv" ]] && labels_json="$(_pl_resolve_labels "$labels_csv")"
local body
body=$(python3 -c "
import json, sys
d = {'name': sys.argv[1], 'priority': sys.argv[2], 'state': sys.argv[3]}
if sys.argv[4]: d['target_date'] = sys.argv[4]
lbls = json.loads(sys.argv[5])
if lbls: d['labels'] = lbls
print(json.dumps(d))
" "$title" "$priority" "$state_id" "$due" "$labels_json")
_pl_post "$_PL_BASE/projects/$_PL_PROJECT/issues/" -d "$body" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created #{d[\"sequence_id\"]} {d[\"name\"]} [{d[\"priority\"]}]')"
# Restore original project if we switched for interactive mode
if [[ "$interactive" == true && "$orig_project" != "$proj_id" ]]; then
pluse "$orig_project" > /dev/null
fi
}
# Change state interactively or by id: plstate [#]
plstate() {
local seq="${1:-$(_pl_pick "Issue")}" id new_state state_id
[[ -z "$seq" ]] && return
id=$(_pl_id_from_seq "$seq")
[[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; }
new_state=$(printf '%s\n' backlog todo progress done cancelled | fzf --prompt="New state > ")
[[ -z "$new_state" ]] && return
state_id="${_PL_STATE[$new_state]}"
[[ -z "$state_id" ]] && { echo "Unknown state: $new_state"; return 1; }
_pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "{\"state\":\"$state_id\"}" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Updated #{d[\"sequence_id\"]} {d[\"name\"]}')"
}
# Mark done: pldone [#]
pldone() {
local seq="${1:-$(_pl_pick "Done")}" id
[[ -z "$seq" ]] && return
id=$(_pl_id_from_seq "$seq")
[[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; }
_pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "{\"state\":\"${_PL_STATE[done]}\"}" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Done #{d[\"sequence_id\"]} {d[\"name\"]}')"
}
# Fuzzy-pick and inspect: plshow
plshow() {
local seq id
seq=$(_pl_pick "Show")
[[ -z "$seq" ]] && return
id=$(_pl_id_from_seq "$seq")
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" | python3 -c "
import sys, json
d = json.load(sys.stdin)
smap = json.loads(sys.argv[1]); id2key = {v: k for k, v in smap.items()}
labels = json.loads(sys.argv[2])
due = (d.get('target_date') or '')[:10] or '—'
lbl = ', '.join(labels.get(l, l) for l in d.get('labels', [])) or '—'
print(f'#{d[\"sequence_id\"]} {d[\"name\"]}')
print(f' priority : {d[\"priority\"]}')
print(f' state : {id2key.get(d[\"state\"],\"?\")}')
print(f' labels : {lbl}')
print(f' due : {due}')
print(f' created : {d[\"created_at\"][:10]}')
" "$(_pl_state_json)" "$(_pl_labels_json)"
}
# Edit title/priority/due: pledit [#]
pledit() {
local seq="${1:-$(_pl_pick "Edit")}" id
[[ -z "$seq" ]] && return
id=$(_pl_id_from_seq "$seq")
[[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; }
local title priority due labels_in
echo -n "New title (enter to skip): "; read title
echo -n "New priority (enter to skip): "; read priority
echo -n "New due YYYY-MM-DD (enter to skip): "; read due
echo -n "Labels a,b,c (enter=skip, - =clear): "; read labels_in
local labels_json=""
if [[ "$labels_in" == "-" ]]; then labels_json="[]"
elif [[ -n "$labels_in" ]]; then labels_json="$(_pl_resolve_labels "$labels_in")"; fi
local body
body=$(python3 -c "
import json, sys
d = {}
if sys.argv[1]: d['name'] = sys.argv[1]
if sys.argv[2]: d['priority'] = sys.argv[2]
if sys.argv[3]: d['target_date'] = sys.argv[3]
if sys.argv[4] != '': d['labels'] = json.loads(sys.argv[4])
print(json.dumps(d))
" "$title" "$priority" "$due" "$labels_json")
[[ "$body" == "{}" ]] && { echo "Nothing changed."; return; }
_pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "$body" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Updated #{d[\"sequence_id\"]} {d[\"name\"]} [{d[\"priority\"]}]')"
}
# Delete issues: plrm [# ...]
plrm() {
local -a seqs=("$@")
[[ ${#seqs} -eq 0 ]] && seqs=($(_pl_pick "Delete" --multi))
[[ ${#seqs} -eq 0 ]] && return
for seq in "${seqs[@]}"; do
local id=$(_pl_id_from_seq "$seq")
if [[ -z "$id" ]]; then echo "Issue #$seq not found"; continue; fi
_pl_delete "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" > /dev/null
echo "Deleted #$seq"
done
}
# Comments: plcomment [#] [text...]
# plcomment fzf-pick an issue, then list its comments
# plcomment 7 list comments on #7
# plcomment 7 fixed it add a comment to #7
plcomment() {
local seq=""
if [[ -n "$1" && "$1" != -* ]]; then seq="$1"; shift; fi
[[ -z "$seq" ]] && seq=$(_pl_pick "Comment")
[[ -z "$seq" ]] && return
local id; id=$(_pl_id_from_seq "$seq")
[[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; }
local text="$*"
if [[ -n "$text" ]]; then
_pl_post "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/comments/" \
-d "$(python3 -c 'import json,sys,html; print(json.dumps({"comment_html":"<p>"+html.escape(sys.argv[1])+"</p>"}))' "$text")" \
> /dev/null
echo "Commented on #$seq"
return
fi
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/comments/" | python3 -c "
import sys, json, re
d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', [])
if not r: print(' (no comments)'); sys.exit()
for c in r:
txt = c.get('comment_stripped') or re.sub('<[^>]+>', '', c.get('comment_html', '') or '')
txt = ' '.join(txt.split())
when = (c.get('created_at') or '')[:16].replace('T', ' ')
print(f' [{when}] {txt}')
"
}
# Labels: pllabels [add "name" [#hexcolor]]
# pllabels list project labels
# pllabels add "bug" "#FF0000" create a label
pllabels() {
if [[ "$1" == "add" ]]; then
local name="$2" color="${3:-#6b7280}"
[[ -z "$name" ]] && { echo "Usage: pllabels add \"name\" [#hexcolor]"; return 1; }
_pl_post "$_PL_BASE/projects/$_PL_PROJECT/labels/" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"color":sys.argv[2]}))' "$name" "$color")" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created label {d.get(\"name\",\"?\")} {d.get(\"color\",\"\")}')"
return
fi
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c "
import sys, json
d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', [])
if not r: print(' (no labels yet — pllabels add \"name\" [#hexcolor])'); sys.exit()
for l in r: print(f' {l[\"name\"]:20} {l.get(\"color\",\"\")}')
"
}
# List all workspace projects: plprojects
plprojects() {
_pl_get "$_PL_BASE/projects/" | python3 -c "
import sys, json
projects = json.load(sys.stdin)
projects = projects if isinstance(projects, list) else projects.get('results', [])
active = '${_PL_PROJECT}'
for p in projects:
marker = ' ←' if p['id'] == active else ''
print(f' {p[\"identifier\"]:12} {p[\"name\"]}{marker} ({p[\"id\"]})')
"
}
# Switch active project: pluse [name|identifier|id]
pluse() {
local raw="$1"
local choice id name
if [[ -z "$raw" ]]; then
# fzf picker
choice=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
import sys, json
projects = json.load(sys.stdin)
projects = projects if isinstance(projects, list) else projects.get('results', [])
for p in projects:
print(f'{p[\"id\"]} {p[\"identifier\"]:12} {p[\"name\"]}')
" | fzf --prompt="Switch project > ")
[[ -z "$choice" ]] && return
id=$(echo "$choice" | awk '{print $1}')
name=$(echo "$choice" | awk '{print $3}')
else
# match by name, identifier, or id prefix
local match
match=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
import sys, json
projects = json.load(sys.stdin)
projects = projects if isinstance(projects, list) else projects.get('results', [])
q = sys.argv[1].lower()
for p in projects:
if q in p['name'].lower() or q == p['identifier'].lower() or p['id'].startswith(q):
print(p['id'] + ' ' + p['name'])
break
" "$raw")
if [[ -z "$match" ]]; then
echo "No project matching '$raw'"; return 1
fi
id=$(echo "$match" | awk '{print $1}')
name=$(echo "$match" | awk '{print $2}')
fi
# Fetch states for the new project and update globals
local states_json
states_json=$(_pl_get "$_PL_BASE/projects/$id/states/")
eval "$(python3 -c "
import sys, json
states = json.load(sys.stdin)
states = states if isinstance(states, list) else states.get('results', [])
mapping = {'backlog':'backlog','unstarted':'backlog','started':'progress','in progress':'progress',
'todo':'todo','done':'done','completed':'done','cancelled':'cancelled'}
out = {}
for s in states:
key = mapping.get(s['group'].lower(), s['group'].lower())
out.setdefault(key, s['id'])
parts = ' '.join(f'[{k}]=\"{v}\"' for k,v in out.items())
print(f'typeset -gA _PL_STATE; _PL_STATE=({parts})')
print(f'_PL_PROJECT=\"{sys.argv[1]}\"')
print(f'_PL_PROJECT_NAME=\"{sys.argv[2]}\"')
" "$id" "$name" <<< "$states_json")"
echo "Switched to project: $name"
}
# Sync open issues from all projects → Everything project
# Uses "[IDENTIFIER] title" as the sync key so re-runs are idempotent.
# Closed/cancelled issues are removed from Everything on next sync.
plsync() {
local dry="${1:-}"
local EVERYTHING_ID="7effc903-e24a-474e-a300-91e8e9462538"
local KEY="$_PL_KEY"
local BASE="$_PL_BASE"
python3 - "$dry" "$KEY" "$BASE" "$EVERYTHING_ID" <<'PYEOF'
import json, sys, subprocess
dry = sys.argv[1] == "--dry-run"
key = sys.argv[2]
base = sys.argv[3]
everything = sys.argv[4]
def api(method, path, body=None):
cmd = ["curl", "-s", "-H", f"X-Api-Key: {key}"]
if method in ("POST", "PATCH"):
cmd += ["-X", method, "-H", "Content-Type: application/json", "-d", json.dumps(body)]
elif method == "DELETE":
cmd += ["-X", "DELETE"]
cmd.append(f"{base}{path}")
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
return json.loads(out) if out.strip() else {}
def results(data):
return data if isinstance(data, list) else data.get("results", [])
# Everything state map: group → id
E_STATES = {
"backlog": "f534d9ae-f9a3-4d30-a49e-ebef2a4655f9",
"unstarted": "70497908-c296-4061-962a-127a36e4deea",
"started": "25028084-45cb-4070-8237-3e111b4cde7f",
"completed": "01edc937-7665-4d15-8e28-7f72185dfba5",
"cancelled": "7707cb0a-fd81-4622-96ec-3150842c02f7",
}
DONE_GROUPS = {"completed", "cancelled"}
# ── Collect open issues from all source projects ──────────────────────────────
projects = results(api("GET", "/projects/"))
source = {} # sync_key → {priority, due, state_id}
for p in projects:
pid = p["id"]
ident = p["identifier"]
name = p["name"]
if pid == everything:
continue
print(f" → {name}")
states = {s["id"]: s["group"] for s in results(api("GET", f"/projects/{pid}/states/"))}
issues = results(api("GET", f"/projects/{pid}/issues/"))
for i in issues:
group = states.get(i.get("state", ""), "unstarted")
if group in DONE_GROUPS:
continue
title = i["name"].replace("[", "(").replace("]", ")")
sync_key = f"[{ident}] {title}"
due = (i.get("target_date") or "")[:10] or None
source[sync_key] = {
"priority": i.get("priority", "none"),
"due": due,
"state_id": E_STATES.get(group, E_STATES["unstarted"]),
}
print(f"\n {len(source)} open issues across source projects")
# ── Fetch existing Everything issues (with current field values) ──────────────
existing = {} # sync_key → {id, priority, due, state_id}
e_states = {s["id"]: s["group"] for s in results(api("GET", f"/projects/{everything}/states/"))}
for i in results(api("GET", f"/projects/{everything}/issues/")):
if i["name"].startswith("["):
existing[i["name"]] = {
"id": i["id"],
"priority": i.get("priority", "none"),
"due": (i.get("target_date") or "")[:10] or None,
"state_id": i.get("state"),
}
# ── Create or update ──────────────────────────────────────────────────────────
created = updated = skipped = deleted = 0
for sync_key, meta in source.items():
if sync_key not in existing:
body = {"name": sync_key, "priority": meta["priority"], "state": meta["state_id"]}
if meta["due"]:
body["target_date"] = meta["due"]
if dry:
print(f" [dry] create: {sync_key} [{meta['priority']}] due:{meta['due'] or '—'}")
else:
r = api("POST", f"/projects/{everything}/issues/", body)
print(f" ✓ created #{r.get('sequence_id','?')} {sync_key}")
created += 1
else:
cur = existing[sync_key]
patch = {}
if meta["priority"] != cur["priority"]:
patch["priority"] = meta["priority"]
if meta["due"] != cur["due"]:
patch["target_date"] = meta["due"] or ""
if meta["state_id"] != cur["state_id"]:
patch["state"] = meta["state_id"]
if patch:
if dry:
print(f" [dry] update: {sync_key} {patch}")
else:
api("PATCH", f"/projects/{everything}/issues/{cur['id']}/", patch)
print(f" ↻ updated: {sync_key} {list(patch.keys())}")
updated += 1
else:
skipped += 1
# ── Remove stale (closed at source) ──────────────────────────────────────────
for sync_key, cur in existing.items():
if sync_key not in source:
if dry:
print(f" [dry] remove: {sync_key}")
else:
api("DELETE", f"/projects/{everything}/issues/{cur['id']}/")
print(f" 🗑 removed: {sync_key}")
deleted += 1
print(f"\n✅ Sync complete — created:{created} updated:{updated} skipped:{skipped} removed:{deleted}")
if dry:
print(" (dry run — nothing was changed)")
PYEOF
}